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/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..229dea4 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,42 @@ +# Whenever there is a pull request merged into the branches, create tag +name: Update Environment Tag +on: + pull_request: + types: [closed] + branches: + - develop + - staging + - production +jobs: + update-tag: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Get the current branch name + id: get_branch + run: | + TARGET_BRANCH="${{ github.event.pull_request.base.ref }}" + + if [ "$TARGET_BRANCH" = "develop" ]; then + echo "ENV_TAG=dev" >> $GITHUB_ENV + elif [ "$TARGET_BRANCH" = "staging" ]; then + echo "ENV_TAG=stage" >> $GITHUB_ENV + elif [ "$TARGET_BRANCH" = "production" ]; then + echo "ENV_TAG=prod" >> $GITHUB_ENV + else + echo "ENV_TAG=" >> $GITHUB_ENV + fi + - name: Force update tag + if: ${{ env.ENV_TAG != '' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + echo "Targeting tag: ${{ env.ENV_TAG }}" + git tag -f $ENV_TAG + git push origin $ENV_TAG --force diff --git a/.gitignore b/.gitignore index 42694c4..4516af1 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,10 @@ alembic/versions/.DS_Store /workspaces-openstreetmap-website pg_user_cache.sqlite -.env** \ No newline at end of file +.env** +integration-report.html +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/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..778fd59 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance" + ] +} diff --git a/alembic_osm/versions/9221408912dd_add_user_role_table.py b/alembic_osm/versions/9221408912dd_add_user_role_table.py index d34f97e..7d7fa57 100644 --- a/alembic_osm/versions/9221408912dd_add_user_role_table.py +++ b/alembic_osm/versions/9221408912dd_add_user_role_table.py @@ -11,6 +11,7 @@ import sqlalchemy as sa from alembic import op from sqlalchemy import inspect, text +from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision: str = "9221408912dd" @@ -24,6 +25,25 @@ def upgrade() -> None: assert bind is not None insp = inspect(bind) + # `users` is normally owned and migrated by the OSM Rails app. When + # running against a fresh database without Rails (CI/testcontainers, + # or a dev box without the osm-rails service), create a minimal stub + # so the FK from `tasking_project_roles`/`user_workspace_roles` can + # be added below. Guarded by has_table so the production-owned + # schema wins when it is already present. + if not insp.has_table("users"): + op.create_table( + "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("auth_uid", sa.String(), nullable=False), + sa.Column("email", sa.String(), nullable=True), + sa.Column("display_name", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("auth_uid", name="auth_uid_unique"), + ) + # Add unique constraint on users.auth_uid (if not already present) constraint_exists = bind.execute( text("SELECT 1 FROM pg_constraint WHERE conname = 'auth_uid_unique'") @@ -49,7 +69,7 @@ def upgrade() -> None: sa.Column("workspace_id", sa.BigInteger(), nullable=False), sa.Column( "role", - sa.Enum( + postgresql.ENUM( "lead", "validator", "contributor", diff --git a/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py new file mode 100644 index 0000000..068d28d --- /dev/null +++ b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py @@ -0,0 +1,551 @@ +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect, text +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, None] = "9221408912dd" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# Enum names + values, declared once and reused on up/down. +TASKING_PROJECT_STATUS = ("draft", "open", "done") +TASKING_TASK_STATUS = ("to_map", "to_review", "to_remap", "completed") +TASKING_TASK_BOUNDARY_TYPE = ("grid", "import") +TASKING_LOCK_RELEASE_REASON = ( + "auto_unlock", + "manual", + "lead_release", + "stale_timeout", + "reset", +) +TASKING_FEEDBACK_REASON = ( + "incomplete_mapping", + "data_quality_issue", + "wrong_area", + "other", +) + + +def _create_enum_if_absent(bind, name: str, values: tuple[str, ...]) -> None: + exists = bind.execute( + text("SELECT 1 FROM pg_type WHERE typname = :n"), {"n": name} + ).scalar() + if not exists: + sa.Enum(*values, name=name).create(bind) + + +def _drop_enum_if_present(bind, name: str) -> None: + exists = bind.execute( + text("SELECT 1 FROM pg_type WHERE typname = :n"), {"n": name} + ).scalar() + if exists: + bind.execute(text(f'DROP TYPE IF EXISTS "{name}"')) + + +def _assert_postgis_installed(bind) -> None: + """Require the postgis extension to be installed in this database.""" + installed = bool( + bind.execute( + text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'") + ).scalar() + ) + if not installed: + raise RuntimeError( + "postgis extension is not installed in this database. " + "Run `CREATE EXTENSION IF NOT EXISTS postgis;` before migrations." + ) + + +def upgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + _assert_postgis_installed(bind) + + # ---- teams / team_user ------------------------------------------- + # + # Created here so the OSM tree owns every table that references + # `users.id`. The `has_table` guards keep this idempotent in both + # production (shared TASK/OSM database) and fresh test installs. + + if not insp.has_table("teams"): + op.create_table( + "teams", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("workspace_id", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_teams_workspace_id", "teams", ["workspace_id"]) + + if not insp.has_table("team_user"): + op.create_table( + "team_user", + sa.Column("team_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["team_id"], ["teams.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("team_id", "user_id"), + ) + + # ---- Enums -------------------------------------------------------- + + _create_enum_if_absent(bind, "tasking_project_status", TASKING_PROJECT_STATUS) + _create_enum_if_absent(bind, "tasking_task_status", TASKING_TASK_STATUS) + _create_enum_if_absent( + bind, "tasking_task_boundary_type", TASKING_TASK_BOUNDARY_TYPE + ) + _create_enum_if_absent( + bind, "tasking_lock_release_reason", TASKING_LOCK_RELEASE_REASON + ) + _create_enum_if_absent(bind, "tasking_feedback_reason", TASKING_FEEDBACK_REASON) + + # ---- tasking_projects -------------------------------------------- + + if not insp.has_table("tasking_projects"): + op.create_table( + "tasking_projects", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + # Cross-DB ref to workspaces.id — no FK by design (matches + # user_workspace_roles convention). + sa.Column("workspace_id", sa.BigInteger(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("instructions", sa.Text(), nullable=True), + sa.Column( + "status", + postgresql.ENUM( + *TASKING_PROJECT_STATUS, + name="tasking_project_status", + create_type=False, + ), + nullable=False, + server_default="draft", + ), + sa.Column( + "review_required", + sa.Boolean(), + nullable=False, + server_default=sa.true(), + ), + sa.Column( + "lock_timeout_hours", + sa.Integer(), + nullable=False, + server_default="8", + ), + sa.Column( + "task_boundary_type", + postgresql.ENUM( + *TASKING_TASK_BOUNDARY_TYPE, + name="tasking_task_boundary_type", + create_type=False, + ), + nullable=True, + ), + # AOI is MultiPolygon in EPSG:4326. Polygon inputs are + # upcast to single-member MultiPolygons in the app layer. + sa.Column( + "aoi", + sa.dialects.postgresql.BYTEA(), # placeholder; replaced below + nullable=True, + ), + sa.Column("created_by", sa.Uuid(), nullable=False), + sa.Column("created_by_name", sa.String(length=255), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + + op.execute("ALTER TABLE tasking_projects DROP COLUMN aoi") + op.execute( + "ALTER TABLE tasking_projects " + "ADD COLUMN aoi GEOMETRY(MultiPolygon, 4326)" + ) + + # Unique project name per workspace among non-deleted rows. + op.execute( + "CREATE UNIQUE INDEX tasking_projects_workspace_name_unique " + "ON tasking_projects (workspace_id, lower(name)) " + "WHERE deleted_at IS NULL" + ) + + op.create_index( + "tasking_projects_workspace_idx", + "tasking_projects", + ["workspace_id"], + ) + op.create_index( + "tasking_projects_status_idx", + "tasking_projects", + ["status"], + ) + + # ---- tasking_project_roles --------------------------------------- + + if not insp.has_table("tasking_project_roles"): + op.create_table( + "tasking_project_roles", + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("user_auth_uid", sa.String(), nullable=False), + sa.Column( + "role", + postgresql.ENUM( + "lead", + "validator", + "contributor", + name="workspace_role", + create_type=False, + ), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + sa.PrimaryKeyConstraint("project_id", "user_auth_uid"), + ) + + # ---- tasking_tasks ------------------------------------------------ + + if not insp.has_table("tasking_tasks"): + op.create_table( + "tasking_tasks", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + 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( + "status", + postgresql.ENUM( + *TASKING_TASK_STATUS, + name="tasking_task_status", + create_type=False, + ), + nullable=False, + server_default="to_map", + ), + sa.Column("last_mapper_id", sa.String(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.UniqueConstraint( + "project_id", "task_number", name="tasking_tasks_pn_unique" + ), + ) + op.execute( + "ALTER TABLE tasking_tasks " + "ADD COLUMN geometry GEOMETRY(Polygon, 4326) NOT NULL" + ) + op.execute( + "CREATE INDEX tasking_tasks_geometry_idx " + "ON tasking_tasks USING GIST (geometry)" + ) + op.create_index("tasking_tasks_project_idx", "tasking_tasks", ["project_id"]) + + # ---- tasking_locks ------------------------------------------------ + + if not insp.has_table("tasking_locks"): + op.create_table( + "tasking_locks", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("task_id", sa.BigInteger(), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("user_auth_uid", sa.String(), nullable=False), + sa.Column( + "task_status_at_lock", + postgresql.ENUM( + *TASKING_TASK_STATUS, + name="tasking_task_status", + create_type=False, + ), + nullable=False, + ), + sa.Column( + "locked_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("released_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "release_reason", + postgresql.ENUM( + *TASKING_LOCK_RELEASE_REASON, + name="tasking_lock_release_reason", + create_type=False, + ), + nullable=True, + ), + sa.ForeignKeyConstraint( + ["task_id"], ["tasking_tasks.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + ) + # One active lock per task; one active lock per (project, user). + op.execute( + "CREATE UNIQUE INDEX tasking_locks_one_active_per_task " + "ON tasking_locks (task_id) WHERE released_at IS NULL" + ) + op.execute( + "CREATE UNIQUE INDEX tasking_locks_one_active_per_user_project " + "ON tasking_locks (project_id, user_auth_uid) " + "WHERE released_at IS NULL" + ) + op.execute( + "CREATE INDEX tasking_locks_expiry_idx " + "ON tasking_locks (expires_at) WHERE released_at IS NULL" + ) + + # ---- tasking_changesets ------------------------------------------ + + if not insp.has_table("tasking_changesets"): + op.create_table( + "tasking_changesets", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("task_id", sa.BigInteger(), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("lock_id", sa.BigInteger(), nullable=False), + sa.Column("user_auth_uid", sa.String(), nullable=False), + sa.Column("osm_changeset_id", sa.BigInteger(), nullable=False), + sa.Column( + "submitted_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["task_id"], ["tasking_tasks.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["lock_id"], ["tasking_locks.id"]), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + ) + op.create_index( + "tasking_changesets_task_idx", "tasking_changesets", ["task_id"] + ) + + # ---- tasking_feedback -------------------------------------------- + # + # Generic per-task feedback table. Covers validator remap rejections + # (originally the only use case) plus any other free-form notes a + # contributor / validator / lead may attach to a task — approval + # comments, follow-up reminders, etc. + # + # `reason_category` is nullable: required only when the feedback is + # used to drive a `to_review → to_remap` transition; left NULL for + # generic notes. `notes` is required so a row always has at least + # one of (category, free text) — usually both. + + if not insp.has_table("tasking_feedback"): + op.create_table( + "tasking_feedback", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("task_id", sa.BigInteger(), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("author_user_auth_uid", sa.String(), nullable=False), + sa.Column( + "reason_category", + postgresql.ENUM( + *TASKING_FEEDBACK_REASON, + name="tasking_feedback_reason", + create_type=False, + ), + nullable=True, + ), + sa.Column("notes", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["task_id"], ["tasking_tasks.id"], ondelete="CASCADE" + ), + 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"]) + op.create_index( + "tasking_feedback_project_idx", "tasking_feedback", ["project_id"] + ) + + # ---- tasking_audit_events ---------------------------------------- + + if not insp.has_table("tasking_audit_events"): + op.create_table( + "tasking_audit_events", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("event_type", sa.String(length=64), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("task_id", sa.BigInteger(), nullable=True), + sa.Column("actor_user_auth_uid", sa.Uuid(), nullable=False), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "details", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column( + "project_deleted", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + # No FK to tasking_projects so audit survives the + # project's hard-delete of children + soft-delete itself. + ) + op.create_index( + "tasking_audit_project_idx", "tasking_audit_events", ["project_id"] + ) + op.create_index( + "tasking_audit_project_task_idx", + "tasking_audit_events", + ["project_id", "task_id"], + ) + op.create_index( + "tasking_audit_occurred_idx", + "tasking_audit_events", + ["occurred_at"], + ) + + # ---- tasking_task_save_idempotency ------------------------------- + + if not insp.has_table("tasking_task_save_idempotency"): + op.create_table( + "tasking_task_save_idempotency", + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("key", sa.String(length=128), nullable=False), + sa.Column("body_hash", sa.String(length=128), nullable=False), + sa.Column( + "response_json", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("project_id", "key"), + ) + op.create_index( + "tasking_task_save_idempotency_created_idx", + "tasking_task_save_idempotency", + ["created_at"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + # Drop tables in reverse FK order. + for table in ( + "tasking_task_save_idempotency", + "tasking_audit_events", + "tasking_feedback", + "tasking_changesets", + "tasking_locks", + "tasking_tasks", + "tasking_project_roles", + "tasking_projects", + "team_user", + "teams", + ): + if insp.has_table(table): + op.drop_table(table) + + # Drop tasking-specific enums (workspace_role is owned by an earlier + # revision and stays). + for enum_name in ( + "tasking_feedback_reason", + "tasking_lock_release_reason", + "tasking_task_boundary_type", + "tasking_task_status", + "tasking_project_status", + ): + _drop_enum_if_present(bind, enum_name) diff --git a/alembic_task/versions/add6266277c7_.py b/alembic_task/versions/add6266277c7_.py index ed80db5..cd27884 100644 --- a/alembic_task/versions/add6266277c7_.py +++ b/alembic_task/versions/add6266277c7_.py @@ -13,7 +13,7 @@ revision = "add6266277c7" branch_labels = None depends_on = None -down_revision = None +down_revision = "c5121cbba124" def upgrade(): diff --git a/alembic_task/versions/c5121cbba124_initial_task_schema.py b/alembic_task/versions/c5121cbba124_initial_task_schema.py new file mode 100644 index 0000000..a112007 --- /dev/null +++ b/alembic_task/versions/c5121cbba124_initial_task_schema.py @@ -0,0 +1,112 @@ +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from geoalchemy2 import Geometry +from sqlalchemy import inspect, text +from sqlalchemy.dialects import postgresql + +revision: str = "c5121cbba124" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _assert_postgis_installed(bind) -> None: + """Require the postgis extension to be installed in this database.""" + installed = bool( + bind.execute( + text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'") + ).scalar() + ) + if not installed: + raise RuntimeError( + "postgis extension is not installed in this database. " + "Run `CREATE EXTENSION IF NOT EXISTS postgis;` before migrations." + ) + + +def upgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + _assert_postgis_installed(bind) + + geometry_column = sa.Column( + "geometry", + Geometry(geometry_type="MULTIPOLYGON", srid=4326), + nullable=True, + ) + + # The TASK tree owns `workspaces` and `workspaces_*` only. + # `users`, `teams`, `team_user`, `user_workspace_roles`, and the + # `tasking_*` tables are owned by the OSM tree. + + if not insp.has_table("workspaces"): + op.create_table( + "workspaces", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("type", sa.String(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + 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("createdAt", sa.DateTime(), nullable=False), + sa.Column("createdBy", sa.Uuid(), nullable=False), + sa.Column("createdByName", sa.String(), nullable=False), + geometry_column, + sa.Column( + "externalAppAccess", + sa.SmallInteger(), + nullable=False, + server_default="0", + ), + sa.Column("kartaViewToken", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + if not insp.has_table("workspaces_long_quests"): + op.create_table( + "workspaces_long_quests", + sa.Column("workspace_id", sa.Integer(), nullable=False), + sa.Column("definition", sa.String(), nullable=True), + sa.Column("modifiedAt", sa.DateTime(), nullable=False), + sa.Column("modifiedBy", sa.Uuid(), nullable=False), + sa.Column("modifiedByName", sa.String(), nullable=False), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("workspace_id"), + ) + + if not insp.has_table("workspaces_imagery"): + op.create_table( + "workspaces_imagery", + sa.Column("workspace_id", sa.Integer(), nullable=False), + sa.Column( + "definition", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + ), + sa.Column("modifiedAt", sa.DateTime(), nullable=False), + sa.Column("modifiedBy", sa.Uuid(), nullable=False), + sa.Column("modifiedByName", sa.String(), nullable=False), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("workspace_id"), + ) + + +def downgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + if insp.has_table("workspaces_imagery"): + op.drop_table("workspaces_imagery") + if insp.has_table("workspaces_long_quests"): + op.drop_table("workspaces_long_quests") + if insp.has_table("workspaces"): + op.drop_table("workspaces") diff --git a/api/core/security.py b/api/core/security.py index c283af4..6a8f350 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -43,6 +43,104 @@ async def close_tdei_client() -> None: _tdei_client = None +class TdeiProjectGroupUser: + """One member of a TDEI project group, as returned by + ``GET /project-group/{pg_id}/users``. + + Field names are normalised here because the upstream JSON shape varies + across deployments (`user_id` vs `id`, `username` vs `display_name`). + """ + + def __init__( + self, + *, + auth_uid: str, + email: str | None, + display_name: str | None, + ) -> None: + self.auth_uid = auth_uid + self.email = email + self.display_name = display_name + + +async def fetch_project_group_users( + project_group_id: str, + bearer_token: str, +) -> list[TdeiProjectGroupUser]: + """Page through ``GET /project-group/{pg_id}/users`` on TDEI. + + Returns every member of the project group. The endpoint is paginated + server-side; we fetch all pages so the caller can look up an + arbitrary user UUID without guessing page numbers. Raises 502 if + TDEI is unreachable, 401 if the token is rejected. + """ + if _tdei_client is None: # pragma: no cover — lifespan should init this + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="TDEI client is not initialised", + ) + + headers = {"Authorization": f"Bearer {bearer_token}"} + page_no = 1 + page_size = 200 + out: list[TdeiProjectGroupUser] = [] + while True: + try: + response = await _tdei_client.get( + f"project-group/{project_group_id}/users", + headers=headers, + params={"page_no": page_no, "page_size": page_size}, + ) + except httpx.RequestError: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Could not reach TDEI backend to fetch project group users", + ) from None + + if response.status_code == 401: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="TDEI rejected the bearer token", + ) + if response.status_code != 200: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=( + f"TDEI returned {response.status_code} when listing " + f"users for project group {project_group_id}" + ), + ) + + try: + page = response.json() + except Exception: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="TDEI returned a non-JSON body", + ) from None + + if not isinstance(page, list) or not page: + break + + for row in page: + uid = row.get("user_id") + if not uid: + continue + out.append( + TdeiProjectGroupUser( + auth_uid=str(uid), + email=row.get("email"), + display_name=(row.get("username")), + ) + ) + + if len(page) < page_size: + break + page_no += 1 + + return out + + def evict_user_from_cache(auth_uid: UUID) -> None: """ Evict a user's cached UserInfo object so that their next request re-fetches diff --git a/api/main.py b/api/main.py index dd82fcc..fcff505 100644 --- a/api/main.py +++ b/api/main.py @@ -22,6 +22,10 @@ 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 from api.src.teams.routes import router as teams_router from api.src.users.routes import router as users_router from api.src.workspaces.repository import WorkspaceRepository @@ -91,6 +95,10 @@ async def lifespan(_app: FastAPI): app.include_router(teams_router, prefix="/api/v1") app.include_router(users_router, prefix="/api/v1") app.include_router(workspaces_router, prefix="/api/v1") +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") @@ -208,6 +216,20 @@ async def catch_all( Catch-all route to proxy requests to the OSM service. """ + # `/api/v1/...` paths belong to the FastAPI routers (workspaces, + # users, teams, tasking-projects, tasking-tasks). If none matched, + # the URL is wrong or the method is unsupported — surface that as + # a clean 404 instead of letting the OSM proxy swallow it with a + # misleading "No X-Workspace header supplied". + if request.url.path.startswith("/api/v1/"): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=( + f"No handler for {request.method} {request.url.path}. " + "Check the method and path; this URL is not proxied to OSM." + ), + ) + if request.headers.get("X-Workspace") is not None: try: workspace_id = int(request.headers.get("X-Workspace") or "-1") diff --git a/api/src/tasking/__init__.py b/api/src/tasking/__init__.py new file mode 100644 index 0000000..e69de29 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..c8003de --- /dev/null +++ b/api/src/tasking/audit/dtos.py @@ -0,0 +1,39 @@ +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 + 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..0234f0f --- /dev/null +++ b/api/src/tasking/audit/repository.py @@ -0,0 +1,269 @@ +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 {} + return AuditEvent( + id=event_id, + event_type=AuditEventType(event_type), + project_id=project_id, + 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..9dc27be --- /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" + TASKS_CREATED = "tasks_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/__init__.py b/api/src/tasking/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py new file mode 100644 index 0000000..488e981 --- /dev/null +++ b/api/src/tasking/projects/dtos.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal, Optional +from uuid import UUID + +from pydantic import BaseModel, ConfigDict +from pydantic import Field as PydField +from pydantic import field_validator + +from api.src.tasking.projects.schemas import ( + AoiInput, + ProjectRoleType, + ProjectStatus, + TaskBoundaryType, + _MultiPolygon, +) + +# --------------------------------------------------------------------------- +# Shared DTO base. +# --------------------------------------------------------------------------- + + +class WireModel(BaseModel): + + model_config = ConfigDict(extra="forbid") + + +# --------------------------------------------------------------------------- +# Project DTOs +# --------------------------------------------------------------------------- + + +class ProjectRoleAssignment(WireModel): + """Seed entry for `tasking_project_roles` at create time.""" + + user_id: UUID + role: Literal["lead", "validator", "contributor"] + + +class ProjectCreateRequest(WireModel): + """Body for `POST /workspaces/{wid}/tasking/projects`.""" + + name: str = PydField(min_length=1, max_length=255) + instructions: Optional[str] = PydField(default=None, max_length=10_000) + review_required: bool = True + lock_timeout_hours: int = PydField(default=8, ge=1, le=720) + aoi: Optional[AoiInput] = None + role_assignments: list[ProjectRoleAssignment] = PydField(default_factory=list) + + @field_validator("name") + @classmethod + def _name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name cannot be blank") + return v + + +class ProjectUpdateRequest(WireModel): + """Body for `PATCH /workspaces/{wid}/tasking/projects/{pid}`. + + All fields optional; per-status mutability is enforced in the + repository layer. + """ + + name: Optional[str] = PydField(default=None, min_length=1, max_length=255) + instructions: Optional[str] = PydField(default=None, max_length=10_000) + lock_timeout_hours: Optional[int] = PydField(default=None, ge=1, le=720) + review_required: Optional[bool] = None + + +class ProjectResponse(WireModel): + """Project detail returned by GET/POST/PATCH/activate/close/reset.""" + + id: int + workspace_id: int + name: str + instructions: Optional[str] = None + status: ProjectStatus + review_required: bool + lock_timeout_hours: int + task_boundary_type: Optional[TaskBoundaryType] = None + has_aoi: bool + task_count: int + created_by: UUID + created_by_name: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class ProjectListItem(WireModel): + """Row for `GET /workspaces/{wid}/tasking/projects`.""" + + id: int + name: str + status: ProjectStatus + task_count: int + percent_completed: int + created_by: UUID + created_by_name: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class Pagination(WireModel): + page: int + page_size: int + total: int + + +class ProjectListResponse(WireModel): + results: list[ProjectListItem] + pagination: Pagination + + +# --------------------------------------------------------------------------- +# AOI response shape (canonical Feature wrapping a MultiPolygon) +# --------------------------------------------------------------------------- + + +class AoiFeature(WireModel): + type: Literal["Feature"] = "Feature" + geometry: _MultiPolygon + properties: dict[str, Any] = PydField(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Project-role management DTOs +# --------------------------------------------------------------------------- + + +class ProjectRoleItem(WireModel): + """One row of `GET /projects/{pid}/roles`.""" + + user_id: UUID + user_name: Optional[str] = None + role: ProjectRoleType + updated_at: datetime + + +class ProjectRoleListResponse(WireModel): + """Paginated-but-flat list of role assignments for a project.""" + + results: list[ProjectRoleItem] + + +class ProjectRoleAddRequest(WireModel): + """Body for `POST /projects/{pid}/roles`.""" + + user_id: UUID + role: ProjectRoleType + + +class ProjectRoleUpdateRequest(WireModel): + """Body for `PATCH /projects/{pid}/roles/{user_id}`.""" + + role: ProjectRoleType + + +# --------------------------------------------------------------------------- +# Self project-roles — `GET /me/workspaces/{wid}/tasking/projects/roles`. +# One row per project in the workspace with the caller's effective role on +# that project: explicit `tasking_project_roles` row if present, else the +# workspace-level role (which is the implicit fallback). +# --------------------------------------------------------------------------- + + +class SelfProjectRoleItem(WireModel): + project_id: int + project_name: str + project_status: ProjectStatus + role: ProjectRoleType + + +class SelfProjectRolesResponse(WireModel): + workspace_role: ProjectRoleType + projects: list[SelfProjectRoleItem] + + +__all__ = [ + "AoiFeature", + "Pagination", + "ProjectCreateRequest", + "ProjectListItem", + "ProjectListResponse", + "ProjectResponse", + "ProjectRoleAddRequest", + "ProjectRoleAssignment", + "ProjectRoleItem", + "ProjectRoleListResponse", + "ProjectRoleUpdateRequest", + "ProjectUpdateRequest", + "SelfProjectRoleItem", + "SelfProjectRolesResponse", + "WireModel", +] diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py new file mode 100644 index 0000000..eaa81eb --- /dev/null +++ b/api/src/tasking/projects/repository.py @@ -0,0 +1,1323 @@ +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any +from uuid import UUID + +from fastapi import HTTPException, status +from geoalchemy2.shape import from_shape, to_shape +from shapely.geometry import MultiPolygon as ShapelyMultiPolygon +from shapely.geometry import Polygon as ShapelyPolygon +from shapely.geometry import shape as shapely_shape +from sqlalchemy import func, or_, select, text, update +from sqlalchemy.exc import IntegrityError +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import ( + AlreadyExistsException, + ForbiddenException, + NotFoundException, +) +from api.core.security import UserInfo +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import ( + AoiFeature, + Pagination, + ProjectCreateRequest, + ProjectListItem, + ProjectListResponse, + ProjectResponse, + ProjectRoleAddRequest, + ProjectRoleItem, + ProjectRoleListResponse, + ProjectRoleUpdateRequest, + ProjectUpdateRequest, + SelfProjectRoleItem, + SelfProjectRolesResponse, +) +from api.src.tasking.projects.schemas import ( + AoiInput, + ProjectRoleType, + ProjectStatus, + TaskingProject, + _Feature, + _FeatureCollection, + _MultiPolygon, + _Polygon, +) + +# --------------------------------------------------------------------------- +# AOI helpers +# --------------------------------------------------------------------------- + + +def _aoi_to_shapely(aoi: AoiInput) -> ShapelyMultiPolygon: + """Normalise any of the accepted GeoJSON shapes to a Shapely + MultiPolygon. Bare Polygons are upcast to a single-member + MultiPolygon — storage column is always MULTIPOLYGON(4326). + """ + if isinstance(aoi, _FeatureCollection): + geom_dict = aoi.features[0].geometry.model_dump() + elif isinstance(aoi, _Feature): + geom_dict = aoi.geometry.model_dump() + elif isinstance(aoi, (_Polygon, _MultiPolygon)): + geom_dict = aoi.model_dump() + else: # pragma: no cover — Pydantic guards against this + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Unsupported AOI shape", + ) + + try: + geom = shapely_shape(geom_dict) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid AOI geometry: {e}", + ) from None + + if isinstance(geom, ShapelyPolygon): + geom = ShapelyMultiPolygon([geom]) + elif not isinstance(geom, ShapelyMultiPolygon): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="AOI must be a Polygon or MultiPolygon", + ) + + if not geom.is_valid: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"AOI is not a valid polygon: {geom.is_valid_reason if hasattr(geom, 'is_valid_reason') else 'self-intersection or invalid ring'}", + ) + + return geom + + +def _shapely_to_aoi_feature(geom: ShapelyMultiPolygon) -> AoiFeature: + """Build the GeoJSON Feature wrapper returned by the AOI GET endpoint.""" + raw = geom.__geo_interface__ + return AoiFeature( + type="Feature", + geometry=_MultiPolygon( + type="MultiPolygon", + coordinates=raw["coordinates"], + ), + properties={}, + ) + + +# --------------------------------------------------------------------------- +# Constraint translation — map Postgres `IntegrityError` to a precise +# HTTPException keyed by constraint name. Avoids the generic +# "everything is 409: name already exists" message. +# --------------------------------------------------------------------------- + + +def _constraint_name(e: IntegrityError) -> str | None: + """Return the PG constraint name from an `IntegrityError`, or None.""" + orig = getattr(e, "orig", None) + name = getattr(orig, "constraint_name", None) + if name: + return name + inner = getattr(orig, "__cause__", None) + return getattr(inner, "constraint_name", None) + + +def _translate_integrity_error(e: IntegrityError) -> HTTPException: + """Convert a Postgres constraint violation into an HTTPException.""" + name = _constraint_name(e) or "" + + if name == "tasking_projects_workspace_name_unique": + return AlreadyExistsException( + "A project with this name already exists in the workspace" + ) + + if name == "tasking_project_roles_user_auth_uid_fkey": + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "One or more `role_assignments[].user_id` values refer " + "to a user that has not signed in to Workspaces yet." + ), + ) + + if name == "tasking_tasks_project_id_fkey": + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Cannot insert tasks: parent project does not exist.", + ) + + # NOT NULL violations surface with no constraint_name on asyncpg. + orig_class = type(getattr(e, "orig", None)).__name__ + if orig_class == "NotNullViolationError": + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Required field is missing.", + ) + + if name: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Database constraint violated: {name}", + ) + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Database constraint violated.", + ) + + +# --------------------------------------------------------------------------- +# Repository +# --------------------------------------------------------------------------- + + +class TaskingProjectRepository: + """CRUD and lifecycle for tasking projects. + + Methods assume the caller has already passed the workspace tenancy + gate (`WorkspaceRepository.getById`); that check is performed at + the route layer. + """ + + def __init__(self, session: AsyncSession): + self.session = session + + async def _audit( + self, + *, + event_type: AuditEventType, + project_id: int, + actor_uuid: UUID, + details: dict[str, Any] | None = None, + ) -> None: + await self.session.execute( + text( + "INSERT INTO tasking_audit_events " + "(event_type, project_id, task_id, actor_user_auth_uid, details) " + "VALUES (:et, :pid, NULL, :uid, CAST(:dt AS jsonb))" + ), + { + "et": event_type, + "pid": project_id, + "uid": str(actor_uuid), + "dt": json.dumps(details or {}), + }, + ) + + # ---- internal helpers -------------------------------------------- + + 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( + (TaskingProject.id == project_id) + & (TaskingProject.workspace_id == workspace_id) + & (TaskingProject.deleted_at.is_(None)) + ) + ) + project = result.scalar_one_or_none() + if project is None: + raise NotFoundException(f"Project {project_id} not found") + return project + + @staticmethod + def _to_response(project: TaskingProject, task_count: int = 0) -> ProjectResponse: + return ProjectResponse( + id=project.id, # type: ignore[arg-type] + workspace_id=project.workspace_id, + name=project.name, + instructions=project.instructions, + status=project.status, + review_required=project.review_required, + lock_timeout_hours=project.lock_timeout_hours, + task_boundary_type=project.task_boundary_type, + has_aoi=project.aoi is not None, + task_count=task_count, + created_by=project.created_by, + created_by_name=project.created_by_name, + created_at=project.created_at, + updated_at=project.updated_at, + ) + + async def _provision_users_from_tdei( + self, + missing_uuids: list[str], + project_group_id: str, + bearer_token: str, + ) -> list[str]: + """Look up `missing_uuids` against TDEI's project-group members + and insert matching rows into `users`. Return the UUIDs that are + still unknown (not members of the project group at all). + + This is the auto-provisioning fallback for `role_assignments[]`: + 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 sqlalchemy import text + + from api.core.security import fetch_project_group_users + + try: + members = await fetch_project_group_users(project_group_id, bearer_token) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to fetch project group users from TDEI: {e}", + ) from e + + by_uid = {m.auth_uid: m for m in members} + + resolved: set[str] = set() + for uid in missing_uuids: + member = by_uid.get(uid) + if member is None: + continue + await self.session.execute( + text( + "INSERT INTO users (auth_uid, email, display_name) " + "VALUES (:uid, :email, :name) " + "ON CONFLICT (auth_uid) DO NOTHING" + ), + { + "uid": uid, + "email": member.email, + "name": member.display_name, + }, + ) + resolved.add(uid) + + if resolved: + # Commit the new `users` rows in their own transaction so the + # subsequent FK insert into `tasking_project_roles` resolves. + await self.session.commit() + + return [u for u in missing_uuids if u not in resolved] + + 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 + downstream inserts produce a clean 422 with the offending ids + instead of a 23503 foreign-key-violation. + """ + if not uuids: + return [] + + from sqlalchemy import text + + rows = await self.session.execute( + 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()} + return [str(u) for u in uuids if str(u) not in existing] + + async def _task_count(self, project_id: int) -> int: + """Read-only task count for a project; raw SQL to keep this + module independent of the tasks sub-module's ORM.""" + from sqlalchemy import text + + result = await self.session.execute( + text("SELECT COUNT(*) FROM tasking_tasks WHERE project_id = :pid"), + {"pid": project_id}, + ) + return int(result.scalar() or 0) + + # ---- create / list / get / patch / delete ------------------------ + + async def list_projects( + self, + workspace_id: int, + *, + status_filter: ProjectStatus | None = None, + text_search: str | None = None, + page: int = 1, + page_size: int = 20, + order_by: str = "created_at", + order_dir: str = "DESC", + ) -> ProjectListResponse: + valid_order = { + "created_at": TaskingProject.created_at, + "updated_at": TaskingProject.updated_at, + "name": TaskingProject.name, + } + col = valid_order.get(order_by, TaskingProject.created_at) + col = col.desc() if order_dir.upper() == "DESC" else col.asc() + + where = (TaskingProject.workspace_id == workspace_id) & ( + TaskingProject.deleted_at.is_(None) + ) + if status_filter is not None: + where = where & (TaskingProject.status == status_filter) + if text_search: + where = where & ( + func.lower(TaskingProject.name).contains(text_search.lower()) + ) + + total_q = await self.session.execute( + select(func.count()).select_from(TaskingProject).where(where) + ) + total = int(total_q.scalar() or 0) + + page = max(page, 1) + page_size = max(min(page_size, 200), 1) + offset = (page - 1) * page_size + + rows = await self.session.execute( + select(TaskingProject) + .where(where) + .order_by(col) + .limit(page_size) + .offset(offset) + ) + projects = list(rows.scalars().all()) + + # task counts in one round trip + counts: dict[int, int] = {} + if projects: + from sqlalchemy import text + + ids = [p.id for p in projects] + cnt_rows = await self.session.execute( + text( + "SELECT project_id, COUNT(*) FROM tasking_tasks " + "WHERE project_id = ANY(:ids) GROUP BY project_id" + ), + {"ids": ids}, + ) + counts = {pid: int(c) for pid, c in cnt_rows.all()} + + items: list[ProjectListItem] = [] + for p in projects: + tc = counts.get(p.id, 0) # type: ignore[arg-type] + completed = 0 + if tc > 0: + from sqlalchemy import text + + done_q = await self.session.execute( + text( + "SELECT COUNT(*) FROM tasking_tasks " + "WHERE project_id = :pid AND status = 'completed'" + ), + {"pid": p.id}, + ) + completed = int(done_q.scalar() or 0) + pct = int(round((completed / tc) * 100)) if tc > 0 else 0 + items.append( + ProjectListItem( + id=p.id, # type: ignore[arg-type] + name=p.name, + status=p.status, + task_count=tc, + percent_completed=pct, + created_by=p.created_by, + created_by_name=p.created_by_name, + created_at=p.created_at, + updated_at=p.updated_at, + ) + ) + + return ProjectListResponse( + results=items, + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + async def create( + self, + workspace_id: int, + current_user: UserInfo, + body: ProjectCreateRequest, + tdei_project_group_id: str, + ) -> ProjectResponse: + # Preflight every user_auth_uid that will be inserted into + # `tasking_project_roles` — the creator's auto-LEAD seed plus + # any explicit role_assignments. Any UUID missing from `users` + # is looked up against TDEI's project-group member list and + # auto-provisioned; only UUIDs that are not members at all + # surface as a 422 to the caller. + candidate_uuids: list[UUID] = [current_user.user_uuid] + candidate_uuids.extend(ra.user_id for ra in body.role_assignments or []) + missing = await self._missing_user_auth_uids(candidate_uuids) + + if missing: + creator_uid = str(current_user.user_uuid) + if creator_uid in missing: + # The signed-in caller is not in `users`. Pull them + # from TDEI just like any other role-assignment user; + # if even TDEI doesn't know them, surface 409 because + # the request can never succeed. + pass + + # Auto-provision via TDEI for the members of this project group. + still_missing = await self._provision_users_from_tdei( + missing, + project_group_id=tdei_project_group_id, + bearer_token=current_user.credentials, + ) + + if creator_uid in still_missing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "Your user record could not be provisioned: TDEI " + "does not list you as a member of this project " + "group. Sign in to Workspaces once, then retry." + ), + ) + if still_missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "One or more `role_assignments[].user_id` " + "values are not members of this workspace's " + "project group in TDEI." + ), + "missing_user_ids": still_missing, + }, + ) + + project = TaskingProject( + workspace_id=workspace_id, + name=body.name, + instructions=body.instructions, + review_required=body.review_required, + lock_timeout_hours=body.lock_timeout_hours, + created_by=current_user.user_uuid, + created_by_name=current_user.user_name, + ) + if body.aoi is not None: + geom = _aoi_to_shapely(body.aoi) + project.aoi = from_shape(geom, srid=4326) + + try: + self.session.add(project) + await self.session.flush() # need project.id for role rows + + # Seed project-level role overrides. + if body.role_assignments: + from sqlalchemy import text + + for ra in body.role_assignments: + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role) " + "VALUES (:pid, :uid, :role) " + "ON CONFLICT (project_id, user_auth_uid) " + "DO UPDATE SET role = EXCLUDED.role, " + " updated_at = NOW()" + ), + { + "pid": project.id, + "uid": str(ra.user_id), + "role": ra.role, + }, + ) + + # Creator is auto-assigned the LEAD role on the project, + # mirroring the workspace-creator auto-LEAD convention. + from sqlalchemy import text + + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role) " + "VALUES (:pid, :uid, 'lead') " + "ON CONFLICT DO NOTHING" + ), + { + "pid": project.id, + "uid": str(current_user.user_uuid), + }, + ) + + await self._audit( + event_type=AuditEventType.PROJECT_CREATED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + details={"name": project.name}, + ) + await self.session.commit() + await self.session.refresh(project) + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + + return self._to_response(project) + + async def get(self, workspace_id: int, project_id: int) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + tc = await self._task_count(project.id) # type: ignore[arg-type] + return self._to_response(project, task_count=tc) + + async def patch( + self, + workspace_id: int, + project_id: int, + body: ProjectUpdateRequest, + current_user: UserInfo, + ) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + + if project.status == ProjectStatus.DONE: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="A closed project cannot be edited", + ) + + # `review_required` immutable after activation + if ( + body.review_required is not None + and project.status != ProjectStatus.DRAFT + and body.review_required != project.review_required + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="review_required is immutable after activation", + ) + + updates: dict[str, Any] = {} + if body.name is not None: + updates["name"] = body.name.strip() + if body.instructions is not None: + updates["instructions"] = body.instructions + if body.lock_timeout_hours is not None: + updates["lock_timeout_hours"] = body.lock_timeout_hours + if body.review_required is not None: + updates["review_required"] = body.review_required + + if updates: + updates["updated_at"] = datetime.now() + try: + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values(**updates) + ) + await self._audit( + event_type=AuditEventType.PROJECT_EDITED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + details={ + "updatedFields": [k for k in updates if k != "updated_at"] + }, + ) + await self.session.commit() + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + await self.session.refresh(project) + + tc = await self._task_count(project.id) # type: ignore[arg-type] + return self._to_response(project, task_count=tc) + + async def soft_delete( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> None: + project = await self._get_active(workspace_id, project_id) + + # Refuse if any active task locks remain. + from sqlalchemy import text + + active = await self.session.execute( + text( + "SELECT 1 FROM tasking_locks " + "WHERE project_id = :pid AND released_at IS NULL LIMIT 1" + ), + {"pid": project.id}, + ) + if active.scalar() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project has active task locks; force-release first", + ) + + # Soft-delete the project, hard-delete its tasks, flag audit rows. + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values(deleted_at=datetime.now()) + ) + await self.session.execute( + text("DELETE FROM tasking_tasks WHERE project_id = :pid"), + {"pid": project.id}, + ) + # Insert the deletion event before flagging so this row is caught too. + await self._audit( + event_type=AuditEventType.PROJECT_DELETED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.execute( + text( + "UPDATE tasking_audit_events SET project_deleted = TRUE " + "WHERE project_id = :pid" + ), + {"pid": project.id}, + ) + await self.session.commit() + + # ---- lifecycle transitions --------------------------------------- + + async def activate( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Only draft projects can be activated", + ) + if not project.name.strip(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project name is required", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required", + ) + tc = await self._task_count(project.id) # type: ignore[arg-type] + if tc == 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project must have at least one task", + ) + + # Activation requires at least one explicit contributor or + # validator allocation (creator's auto-LEAD does not count). + from sqlalchemy import text + + worker_q = await self.session.execute( + text( + "SELECT 1 FROM tasking_project_roles " + "WHERE project_id = :pid AND role IN ('contributor', 'validator') " + "LIMIT 1" + ), + {"pid": project.id}, + ) + if worker_q.scalar() is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="At least one contributor or validator must be allocated to the project", + ) + + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values(status=ProjectStatus.OPEN, updated_at=datetime.now()) + ) + await self._audit( + event_type=AuditEventType.PROJECT_ACTIVATED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.commit() + await self.session.refresh(project) + return self._to_response(project, task_count=tc) + + async def close( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.OPEN: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Only open projects can be closed", + ) + + from sqlalchemy import text + + not_done = await self.session.execute( + text( + "SELECT 1 FROM tasking_tasks " + "WHERE project_id = :pid AND status <> 'completed' LIMIT 1" + ), + {"pid": project.id}, + ) + if not_done.scalar() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project has tasks that are not yet completed", + ) + active_lock = await self.session.execute( + text( + "SELECT 1 FROM tasking_locks " + "WHERE project_id = :pid AND released_at IS NULL LIMIT 1" + ), + {"pid": project.id}, + ) + if active_lock.scalar() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project has active task locks", + ) + + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values(status=ProjectStatus.DONE, updated_at=datetime.now()) + ) + await self._audit( + event_type=AuditEventType.PROJECT_CLOSED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.commit() + await self.session.refresh(project) + 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, current_user: UserInfo + ) -> ProjectResponse: + """LEAD reset — see spec §projects.""" + project = await self._get_active(workspace_id, project_id) + if project.status == ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Cannot reset a draft project (nothing to reset)", + ) + + from sqlalchemy import text + + # Release every active lock with release_reason='reset'. + await self.session.execute( + text( + "UPDATE tasking_locks " + "SET released_at = NOW(), release_reason = 'reset' " + "WHERE project_id = :pid AND released_at IS NULL" + ), + {"pid": project.id}, + ) + # Wind tasks back to to_map; clear last_mapper_id. + await self.session.execute( + text( + "UPDATE tasking_tasks " + "SET status = 'to_map', last_mapper_id = NULL, updated_at = NOW() " + "WHERE project_id = :pid AND status <> 'to_map'" + ), + {"pid": project.id}, + ) + # Project reopens if it was done. + if project.status == ProjectStatus.DONE: + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values(status=ProjectStatus.OPEN, updated_at=datetime.now()) + ) + + await self._audit( + event_type=AuditEventType.PROJECT_RESET, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.commit() + await self.session.refresh(project) + tc = await self._task_count(project.id) # type: ignore[arg-type] + return self._to_response(project, task_count=tc) + + # ---- AOI --------------------------------------------------------- + + 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") + geom = to_shape(project.aoi) + if isinstance(geom, ShapelyPolygon): # defensive + geom = ShapelyMultiPolygon([geom]) + return _shapely_to_aoi_feature(geom) + + async def upload_aoi( + self, workspace_id: int, project_id: int, aoi: AoiInput, current_user: UserInfo + ) -> AoiFeature: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="AOI can only be set or replaced while the project is in draft", + ) + + geom = _aoi_to_shapely(aoi) + from sqlalchemy import text + + # Replacing AOI hard-deletes any saved tasks and clears the + # boundary type (per spec). + await self.session.execute( + text("DELETE FROM tasking_tasks WHERE project_id = :pid"), + {"pid": project.id}, + ) + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values( + aoi=from_shape(geom, srid=4326), + task_boundary_type=None, + updated_at=datetime.now(), + ) + ) + await self._audit( + event_type=AuditEventType.AOI_UPLOADED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.commit() + return _shapely_to_aoi_feature(geom) + + # ---- Project roles ------------------------------------------------ + # + # Schema lives in `tasking_project_roles (project_id, user_auth_uid, + # role, updated_at)` with PK on (project_id, user_auth_uid) and a FK + # to `users.auth_uid`. The `add` path inserts via raw SQL so duplicate + # rows raise the PK uniqueness violation translated into a 409; FK + # 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: + """True if the user holds a `lead` role on the given project.""" + from sqlalchemy import text + + result = await self.session.execute( + text( + "SELECT 1 FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid " + "AND role = 'lead' LIMIT 1" + ), + {"pid": project_id, "uid": str(user_uuid)}, + ) + return result.scalar() is not None + + async def assert_can_manage_roles( + self, + workspace_id: int, + project_id: int, + current_user: UserInfo, + ) -> None: + """Permission gate for role-management endpoints. + + Allows either a workspace-level LEAD (per `UserInfo.isWorkspaceLead`) + or a project-level LEAD recorded in `tasking_project_roles`. The + project must already exist; otherwise the underlying `_get_active` + call raises 404. + """ + await self._get_active(workspace_id, project_id) + if current_user.isWorkspaceLead(workspace_id): + return + 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." + ) + + async def _lead_count(self, project_id: int) -> int: + from sqlalchemy import text + + result = await self.session.execute( + text( + "SELECT COUNT(*) FROM tasking_project_roles " + "WHERE project_id = :pid AND role = 'lead'" + ), + {"pid": project_id}, + ) + return int(result.scalar() or 0) + + async def list_roles( + self, workspace_id: int, project_id: int + ) -> ProjectRoleListResponse: + """Return every role assignment on the project, newest first.""" + await self._get_active(workspace_id, project_id) + from sqlalchemy import text + + rows = await self.session.execute( + text( + "SELECT r.user_auth_uid, u.display_name, r.role, r.updated_at " + "FROM tasking_project_roles r " + "LEFT JOIN users u ON u.auth_uid = r.user_auth_uid " + "WHERE r.project_id = :pid " + "ORDER BY r.updated_at DESC" + ), + {"pid": project_id}, + ) + items = [ + ProjectRoleItem( + user_id=UUID(uid), + user_name=name, + role=ProjectRoleType(role), + updated_at=updated, + ) + for uid, name, role, updated in rows.all() + ] + return ProjectRoleListResponse(results=items) + + async def add_role( + self, + workspace_id: int, + project_id: int, + body: ProjectRoleAddRequest, + ) -> ProjectRoleItem: + """Insert a new role row. 422 on missing user; 409 on duplicate.""" + await self._get_active(workspace_id, project_id) + + missing = await self._missing_user_auth_uids([body.user_id]) + if missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "`user_id` refers to a user that has not signed in " + "to Workspaces yet — no `users` row exists." + ), + "missing_user_ids": missing, + }, + ) + + from sqlalchemy import text + + existing = await self.session.execute( + text( + "SELECT 1 FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(body.user_id)}, + ) + if existing.scalar() is not None: + raise AlreadyExistsException( + "This user is already assigned a role on the project. " + "Use PATCH to change their role." + ) + + try: + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role, updated_at) " + "VALUES (:pid, :uid, :role, NOW())" + ), + { + "pid": project_id, + "uid": str(body.user_id), + "role": body.role.value, + }, + ) + await self.session.commit() + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + + # Re-read so the response carries server-set `updated_at` and the + # `display_name` join with `users`. + return await self._get_role(project_id, body.user_id) + + async def update_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + ) -> ProjectRoleItem: + """Change a user's role. 404 if not assigned. 422 if it would + remove the last LEAD (per spec — projects must always have one).""" + await self._get_active(workspace_id, project_id) + + current = await self._get_role_or_none(project_id, user_id) + if current is None: + raise NotFoundException( + f"User {user_id} has no role on project {project_id}" + ) + + # Guard: demoting the only LEAD would orphan the project. + if ( + current.role == ProjectRoleType.LEAD + and body.role != ProjectRoleType.LEAD + and await self._lead_count(project_id) <= 1 + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Cannot demote the last LEAD on this project. Assign " + "another LEAD first." + ), + ) + + from sqlalchemy import text + + await self.session.execute( + text( + "UPDATE tasking_project_roles " + "SET role = :role, updated_at = NOW() " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + { + "pid": project_id, + "uid": str(user_id), + "role": body.role.value, + }, + ) + await self.session.commit() + return await self._get_role(project_id, user_id) + + async def list_self_project_roles( + self, + workspace_id: int, + current_user: UserInfo, + ) -> SelfProjectRolesResponse: + """One row per non-deleted project in the workspace with the + caller's effective role on that project. + + Resolution rule: + 1. If `tasking_project_roles` has an explicit row for this + user on the project, that wins. + 2. Otherwise the caller's workspace-level role applies (the + implicit fallback that grants `contributor` access to + every project in the workspace). + + Workspace-level role is mapped to the same `ProjectRoleType` + enum the project rows use. + """ + # Workspace-level role first — used as the fallback for projects + # without an explicit override. + from api.src.users.schemas import WorkspaceUserRoleType + + ws_role_raw = current_user.effective_role(workspace_id) + ws_role = ProjectRoleType(ws_role_raw.value) + + from sqlalchemy import text + + rows = await self.session.execute( + text( + "SELECT p.id, p.name, p.status, r.role " + "FROM tasking_projects p " + "LEFT JOIN tasking_project_roles r " + " ON r.project_id = p.id " + " AND r.user_auth_uid = :uid " + "WHERE p.workspace_id = :wid " + " AND p.deleted_at IS NULL " + "ORDER BY p.created_at DESC" + ), + { + "wid": workspace_id, + "uid": str(current_user.user_uuid), + }, + ) + + items = [ + SelfProjectRoleItem( + project_id=pid, + project_name=name, + project_status=ProjectStatus(status_value), + role=( + ProjectRoleType(explicit_role) + if explicit_role is not None + else ws_role + ), + ) + for pid, name, status_value, explicit_role in rows.all() + ] + return SelfProjectRolesResponse( + workspace_role=ws_role, + projects=items, + ) + + async def get_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + ) -> ProjectRoleItem: + """Single-user role read. 404 if no assignment exists.""" + await self._get_active(workspace_id, project_id) + item = await self._get_role_or_none(project_id, user_id) + if item is None: + raise NotFoundException( + f"User {user_id} has no role on project {project_id}" + ) + return item + + async def upsert_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + ) -> tuple[ProjectRoleItem, bool]: + """PUT semantics: idempotent insert-or-update on a role row. + + Returns ``(item, created)`` where ``created`` is True iff the + row did not exist before this call. The route layer uses that + flag to pick 201 vs 200. + + Honours the last-LEAD guard: a PUT that would demote the only + remaining LEAD returns 422 — assign another LEAD first. + """ + await self._get_active(workspace_id, project_id) + + current = await self._get_role_or_none(project_id, user_id) + + if current is not None: + # Update path — guard against orphaning the project. + if ( + current.role == ProjectRoleType.LEAD + and body.role != ProjectRoleType.LEAD + and await self._lead_count(project_id) <= 1 + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Cannot demote the last LEAD on this project. " + "Assign another LEAD first." + ), + ) + else: + # Insert path — `user_id` must exist in `users`. Preflight so + # the caller gets a 422 with `missing_user_ids` rather than + # a generic 23503 FK violation. + missing = await self._missing_user_auth_uids([user_id]) + if missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "`user_id` refers to a user that has not signed " + "in to Workspaces yet — no `users` row exists." + ), + "missing_user_ids": missing, + }, + ) + + from sqlalchemy import text + + try: + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role, updated_at) " + "VALUES (:pid, :uid, :role, NOW()) " + "ON CONFLICT (project_id, user_auth_uid) DO UPDATE " + "SET role = EXCLUDED.role, updated_at = NOW()" + ), + { + "pid": project_id, + "uid": str(user_id), + "role": body.role.value, + }, + ) + await self.session.commit() + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + + item = await self._get_role(project_id, user_id) + return item, current is None + + async def remove_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + ) -> None: + """Drop a role assignment. 404 if absent. 422 on last-LEAD removal.""" + await self._get_active(workspace_id, project_id) + + current = await self._get_role_or_none(project_id, user_id) + if current is None: + raise NotFoundException( + f"User {user_id} has no role on project {project_id}" + ) + + if ( + current.role == ProjectRoleType.LEAD + and await self._lead_count(project_id) <= 1 + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Cannot remove the last LEAD on this project. Assign " + "another LEAD first." + ), + ) + + from sqlalchemy import text + + await self.session.execute( + text( + "DELETE FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(user_id)}, + ) + await self.session.commit() + + 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( + f"User {user_id} has no role on project {project_id}" + ) + return item + + async def _get_role_or_none( + self, project_id: int, user_id: UUID + ) -> ProjectRoleItem | None: + from sqlalchemy import text + + row = await self.session.execute( + text( + "SELECT r.user_auth_uid, u.display_name, r.role, r.updated_at " + "FROM tasking_project_roles r " + "LEFT JOIN users u ON u.auth_uid = r.user_auth_uid " + "WHERE r.project_id = :pid AND r.user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(user_id)}, + ) + result = row.first() + if result is None: + return None + uid, name, role, updated = result + return ProjectRoleItem( + user_id=UUID(uid), + user_name=name, + role=ProjectRoleType(role), + updated_at=updated, + ) + + async def delete_aoi( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> None: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="AOI can only be deleted while the project is in draft", + ) + if project.aoi is None: + raise NotFoundException("AOI is not set on this project") + + from sqlalchemy import text + + await self.session.execute( + text("DELETE FROM tasking_tasks WHERE project_id = :pid"), + {"pid": project.id}, + ) + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values( + aoi=None, + task_boundary_type=None, + updated_at=datetime.now(), + ) + ) + await self._audit( + event_type=AuditEventType.AOI_DELETED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.commit() + + +__all__ = ["TaskingProjectRepository"] diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py new file mode 100644 index 0000000..7389246 --- /dev/null +++ b/api/src/tasking/projects/routes.py @@ -0,0 +1,397 @@ +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.dtos import ( + AoiFeature, + ProjectCreateRequest, + ProjectListResponse, + ProjectResponse, + ProjectRoleAddRequest, + ProjectRoleItem, + ProjectRoleListResponse, + ProjectRoleUpdateRequest, + ProjectUpdateRequest, + SelfProjectRolesResponse, +) +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( + prefix="/workspaces/{workspace_id}/tasking/projects", + tags=["tasking-projects"], +) + +# Sibling router for caller-scoped (`/me/...`) tasking endpoints. Kept on +# its own prefix so the project-scoped router above can stay focused. +me_router = APIRouter( + prefix="/me/workspaces/{workspace_id}/tasking/projects", + tags=["tasking-projects"], +) + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def get_project_repo( + session: AsyncSession = Depends(get_osm_session), +) -> TaskingProjectRepository: + return TaskingProjectRepository(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) + + +def assert_workspace_lead(workspace_id: int, current_user: UserInfo) -> None: + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User does not have permission to edit this workspace", + ) + + +# --------------------------------------------------------------------------- +# Projects — CRUD + lifecycle +# --------------------------------------------------------------------------- + + +@router.get("", response_model=ProjectListResponse) +async def list_projects( + workspace_id: int, + 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), + order_by: str = Query("created_at"), + order_by_type: str = Query("DESC"), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.list_projects( + workspace_id, + status_filter=status_filter, + text_search=text_search, + page=page, + page_size=page_size, + order_by=order_by, + order_dir=order_by_type, + ) + + +@router.post( + "", + response_model=ProjectResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_project( + workspace_id: int, + body: ProjectCreateRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + # `getById` enforces the tenancy gate AND returns the workspace + # row, whose `tdeiProjectGroupId` we hand to the repository so it + # can auto-provision `role_assignments[]` users from TDEI. + workspace = await workspace_repo.getById(current_user, workspace_id) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.create( + workspace_id, + current_user, + body, + tdei_project_group_id=str(workspace.tdeiProjectGroupId), + ) + + +@router.get("/{project_id}", response_model=ProjectResponse) +async def get_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.get(workspace_id, project_id) + + +@router.patch("/{project_id}", response_model=ProjectResponse) +async def update_project( + workspace_id: int, + project_id: int, + body: ProjectUpdateRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.patch(workspace_id, project_id, body, current_user) + + +@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + await project_repo.soft_delete(workspace_id, project_id, current_user) + + +@router.post("/{project_id}/activate", response_model=ProjectResponse) +async def activate_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.activate(workspace_id, project_id, current_user) + + +@router.post("/{project_id}/close", response_model=ProjectResponse) +async def close_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.close(workspace_id, project_id, current_user) + + +@router.post("/{project_id}/reset", response_model=ProjectResponse) +async def reset_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.reset(workspace_id, project_id, current_user) + + +# --------------------------------------------------------------------------- +# AOI +# --------------------------------------------------------------------------- + + +@router.get("/{project_id}/aoi", response_model=AoiFeature) +async def get_project_aoi( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.get_aoi(workspace_id, project_id) + + +@router.post("/{project_id}/aoi", response_model=AoiFeature) +async def upload_project_aoi( + workspace_id: int, + project_id: int, + body: AoiInput = Body(...), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.upload_aoi(workspace_id, project_id, body, current_user) + + +# --------------------------------------------------------------------------- +# Project role management +# +# Writes require either workspace LEAD or project LEAD (delegated to the +# repository so the project-LEAD check can hit `tasking_project_roles`). +# Reads are open to any caller who passes the workspace tenancy gate. +# --------------------------------------------------------------------------- + + +@router.get( + "/{project_id}/roles", + response_model=ProjectRoleListResponse, +) +async def list_project_roles( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.list_roles(workspace_id, project_id) + + +@router.post( + "/{project_id}/roles", + response_model=ProjectRoleItem, + status_code=status.HTTP_201_CREATED, +) +async def add_project_role( + workspace_id: int, + project_id: int, + body: ProjectRoleAddRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + 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.add_role(workspace_id, project_id, body) + + +@router.get( + "/{project_id}/roles/{user_id}", + response_model=ProjectRoleItem, +) +async def get_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.get_role(workspace_id, project_id, user_id) + + +@router.put( + "/{project_id}/roles/{user_id}", + response_model=ProjectRoleItem, +) +async def put_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + response: Response, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + """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) + item, created = await project_repo.upsert_role( + workspace_id, project_id, user_id, body + ) + if created: + response.status_code = status.HTTP_201_CREATED + return item + + +@router.patch( + "/{project_id}/roles/{user_id}", + response_model=ProjectRoleItem, +) +async def update_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + 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) + + +@router.delete( + "/{project_id}/roles/{user_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def remove_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + 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.remove_role(workspace_id, project_id, user_id) + + +@router.delete("/{project_id}/aoi", status_code=status.HTTP_204_NO_CONTENT) +async def delete_project_aoi( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + await project_repo.delete_aoi(workspace_id, project_id, current_user) + + +# --------------------------------------------------------------------------- +# /me/* — caller-scoped views. +# +# Mounted on `me_router` so the path prefix is `/me/workspaces/{wid}/...` +# instead of the project-scoped router's `/workspaces/{wid}/...`. Reads +# only; no writes here. +# --------------------------------------------------------------------------- + + +@me_router.get( + "/roles", + response_model=SelfProjectRolesResponse, +) +async def list_self_project_roles( + workspace_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + """Per-project role for the caller across every project in the workspace. + + Returns the workspace-level role and a per-project list with the + project-LEVEL role override where one exists, else the workspace role. + 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) diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py new file mode 100644 index 0000000..901d5f6 --- /dev/null +++ b/api/src/tasking/projects/schemas.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Any, Literal, Optional +from uuid import UUID + +from geoalchemy2 import Geometry +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) +# --------------------------------------------------------------------------- + + +class ProjectStatus(StrEnum): + DRAFT = "draft" + OPEN = "open" + DONE = "done" + + +class TaskBoundaryType(StrEnum): + GRID = "grid" + IMPORT = "import" + + +# --------------------------------------------------------------------------- +# Table model +# --------------------------------------------------------------------------- + + +class TaskingProject(SQLModel, table=True): + """Tasking project — lifecycle, AOI, settings.""" + + __tablename__ = "tasking_projects" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + + workspace_id: int = Field(nullable=False, index=True) + + name: str = Field(max_length=255, nullable=False) + instructions: Optional[str] = None + + status: ProjectStatus = Field( + default=ProjectStatus.DRAFT, + sa_column=Column( + SAEnum( + ProjectStatus, + name="tasking_project_status", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=False, + ), + ) + + review_required: bool = Field(default=True, nullable=False) + lock_timeout_hours: int = Field(default=8, nullable=False) + + task_boundary_type: Optional[TaskBoundaryType] = Field( + default=None, + sa_column=Column( + SAEnum( + TaskBoundaryType, + name="tasking_task_boundary_type", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=True, + ), + ) + + # PostGIS MultiPolygon in EPSG:4326. Stored as WKB and converted + # to / from GeoJSON in the repository layer. + aoi: Optional[Any] = Field( + default=None, + sa_column=Column(Geometry(geometry_type="MULTIPOLYGON", srid=4326)), + ) + + created_by: UUID = Field(nullable=False) + created_by_name: Optional[str] = None + + created_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + updated_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False, "onupdate": datetime.now}, + ) + deleted_at: Optional[datetime] = None + + +# --------------------------------------------------------------------------- +# Project role enum + table +# --------------------------------------------------------------------------- + + +class ProjectRoleType(StrEnum): + LEAD = "lead" + VALIDATOR = "validator" + CONTRIBUTOR = "contributor" + + +class TaskingProjectRole(SQLModel, table=True): + """Per-project role override stored in ``tasking_project_roles``. + + Maps a user (`users.auth_uid`) to a role within a single tasking + project. Created at project-seed time and managed via the + ``/projects/{pid}/roles`` endpoints thereafter. Cascades on project + delete; the user FK is intentionally non-cascading so user records + are not destroyed by project deletions. + """ + + __tablename__ = "tasking_project_roles" # type: ignore[assignment] + + project_id: int = Field(primary_key=True, nullable=False) + user_auth_uid: str = Field(primary_key=True, nullable=False) + role: ProjectRoleType = Field( + sa_column=Column( + SAEnum( + ProjectRoleType, + name="workspace_role", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=False, + ), + ) + updated_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False, "onupdate": datetime.now}, + ) + + +# --------------------------------------------------------------------------- +# GeoJSON input shapes — accepted by the AOI endpoints. Polygon inputs +# are upcast to single-member MultiPolygon at the repository layer. +# --------------------------------------------------------------------------- + + +class _Polygon(BaseModel): + type: Literal["Polygon"] + coordinates: list[list[list[float]]] + + +class _MultiPolygon(BaseModel): + type: Literal["MultiPolygon"] + coordinates: list[list[list[list[float]]]] + + +class _Feature(BaseModel): + type: Literal["Feature"] + geometry: _Polygon | _MultiPolygon + properties: Optional[dict[str, Any]] = None + + +class _FeatureCollection(BaseModel): + type: Literal["FeatureCollection"] + features: list[_Feature] = PydField(min_length=1, max_length=1) + + +AoiInput = _Polygon | _MultiPolygon | _Feature | _FeatureCollection + + +__all__ = [ + "AoiInput", + "ProjectRoleType", + "ProjectStatus", + "TaskBoundaryType", + "TaskingProject", + "TaskingProjectRole", + "_Feature", + "_FeatureCollection", + "_MultiPolygon", + "_Polygon", +] diff --git a/api/src/tasking/tasks/__init__.py b/api/src/tasking/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/tasks/dtos.py b/api/src/tasking/tasks/dtos.py new file mode 100644 index 0000000..79a8bdb --- /dev/null +++ b/api/src/tasking/tasks/dtos.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal, Optional +from uuid import UUID + +from pydantic import Field as PydField + +from api.src.tasking.projects.dtos import Pagination, WireModel +from api.src.tasking.tasks.schemas import FeedbackReason, TaskStatus + +# --------------------------------------------------------------------------- +# Task boundary GeoJSON (input for /tasks/validate and /tasks/save) +# --------------------------------------------------------------------------- + + +class TaskBoundaryPolygon(WireModel): + type: Literal["Polygon"] + coordinates: list[list[list[float]]] + + +class TaskBoundaryFeature(WireModel): + type: Literal["Feature"] + geometry: TaskBoundaryPolygon + properties: Optional[dict[str, Any]] = None + + +class TaskBoundariesFeatureCollection(WireModel): + type: Literal["FeatureCollection"] + features: list[TaskBoundaryFeature] = PydField(min_length=1) + + +GridSource = Literal["grid", "import"] + + +# --------------------------------------------------------------------------- +# Task detail / list +# --------------------------------------------------------------------------- + + +class TaskLockSummary(WireModel): + user_id: UUID + user_name: Optional[str] = None + locked_at: datetime + expires_at: datetime + + +class LastMapper(WireModel): + user_id: UUID + user_name: Optional[str] = None + + +class TaskResponse(WireModel): + id: int + task_number: int + status: TaskStatus + geometry: TaskBoundaryPolygon + area_sqkm: float + lock: Optional[TaskLockSummary] = None + last_mapper: Optional[LastMapper] = None + created_at: datetime + updated_at: datetime + + +class TaskListResponse(WireModel): + tasks: list[TaskResponse] + pagination: Pagination + + +# --------------------------------------------------------------------------- +# Validate / Save +# --------------------------------------------------------------------------- + + +class ValidateWarning(WireModel): + task_index: int + issue: Literal["polygon_exceeds_grid_size"] + area_sqkm: Optional[float] = None + + +class ValidatePreviewResponse(WireModel): + valid: bool + warnings: list[ValidateWarning] = PydField(default_factory=list) + source: GridSource = "import" + feature_collection: TaskBoundariesFeatureCollection + + +class SaveTasksRequest(WireModel): + source: GridSource + feature_collection: TaskBoundariesFeatureCollection + + +class SaveTasksResponse(WireModel): + project_id: int + task_boundary_type: GridSource + task_count: int + tasks: list[TaskResponse] + idempotency_key: Optional[str] = None + replayed: bool = False + + +# --------------------------------------------------------------------------- +# Submit / lock +# --------------------------------------------------------------------------- + + +class FeedbackInput(WireModel): + reason_category: Optional[FeedbackReason] = None + notes: str = PydField(min_length=1, max_length=4000) + + +class SubmitRequest(WireModel): + # osm_changeset_id: int = PydField(ge=1) + done: bool + feedback: Optional[FeedbackInput] = None + + +class SubmitTaskChangeset(WireModel): + osm_changeset_id: int = PydField(ge=1) + + +class SubmitTaskChangesetResponse(WireModel): + osm_changeset_id: int = PydField(ge=1) + task_number: int + project_id: int + workspace_id: int + inserted_id: Optional[int] = ( + None # ID of the newly inserted changeset row, if applicable + ) + + +class ExistingLockSummary(WireModel): + task_number: int + task_status: TaskStatus + locked_at: datetime + expires_at: datetime + + +__all__ = [ + "ExistingLockSummary", + "FeedbackInput", + "GridSource", + "LastMapper", + "SaveTasksRequest", + "SaveTasksResponse", + "SubmitRequest", + "TaskBoundariesFeatureCollection", + "TaskBoundaryFeature", + "TaskBoundaryPolygon", + "TaskListResponse", + "TaskLockSummary", + "TaskResponse", + "ValidatePreviewResponse", + "ValidateWarning", +] diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py new file mode 100644 index 0000000..ad99c12 --- /dev/null +++ b/api/src/tasking/tasks/repository.py @@ -0,0 +1,1159 @@ +from __future__ import annotations + +import hashlib +import json +import math +from datetime import datetime, timedelta +from typing import Any, Optional +from uuid import UUID + +from fastapi import HTTPException, status +from geoalchemy2.shape import from_shape, to_shape +from shapely.geometry import MultiPolygon as ShapelyMultiPolygon +from shapely.geometry import Polygon as ShapelyPolygon +from shapely.geometry import shape as shapely_shape +from sqlalchemy import func, select, text, update +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.exc import IntegrityError +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import ( + AlreadyExistsException, + ForbiddenException, + NotFoundException, +) +from api.core.security import UserInfo +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import Pagination +from api.src.tasking.projects.schemas import ProjectStatus, TaskingProject +from api.src.tasking.tasks.dtos import ( + ExistingLockSummary, + FeedbackInput, + LastMapper, + SaveTasksRequest, + SaveTasksResponse, + SubmitRequest, + TaskBoundariesFeatureCollection, + TaskBoundaryFeature, + TaskBoundaryPolygon, + TaskListResponse, + TaskLockSummary, + TaskResponse, + ValidatePreviewResponse, + ValidateWarning, +) +from api.src.tasking.tasks.schemas import ( + LockReleaseReason, + TaskingChangeset, + TaskingFeedback, + TaskingLock, + TaskingTask, + 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 +# reprojection. +_DEG2_TO_KM2 = 111.32 * 111.32 + +# Threshold for the `polygon_exceeds_grid_size` warning. Default is +# 5000 m per side (matching the conventional Tasking Manager grid). +# Overridable via the `TM_TASKING_GRID_SIZE_METERS` environment +# variable; read once at import time. +import os as _os # noqa: E402 + +try: + _GRID_SIZE_M = float(_os.getenv("TM_TASKING_GRID_SIZE_METERS", "5000")) +except ValueError: + _GRID_SIZE_M = 5000.0 +_GRID_MAX_KM2 = (_GRID_SIZE_M / 1000.0) ** 2 + + +# --------------------------------------------------------------------------- +# Geometry helpers +# --------------------------------------------------------------------------- + + +def _polygon_to_shapely(geom_dict: dict) -> ShapelyPolygon: + try: + geom = shapely_shape(geom_dict) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid task geometry: {e}", + ) from None + if not isinstance(geom, ShapelyPolygon): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Each task feature must be a Polygon", + ) + if not geom.is_valid: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Task polygon is not valid (self-intersection or invalid ring)", + ) + return geom + + +def _shapely_polygon_to_geojson(geom: ShapelyPolygon) -> TaskBoundaryPolygon: + raw = geom.__geo_interface__ + return TaskBoundaryPolygon(type="Polygon", coordinates=raw["coordinates"]) + + +def _polygon_area_km2(geom: ShapelyPolygon) -> float: + return float(geom.area) * _DEG2_TO_KM2 + + +# AOI containment is checked with `aoi.covers(poly)`, which uses exact +# predicates. Grid generation produces cells clipped against the AOI; +# the clipped vertices land on (or fractionally outside) the AOI edge +# due to floating-point rounding, so `covers` returns False even though +# the cell is topologically inside the AOI. +# +# Treat a polygon as inside the AOI when the area of `poly.difference(aoi)` +# is below this fraction of the polygon's own area. 1e-9 corresponds to +# ~1 µm² at this latitude — well below any real-world AOI authoring error. +_AOI_COVER_REL_TOLERANCE = 1e-9 + + +def _aoi_covers(aoi_geom, poly: ShapelyPolygon) -> bool: + """Tolerant containment test for AOI vs. task polygon.""" + if aoi_geom.covers(poly): + return True + poly_area = poly.area + if poly_area == 0: + return True + overrun = poly.difference(aoi_geom).area + return overrun <= _AOI_COVER_REL_TOLERANCE * poly_area + + +def _generate_grid_over_aoi( + aoi: ShapelyMultiPolygon, + cell_size_m: float, +) -> list[ShapelyPolygon]: + """Build a regular grid of square cells over the AOI bounding box, + clip each cell to the AOI, and return the resulting polygons. + + Sizes are converted from meters using an equirectangular + approximation: + - 1° latitude ≈ 111 320 m everywhere. + - 1° longitude ≈ 111 320 · cos(centre latitude) m. + + Adequate for small project AOIs; a proper metric reprojection + (pyproj) is required for global-scale precision. + """ + 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)) + + cells: list[ShapelyPolygon] = [] + # Safety cap for accidental large-AOI + small-cell combinations. + cell_cap = 50_000 + + y = miny + while y < maxy: + x = minx + while x < maxx: + cell = ShapelyPolygon( + [ + (x, y), + (x + lon_step, y), + (x + lon_step, y + lat_step), + (x, y + lat_step), + (x, y), + ] + ) + if cell.intersects(aoi): + clipped = cell.intersection(aoi) + if not clipped.is_empty and clipped.area > 0: + # `intersection` can return a Polygon, MultiPolygon, + # or GeometryCollection; retain polygon pieces only. + geoms = ( + list(clipped.geoms) if hasattr(clipped, "geoms") else [clipped] + ) + for piece in geoms: + if isinstance(piece, ShapelyPolygon) and piece.area > 0: + cells.append(piece) + if len(cells) >= cell_cap: + return cells + x += lon_step + y += lat_step + + return cells + + +# --------------------------------------------------------------------------- +# Repository +# --------------------------------------------------------------------------- + + +class TaskingTaskRepository: + """Tasks, locks, changesets, and submit-flow operations. + + Methods assume the caller has already passed the workspace tenancy + gate (`WorkspaceRepository.getById`); that check is performed at + the route layer. + """ + + def __init__(self, session: AsyncSession): + self.session = session + + # ---- common helpers --------------------------------------------------- + + async def _get_project(self, workspace_id: int, project_id: int) -> TaskingProject: + rs = await self.session.execute( + select(TaskingProject).where( + (TaskingProject.id == project_id) + & (TaskingProject.workspace_id == workspace_id) + & (TaskingProject.deleted_at.is_(None)) + ) + ) + project = rs.scalar_one_or_none() + if project is None: + raise NotFoundException(f"Project {project_id} not found") + return project + + 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) + & (TaskingTask.task_number == task_number) + ) + ) + task = rs.scalar_one_or_none() + if task is None: + raise NotFoundException( + f"Task {task_number} not found in project {project_id}" + ) + return task + + 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)) + ) + ) + return rs.scalar_one_or_none() + + async def _get_active_lock_for_user_in_project( + self, project_id: int, user_auth_uid: str + ) -> Optional[TaskingLock]: + rs = await self.session.execute( + select(TaskingLock).where( + (TaskingLock.project_id == project_id) + & (TaskingLock.user_auth_uid == user_auth_uid) + & (TaskingLock.released_at.is_(None)) + ) + ) + return rs.scalar_one_or_none() + + async def _project_role( + self, project_id: int, user: UserInfo, workspace_id: int + ) -> Optional[str]: + """Effective project role: explicit row overrides workspace-level. + + Returns one of `lead`, `validator`, `contributor`, or `None` + (outsider). Workspace LEAD beats an explicit non-lead row so + a workspace lead is never accidentally demoted by a stale role + assignment. + """ + rs = await self.session.execute( + text( + "SELECT role FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(user.user_uuid)}, + ) + explicit = rs.scalar_one_or_none() + + # Workspace LEAD always wins. + if user.isWorkspaceLead(workspace_id): + return "lead" + if explicit: + return str(explicit) + if user.isWorkspaceValidator(workspace_id): + return "validator" + if user.isWorkspaceContributor(workspace_id): + return "contributor" + return None + + async def _audit( + self, + *, + event_type: AuditEventType, + project_id: int, + task_id: Optional[int], + actor_uuid: UUID, + details: Optional[dict[str, Any]] = None, + ) -> None: + await self.session.execute( + text( + "INSERT INTO tasking_audit_events " + "(event_type, project_id, task_id, actor_user_auth_uid, details) " + "VALUES (:et, :pid, :tid, :uid, CAST(:dt AS jsonb))" + ), + { + "et": event_type, + "pid": project_id, + "tid": task_id, + "uid": str(actor_uuid), + "dt": json.dumps(details or {}), + }, + ) + + 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"), + {"uid": user_auth_uid}, + ) + return rs.scalar_one_or_none() + + async def _to_task_response(self, task: TaskingTask) -> TaskResponse: + geom_shape = to_shape(task.geometry) if task.geometry is not None else None + if geom_shape is None or not isinstance(geom_shape, ShapelyPolygon): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Task geometry missing or non-Polygon", + ) + + lock_row = await self._get_active_lock(task.id) # type: ignore[arg-type] + lock_summary: Optional[TaskLockSummary] = None + if lock_row is not None: + display = await self._lookup_user_display(lock_row.user_auth_uid) + lock_summary = TaskLockSummary( + user_id=UUID(lock_row.user_auth_uid), + user_name=display, + locked_at=lock_row.locked_at, + expires_at=lock_row.expires_at, + ) + + last_mapper: Optional[LastMapper] = None + if task.last_mapper_id: + display = await self._lookup_user_display(task.last_mapper_id) + last_mapper = LastMapper( + user_id=UUID(task.last_mapper_id), user_name=display + ) + + return TaskResponse( + id=task.id, # type: ignore[arg-type] + task_number=task.task_number, + status=task.status, + geometry=_shapely_polygon_to_geojson(geom_shape), + area_sqkm=float(task.area_sqkm), + lock=lock_summary, + last_mapper=last_mapper, + created_at=task.created_at, + updated_at=task.updated_at, + ) + + # ---- grid generation ------------------------------------------------- + + async def generate_grid( + self, + workspace_id: int, + project_id: int, + cell_size_m: int, + ) -> TaskBoundariesFeatureCollection: + """Preview-only grid generation: returns a FeatureCollection of + clipped grid cells over the project AOI without persisting. + + Caller previews the returned shapes and then commits the same + FeatureCollection via `POST /tasks/save` (with + ``source: "grid"``). LEAD-only; project must be in `draft` + with an AOI set. + """ + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Tasks can only be generated while the project is in draft", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required before generating a grid", + ) + + aoi_geom = to_shape(project.aoi) + if isinstance(aoi_geom, ShapelyPolygon): + aoi_geom = ShapelyMultiPolygon([aoi_geom]) + + cells = _generate_grid_over_aoi(aoi_geom, float(cell_size_m)) + if not cells: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Grid produced no cells — AOI may be too small for " + "the chosen cellSizeMeters." + ), + ) + + features = [ + TaskBoundaryFeature( + type="Feature", + geometry=_shapely_polygon_to_geojson(cell), + properties={"cellIndex": idx}, + ) + for idx, cell in enumerate(cells) + ] + return TaskBoundariesFeatureCollection( + type="FeatureCollection", + features=features, + ) + + # ---- validate -------------------------------------------------------- + + async def validate( + self, + workspace_id: int, + project_id: int, + fc: TaskBoundariesFeatureCollection, + ) -> ValidatePreviewResponse: + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Tasks can only be validated while the project is in draft", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required before validating tasks", + ) + + aoi_geom = to_shape(project.aoi) + if isinstance(aoi_geom, ShapelyPolygon): + aoi_geom = ShapelyMultiPolygon([aoi_geom]) + + warnings: list[ValidateWarning] = [] + for idx, feat in enumerate(fc.features): + poly = _polygon_to_shapely(feat.geometry.model_dump()) + if not _aoi_covers(aoi_geom, poly): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Task feature {idx} is not fully inside the project AOI", + ) + area_km2 = _polygon_area_km2(poly) + if area_km2 > _GRID_MAX_KM2: + warnings.append( + ValidateWarning( + task_index=idx, + issue="polygon_exceeds_grid_size", + area_sqkm=round(area_km2, 4), + ) + ) + + return ValidatePreviewResponse( + valid=True, + warnings=warnings, + source="import", + feature_collection=fc, + ) + + # ---- save ------------------------------------------------------------ + + async def save( + self, + workspace_id: int, + project_id: int, + current_user: UserInfo, + body: SaveTasksRequest, + idempotency_key: Optional[str], + ) -> tuple[SaveTasksResponse, bool]: + """Bulk-insert tasks. Returns (response, replayed).""" + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Tasks can only be saved while the project is in draft", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required before saving tasks", + ) + + body_bytes = json.dumps(body.model_dump(mode="json"), sort_keys=True).encode() + body_hash = hashlib.sha256(body_bytes).hexdigest() + + # Idempotent replay path. + if idempotency_key: + rs = await self.session.execute( + text( + "SELECT body_hash, response_json " + "FROM tasking_task_save_idempotency " + "WHERE project_id = :pid AND key = :k" + ), + {"pid": project.id, "k": idempotency_key}, + ) + row = rs.first() + if row is not None: + if row.body_hash != body_hash: + raise AlreadyExistsException( + "Idempotency key reused with a different request" + ) + stored = row.response_json + if isinstance(stored, str): + stored = json.loads(stored) + # Stored payload was serialised with `replayed=False` + # at first-write time; set it to True on replay so the + # caller can distinguish a replay from a fresh save. + stored["replayed"] = True + return SaveTasksResponse(**stored), True + + # 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"), + {"pid": project.id}, + ) + if existing.scalar() is not None: + raise AlreadyExistsException("Tasks already saved") + + # Re-validate every feature against AOI (atomic guarantee). + aoi_geom = to_shape(project.aoi) + if isinstance(aoi_geom, ShapelyPolygon): + aoi_geom = ShapelyMultiPolygon([aoi_geom]) + + created: list[TaskingTask] = [] + for idx, feat in enumerate(body.feature_collection.features): + poly = _polygon_to_shapely(feat.geometry.model_dump()) + if not _aoi_covers(aoi_geom, poly): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Task feature {idx} is not fully inside the project AOI", + ) + task = TaskingTask( + project_id=project.id, # type: ignore[arg-type] + task_number=idx + 1, + area_sqkm=round(_polygon_area_km2(poly), 4), + status=TaskStatus.TO_MAP, + geometry=from_shape(poly, srid=4326), + ) + self.session.add(task) + created.append(task) + + await self.session.flush() # populate ids + + # Set boundary type + bump project updated_at. + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values( + task_boundary_type=body.source, + updated_at=datetime.now(), + ) + ) + + await self._audit( + event_type=AuditEventType.TASKS_CREATED, + project_id=project.id, # type: ignore[arg-type] + task_id=None, + actor_uuid=current_user.user_uuid, + details={"taskCount": len(created), "source": body.source}, + ) + + task_responses = [await self._to_task_response(t) for t in created] + response = SaveTasksResponse( + project_id=project.id, # type: ignore[arg-type] + task_boundary_type=body.source, + task_count=len(created), + tasks=task_responses, + idempotency_key=idempotency_key, + replayed=False, + ) + + # Persist idempotency record (committed in the same txn). + if idempotency_key: + payload = response.model_dump(mode="json") + await self.session.execute( + text( + "INSERT INTO tasking_task_save_idempotency " + "(project_id, key, body_hash, response_json) " + "VALUES (:pid, :k, :bh, CAST(:rj AS jsonb))" + ), + { + "pid": project.id, + "k": idempotency_key, + "bh": body_hash, + "rj": json.dumps(payload), + }, + ) + + await self.session.commit() + return response, False + + # ---- list / get ------------------------------------------------------ + + async def list_tasks( + self, + workspace_id: int, + project_id: int, + *, + status_filter: Optional[TaskStatus] = None, + locked_by_user_id: Optional[UUID] = None, + last_mapper_id: Optional[UUID] = None, + page: int = 1, + page_size: int = 200, + ) -> TaskListResponse: + await self._get_project(workspace_id, project_id) + + where = TaskingTask.project_id == project_id + if status_filter is not None: + where = where & (TaskingTask.status == status_filter) + if last_mapper_id is not None: + where = where & (TaskingTask.last_mapper_id == str(last_mapper_id)) + + total_q = await self.session.execute( + select(func.count()).select_from(TaskingTask).where(where) + ) + total = int(total_q.scalar() or 0) + + page = max(page, 1) + page_size = max(min(page_size, 1000), 1) + offset = (page - 1) * page_size + + rows = await self.session.execute( + select(TaskingTask) + .where(where) + .order_by(TaskingTask.task_number.asc()) + .limit(page_size) + .offset(offset) + ) + tasks = list(rows.scalars().all()) + + responses: list[TaskResponse] = [] + for t in tasks: + tr = await self._to_task_response(t) + if locked_by_user_id is not None and ( + tr.lock is None or tr.lock.user_id != locked_by_user_id + ): + continue + responses.append(tr) + + return TaskListResponse( + tasks=responses, + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + async def get_task( + self, workspace_id: int, project_id: int, task_number: int + ) -> TaskResponse: + await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + return await self._to_task_response(task) + + async def get_task_geojson( + self, workspace_id: int, project_id: int, task_number: int + ) -> TaskBoundariesFeatureCollection: + await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + geom_shape = to_shape(task.geometry) if task.geometry is not None else None + if geom_shape is None or not isinstance(geom_shape, ShapelyPolygon): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Task geometry missing or non-Polygon", + ) + task_geometry = _shapely_polygon_to_geojson(geom_shape) + task_feature = TaskBoundaryFeature( + type="Feature", + geometry=task_geometry, + properties={"taskNumber": task.task_number}, + ) + task_feature_collection = TaskBoundariesFeatureCollection( + type="FeatureCollection", + features=[task_feature], + ) + return task_feature_collection + + # ---- lock / unlock / extend / reset ---------------------------------- + + async def lock_task( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.OPEN: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project is not open", + ) + + task = await self._get_task(project_id, task_number) + + # Eligibility table. + role = await self._project_role(project_id, current_user, workspace_id) + if role is None: + raise ForbiddenException("User has no access to this project") + + if task.status in (TaskStatus.TO_MAP, TaskStatus.TO_REMAP): + if role not in ("contributor", "validator", "lead"): + raise ForbiddenException( + "Role does not permit locking this task for mapping" + ) + elif task.status == TaskStatus.TO_REVIEW: + if role not in ("validator", "lead"): + 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 + ): + raise ForbiddenException("Cannot validate a task you last mapped") + else: + raise ForbiddenException("Task is in a terminal state") + + # One active lock per task. + if await self._get_active_lock(task.id) is not None: # type: ignore[arg-type] + raise AlreadyExistsException("Task is already locked") + + # One active lock per (project, user). + other = await self._get_active_lock_for_user_in_project( + project_id, str(current_user.user_uuid) + ) + if other is not None: + other_task_rs = await self.session.execute( + select(TaskingTask).where(TaskingTask.id == other.task_id) + ) + other_task = other_task_rs.scalar_one() + summary = ExistingLockSummary( + task_number=other_task.task_number, + task_status=other_task.status, + locked_at=other.locked_at, + expires_at=other.expires_at, + ) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": "User already holds a lock in this project", + "existing_lock": summary.model_dump(mode="json"), + }, + ) + + now = datetime.now() + expires_at = now + timedelta(hours=project.lock_timeout_hours) + lock = TaskingLock( + task_id=task.id, # type: ignore[arg-type] + project_id=project_id, + user_auth_uid=str(current_user.user_uuid), + task_status_at_lock=task.status, + locked_at=now, + expires_at=expires_at, + ) + + try: + self.session.add(lock) + await self.session.flush() + await self._audit( + event_type=AuditEventType.TASK_LOCKED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={"taskNumber": task.task_number}, + ) + await self.session.commit() + except IntegrityError: + await self.session.rollback() + raise AlreadyExistsException( + "Task is already locked or user already holds a lock in this project" + ) + + return await self._to_task_response(task) + + async def unlock_task( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + force: bool = False, + ) -> None: + await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Task has no active lock", + ) + + if force: + if not current_user.isWorkspaceLead(workspace_id): + raise ForbiddenException("Only LEAD may force-release a lock") + release_reason = LockReleaseReason.LEAD_RELEASE + else: + if lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Only the lock holder may release it") + release_reason = LockReleaseReason.MANUAL + + now = datetime.now() + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) + .values(released_at=now, release_reason=release_reason) + ) + await self._audit( + event_type=AuditEventType.TASK_UNLOCKED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "releaseReason": release_reason.value, + }, + ) + await self.session.commit() + + async def extend_lock( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Task has no active lock to extend", + ) + if lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Only the lock holder may extend the lock") + + # `expires_at` comes back tz-aware from Postgres (TIMESTAMPTZ); + # the SQLModel column is typed as naive `datetime`. Strip tzinfo + # before re-binding to avoid asyncpg's offset-aware/naive + # mismatch error. + prev = lock.expires_at + if prev.tzinfo is not None: + prev = prev.replace(tzinfo=None) + new_expiry = prev + timedelta(hours=project.lock_timeout_hours) + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) + .values(expires_at=new_expiry) + ) + await self._audit( + event_type=AuditEventType.TASK_LOCK_EXTENDED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "expiresAt": new_expiry.isoformat(), + }, + ) + await self.session.commit() + return await self._to_task_response(task) + + async def reset_task( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + if project.status == ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Cannot reset a task while the project is in draft", + ) + + task = await self._get_task(project_id, task_number) + + now = datetime.now() + # Release any active lock. + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is not None: + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) + .values( + released_at=now, + release_reason=LockReleaseReason.RESET, + ) + ) + await self._audit( + event_type=AuditEventType.TASK_UNLOCKED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "releaseReason": LockReleaseReason.RESET.value, + }, + ) + + previous_status = task.status + if previous_status != TaskStatus.TO_MAP: + await self.session.execute( + update(TaskingTask) + .where(TaskingTask.id == task.id) + .values( + status=TaskStatus.TO_MAP, + last_mapper_id=None, + updated_at=now, + ) + ) + await self._audit( + event_type=AuditEventType.TASK_STATE_CHANGED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "from": previous_status.value, + "to": TaskStatus.TO_MAP.value, + }, + ) + else: + # Clear last_mapper_id even if state was already to_map. + await self.session.execute( + update(TaskingTask) + .where(TaskingTask.id == task.id) + .values(last_mapper_id=None, updated_at=now) + ) + + await self._audit( + event_type=AuditEventType.TASK_RESET, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={"taskNumber": task.task_number}, + ) + + await self.session.commit() + refreshed = await self._get_task(project_id, task_number) + return await self._to_task_response(refreshed) + + # ---- submit ---------------------------------------------------------- + + async def submit( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + body: SubmitRequest, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None or lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Caller does not hold the active lock") + + # Effective role for the *current* task status — drives the + # state transition table. + if task.status in (TaskStatus.TO_MAP, TaskStatus.TO_REMAP): + actor_role = "mapper" + elif task.status == TaskStatus.TO_REVIEW: + actor_role = "validator" + else: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Task is in a terminal state", + ) + + # Feedback is only meaningful in validator context. + # if body.feedback is not None and actor_role != "validator": + # raise HTTPException( + # status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + # detail="feedback is only accepted in validator context", + # ) + + now = datetime.now() + + # # Record the changeset row. + # cs = TaskingChangeset( + # task_id=task.id, # type: ignore[arg-type] + # project_id=project_id, + # lock_id=lock.id, # type: ignore[arg-type] + # user_auth_uid=str(current_user.user_uuid), + # osm_changeset_id=body.osm_changeset_id, + # submitted_at=now, + # ) + # self.session.add(cs) + # await self.session.flush() + + # await self._audit( + # event_type=AuditEventType.CHANGESET_SUBMITTED, + # project_id=project_id, + # task_id=task.id, + # actor_uuid=current_user.user_uuid, + # details={ + # "taskNumber": task.task_number, + # "osmChangesetId": body.osm_changeset_id, + # "done": body.done, + # }, + # ) + + if not body.done: + # Slide lock expiry from submitted_at + lock_timeout_hours. + new_expiry = now + timedelta(hours=project.lock_timeout_hours) + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) + .values(expires_at=new_expiry) + ) + await self._audit( + event_type=AuditEventType.TASK_LOCK_RENEWED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "expiresAt": new_expiry.isoformat(), + }, + ) + await self.session.commit() + refreshed = await self._get_task(project_id, task_number) + return await self._to_task_response(refreshed) + + # done = True → resolve next status per transition table. + previous_status = task.status + new_last_mapper = task.last_mapper_id + + if actor_role == "mapper": + new_last_mapper = str(current_user.user_uuid) + if previous_status == TaskStatus.TO_MAP: + new_status = ( + TaskStatus.TO_REVIEW + if project.review_required + else TaskStatus.COMPLETED + ) + else: # to_remap + new_status = TaskStatus.TO_REVIEW + else: # validator + if body.feedback is not None: + if body.feedback.reason_category is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="feedback.reasonCategory is required when sending feedback", + ) + # Insert the remap feedback row. + self.session.add( + TaskingFeedback( + task_id=task.id, # type: ignore[arg-type] + project_id=project_id, + author_user_auth_uid=str(current_user.user_uuid), + reason_category=body.feedback.reason_category, + notes=body.feedback.notes, + created_at=now, + ) + ) + await self._audit( + event_type=AuditEventType.FEEDBACK_SUBMITTED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "reasonCategory": body.feedback.reason_category.value, + }, + ) + new_status = TaskStatus.TO_REMAP + else: + new_status = TaskStatus.COMPLETED + + # Release the lock (auto_unlock). + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) + .values( + released_at=now, + release_reason=LockReleaseReason.AUTO_UNLOCK, + ) + ) + await self._audit( + event_type=AuditEventType.TASK_UNLOCKED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "releaseReason": LockReleaseReason.AUTO_UNLOCK.value, + }, + ) + + # Apply state transition. + await self.session.execute( + update(TaskingTask) + .where(TaskingTask.id == task.id) + .values( + status=new_status, + last_mapper_id=new_last_mapper, + updated_at=now, + ) + ) + if new_status != previous_status: + await self._audit( + event_type=AuditEventType.TASK_STATE_CHANGED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "from": previous_status.value, + "to": new_status.value, + }, + ) + + await self.session.commit() + refreshed = await self._get_task(project_id, task_number) + return await self._to_task_response(refreshed) + + async def submit_changeset( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + changesetId: int, + ) -> int: + project = await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None or lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Caller does not hold the active lock") + now = datetime.now() + + # Record the changeset row. + cs = TaskingChangeset( + task_id=task.id, # type: ignore[arg-type] + project_id=project_id, + lock_id=lock.id, # type: ignore[arg-type] + user_auth_uid=str(current_user.user_uuid), + osm_changeset_id=changesetId, + submitted_at=now, + ) + self.session.add(cs) + await self.session.flush() + # Get the id of the newly inserted changeset row + changeset_row_id = cs.id # type: ignore[assignment] + print(f"Inserted changeset row with ID: {changeset_row_id}") + + # Audit record for changeset submission + await self._audit( + event_type=AuditEventType.CHANGESET_SUBMITTED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "osmChangesetId": changesetId, + }, + ) + await self.session.commit() + return cs.id # Return the ID of the newly inserted changeset row + + +__all__ = ["TaskingTaskRepository"] diff --git a/api/src/tasking/tasks/routes.py b/api/src/tasking/tasks/routes.py new file mode 100644 index 0000000..08fe0b3 --- /dev/null +++ b/api/src/tasking/tasks/routes.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +from typing import Annotated, Optional +from uuid import UUID + +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 +from api.core.security import UserInfo, validate_token +from api.src.tasking.tasks.dtos import ( + SaveTasksRequest, + SaveTasksResponse, + SubmitRequest, + SubmitTaskChangeset, + SubmitTaskChangesetResponse, + TaskBoundariesFeatureCollection, + TaskListResponse, + TaskResponse, + ValidatePreviewResponse, +) +from api.src.tasking.tasks.repository import TaskingTaskRepository +from api.src.tasking.tasks.schemas import TaskStatus +from api.src.workspaces.repository import WorkspaceRepository + +router = APIRouter( + prefix="/workspaces/{workspace_id}/tasking/projects/{project_id}", + tags=["tasking-tasks"], +) + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def get_task_repo( + session: AsyncSession = Depends(get_osm_session), +) -> TaskingTaskRepository: + return TaskingTaskRepository(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: + await workspace_repo.getById(current_user, workspace_id) + + +def assert_workspace_lead(workspace_id: int, current_user: UserInfo) -> None: + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User does not have permission to edit this workspace", + ) + + +# --------------------------------------------------------------------------- +# Tasks — validate / save / list / get +# --------------------------------------------------------------------------- + + +@router.post("/tasks/grid", response_model=TaskBoundariesFeatureCollection) +async def generate_grid( + workspace_id: int, + project_id: int, + cell_size_meters: int = Query(1000, ge=50, le=100_000), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + """Generate a regular grid of square cells over the project AOI. + + LEAD-only preview — does NOT persist. The client posts the same + FeatureCollection back through `POST /tasks/save` to commit. + """ + 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) + + +@router.post("/tasks/validate", response_model=ValidatePreviewResponse) +async def validate_tasks( + workspace_id: int, + project_id: int, + body: TaskBoundariesFeatureCollection = Body(...), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await task_repo.validate(workspace_id, project_id, body) + + +@router.post("/tasks/save", response_model=SaveTasksResponse) +async def save_tasks( + workspace_id: int, + project_id: int, + body: SaveTasksRequest, + response: Response, + idempotency_key: Annotated[ + Optional[str], Header(alias="Idempotency-Key", min_length=8, max_length=128) + ] = None, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + 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 + return payload + + +@router.get("/tasks", response_model=TaskListResponse) +async def list_tasks( + workspace_id: int, + project_id: int, + 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), + page_size: int = Query(200, ge=1, le=1000), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.list_tasks( + workspace_id, + project_id, + status_filter=status_filter, + locked_by_user_id=locked_by_user_id, + last_mapper_id=last_mapper_id, + page=page, + page_size=page_size, + ) + + +@router.get("/tasks/{task_number}", response_model=TaskResponse) +async def get_task( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.get_task(workspace_id, project_id, task_number) + + +@router.get( + "/tasks/{task_number}/boundary.geojson", + response_model=TaskBoundariesFeatureCollection, +) +async def get_task_geojson( + workspace_id: int, + project_id: int, + task_number: int, + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + return await task_repo.get_task_geojson(workspace_id, project_id, task_number) + + +# --------------------------------------------------------------------------- +# Locks — acquire / release / extend / reset +# --------------------------------------------------------------------------- + + +@router.post("/tasks/{task_number}/lock", response_model=TaskResponse) +async def lock_task( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.lock_task( + workspace_id, project_id, task_number, current_user + ) + + +@router.delete( + "/tasks/{task_number}/lock", + status_code=status.HTTP_204_NO_CONTENT, +) +async def unlock_task( + workspace_id: int, + project_id: int, + task_number: int, + force: bool = Query(False), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await task_repo.unlock_task( + workspace_id, + project_id, + task_number, + current_user, + force=force, + ) + + +@router.post("/tasks/{task_number}/extend", response_model=TaskResponse) +async def extend_lock( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.extend_lock( + workspace_id, project_id, task_number, current_user + ) + + +@router.post("/tasks/{task_number}/reset", response_model=TaskResponse) +async def reset_task( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await task_repo.reset_task( + workspace_id, project_id, task_number, current_user + ) + + +# --------------------------------------------------------------------------- +# Submit — Done? flow +# --------------------------------------------------------------------------- + + +@router.post("/tasks/{task_number}/submit", response_model=TaskResponse) +async def submit_task( + workspace_id: int, + project_id: int, + task_number: int, + body: SubmitRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.submit( + workspace_id, project_id, task_number, current_user, body + ) + + +@router.post( + "/tasks/{task_number}/submit-changeset", response_model=SubmitTaskChangesetResponse +) +async def submit_changeset( + workspace_id: int, + project_id: int, + task_number: int, + changeset_model: SubmitTaskChangeset, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + cs_id = await task_repo.submit_changeset( + workspace_id, + project_id, + task_number, + current_user, + changeset_model.osm_changeset_id, + ) + return SubmitTaskChangesetResponse( + osm_changeset_id=changeset_model.osm_changeset_id, + task_number=task_number, + project_id=project_id, + workspace_id=workspace_id, + inserted_id=cs_id, + ) diff --git a/api/src/tasking/tasks/schemas.py b/api/src/tasking/tasks/schemas.py new file mode 100644 index 0000000..f3c114b --- /dev/null +++ b/api/src/tasking/tasks/schemas.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from enum import StrEnum +from typing import Any, Optional + +from geoalchemy2 import Geometry +from sqlalchemy import Column +from sqlalchemy import Enum as SAEnum +from sqlmodel import Field, SQLModel + +# --------------------------------------------------------------------------- +# Enums (mirrors of postgres enums in the migration) +# --------------------------------------------------------------------------- + + +class TaskStatus(StrEnum): + TO_MAP = "to_map" + TO_REVIEW = "to_review" + TO_REMAP = "to_remap" + COMPLETED = "completed" + + +class LockReleaseReason(StrEnum): + AUTO_UNLOCK = "auto_unlock" + MANUAL = "manual" + LEAD_RELEASE = "lead_release" + STALE_TIMEOUT = "stale_timeout" + RESET = "reset" + + +class FeedbackReason(StrEnum): + INCOMPLETE_MAPPING = "incomplete_mapping" + DATA_QUALITY_ISSUE = "data_quality_issue" + WRONG_AREA = "wrong_area" + OTHER = "other" + + +def _task_status_column(*, nullable: bool = False) -> Column: + return Column( + SAEnum( + TaskStatus, + name="tasking_task_status", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=nullable, + ) + + +def _release_reason_column() -> Column: + return Column( + SAEnum( + LockReleaseReason, + name="tasking_lock_release_reason", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=True, + ) + + +def _feedback_reason_column() -> Column: + return Column( + SAEnum( + FeedbackReason, + name="tasking_feedback_reason", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=True, + ) + + +# --------------------------------------------------------------------------- +# Table models +# --------------------------------------------------------------------------- + + +class TaskingTask(SQLModel, table=True): + """Per-project task polygon — saved as part of a bulk batch.""" + + __tablename__ = "tasking_tasks" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + project_id: int = Field(nullable=False, index=True) + task_number: int = Field(nullable=False) + area_sqkm: Decimal = Field(nullable=False) + + status: TaskStatus = Field( + default=TaskStatus.TO_MAP, + sa_column=_task_status_column(), + ) + + last_mapper_id: Optional[str] = Field(default=None, nullable=True) + + geometry: Optional[Any] = Field( + default=None, + sa_column=Column( + Geometry(geometry_type="POLYGON", srid=4326), + nullable=False, + ), + ) + + created_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + updated_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False, "onupdate": datetime.now}, + ) + + +class TaskingLock(SQLModel, table=True): + """Active / historical lock on a task. + + Active rows have `released_at IS NULL`. Two partial unique indexes + enforce: at most one active lock per task, and at most one active + lock per (project, user). + """ + + __tablename__ = "tasking_locks" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + task_id: int = Field(nullable=False) + project_id: int = Field(nullable=False) + user_auth_uid: str = Field(nullable=False) + + task_status_at_lock: TaskStatus = Field( + sa_column=_task_status_column(), + ) + + locked_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + expires_at: datetime = Field(nullable=False) + released_at: Optional[datetime] = None + + release_reason: Optional[LockReleaseReason] = Field( + default=None, + sa_column=_release_reason_column(), + ) + + +class TaskingChangeset(SQLModel, table=True): + """One row per `/submit` call — links a lock session to an OSM changeset.""" + + __tablename__ = "tasking_changesets" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + task_id: int = Field(nullable=False) + project_id: int = Field(nullable=False) + lock_id: int = Field(nullable=False) + user_auth_uid: str = Field(nullable=False) + osm_changeset_id: int = Field(nullable=False) + + submitted_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + + +class TaskingFeedback(SQLModel, table=True): + """Per-task feedback row (remap rejections + free-form notes).""" + + __tablename__ = "tasking_feedback" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + task_id: int = Field(nullable=False) + project_id: int = Field(nullable=False) + author_user_auth_uid: str = Field(nullable=False) + + reason_category: Optional[FeedbackReason] = Field( + default=None, + sa_column=_feedback_reason_column(), + ) + notes: str = Field(nullable=False) + + created_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + + +__all__ = [ + "FeedbackReason", + "LockReleaseReason", + "TaskStatus", + "TaskingChangeset", + "TaskingFeedback", + "TaskingLock", + "TaskingTask", +] diff --git a/docs/db-schema/db-schema.png b/docs/db-schema/db-schema.png new file mode 100644 index 0000000..fbd1187 Binary files /dev/null and b/docs/db-schema/db-schema.png differ diff --git a/docs/requirements/project-roles-integration.md b/docs/requirements/project-roles-integration.md new file mode 100644 index 0000000..87b25a9 --- /dev/null +++ b/docs/requirements/project-roles-integration.md @@ -0,0 +1,19 @@ +# User Role Management — Workspaces + Tasking Manager + +## Current Behaviour (Workspaces) + +- Users authenticate via TDEI and inherit **implicit contributor** access across all workspaces in their project groups — no manual provisioning needed. +- The user who creates a workspace is automatically assigned the **`lead`** role for that workspace and recorded in the system. +- Today, all collaboration within a workspace operates under this single workspace-level role model. + +## New Requirement (Tasking Manager) + +Tasking Manager introduces **project-level roles** (`lead`, `validator`, `contributor`) so that work inside a workspace can be delegated and reviewed by specific people, not just by anyone with workspace access. + +To deliver this, two new capabilities are needed: + +### 1. User Search +A workspace lead creating or managing a project must be able to search for the right users from within their project group — by name or username — and pick them. This requires **integration with the TDEI user-search API** to surface candidate users in the UI. + +### 2. Role Assignment +Once selected, users are assigned a project-level role (`lead`, `validator`, or `contributor`) and recorded against the project. The lead can later **add**, **change**, or **remove** these assignments as the project evolves, with safeguards to ensure every project always has at least one `lead`. \ No newline at end of file diff --git a/docs/tasking-mvp/tasking-mvp.openapi.json b/docs/tasking-mvp/tasking-mvp.openapi.json new file mode 100644 index 0000000..6e066dc --- /dev/null +++ b/docs/tasking-mvp/tasking-mvp.openapi.json @@ -0,0 +1,3358 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Workspaces Tasking Manager API", + "version": "1.0.0", + "description": "Tasking Manager API for the Workspaces Tasking Manager MVP.\n" + }, + "servers": [ + { + "url": "/api/v1", + "description": "workspaces-backend FastAPI app \u2014 all routers are mounted under `/api/v1` (see `api/main.py`)." + } + ], + "tags": [ + { + "name": "projects", + "description": "Tasking project CRUD and lifecycle." + }, + { + "name": "aoi", + "description": "Project area-of-interest upload, retrieval, deletion." + }, + { + "name": "tasks", + "description": "Task validation, persistence and read access." + }, + { + "name": "locks", + "description": "Lock lifecycle on a task." + }, + { + "name": "submit", + "description": "Done? submit flow \u2014 changeset + state transition." + }, + { + "name": "roles", + "description": "Workspace and project role management." + }, + { + "name": "audit", + "description": "Audit trail." + }, + { + "name": "stats", + "description": "Project and user statistics." + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/workspaces/{workspace_id}/tasking/projects": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + } + ], + "get": { + "tags": [ + "projects" + ], + "summary": "List tasking projects in a workspace.", + "description": "Paginated list of non-deleted projects. Any workspace contributor.", + "operationId": "listProjects", + "parameters": [ + { + "in": "query", + "name": "status", + "schema": { + "$ref": "#/components/schemas/ProjectStatus" + } + }, + { + "in": "query", + "name": "textSearch", + "schema": { + "type": "string", + "maxLength": 255 + }, + "description": "Case-insensitive substring match on `name`." + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 20 + } + }, + { + "in": "query", + "name": "orderBy", + "schema": { + "type": "string", + "enum": [ + "createdAt", + "updatedAt", + "name" + ], + "default": "createdAt" + } + }, + { + "in": "query", + "name": "orderByType", + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + } + } + ], + "responses": { + "200": { + "description": "Paginated list.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/WorkspaceNotFound" + } + } + }, + "post": { + "tags": [ + "projects" + ], + "summary": "Create a tasking project (optionally with AOI and role assignments).", + "description": "LEAD only. Creates a project in `draft` status.\n\nOptional inline AOI may be provided (same validation as\n`POST .../aoi`).\n\nOptional `roleAssignments` may be provided to seed the\nproject-level role overrides table (`tasking_project_roles`) in\nthe same transaction. The creator is auto-added with\n`role = lead`; other users may be added with any of `lead`,\n`validator`, `contributor`.\n\nActivation later requires at least one user assigned with\n`contributor` or `validator` so the project has at least one\nworker before it goes live \u2014 see `POST .../activate`.\n", + "operationId": "createProject", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/WorkspaceNotFound" + }, + "409": { + "description": "A non-deleted project with this name already exists.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "projects" + ], + "summary": "Get a tasking project.", + "operationId": "getProject", + "responses": { + "200": { + "description": "Project detail.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + }, + "patch": { + "tags": [ + "projects" + ], + "summary": "Update editable settings.", + "description": "LEAD only. Per-status mutability:\n\n| Field | draft | open | done |\n|--------------------|-------|------|------|\n| name | yes | yes | no |\n| instructions | yes | yes | no |\n| lockTimeoutHours | yes | yes | no |\n| reviewRequired | yes | NO | no |\n\nAOI is updated through the dedicated AOI endpoints, not PATCH.\nImmutable-field writes return 422.\n", + "operationId": "updateProject", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + }, + "delete": { + "tags": [ + "projects" + ], + "summary": "Soft-delete a tasking project.", + "description": "LEAD only. Sets `deletedAt`, hard-deletes child tasks, retains\naudit rows (flagged with `project_deleted=true`). Returns 409\nif any task currently has an active lock \u2014 force-release first.\n", + "operationId": "deleteProject", + "responses": { + "204": { + "description": "Soft-deleted." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "Project has active task locks.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/activate": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "projects" + ], + "summary": "Activate a draft project (`draft` \u2192 `open`).", + "description": "LEAD only. Pre-conditions (all must hold; 422 with a clear\n`detail` otherwise):\n - Project has a `name`.\n - Project has an AOI set.\n - Project has at least one saved task.\n - At least one user is allocated to the project with role\n `contributor` or `validator` in `tasking_project_roles`\n (the creator's auto-`lead` row does not satisfy this \u2014 a\n worker must be assigned).\n", + "operationId": "activateProject", + "responses": { + "200": { + "description": "Activated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/close": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "projects" + ], + "summary": "Close an open project (`open` \u2192 `done`).", + "description": "LEAD only. Requires every task to be `completed` and no active\nlocks.\n", + "operationId": "closeProject", + "responses": { + "200": { + "description": "Closed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "One or more tasks are still locked or not completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/reset": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "projects" + ], + "summary": "Reset all tasks in a project back to `to_map`.", + "description": "LEAD only. Restarts work on the whole project so contributors\ncan re-map and re-validate.\n\nEffect (single transaction):\n - Releases every active lock with `release_reason = 'reset'`,\n emitting one `task_unlocked` audit event per released lock.\n - Sets every task's `status` to `to_map`, clears\n `last_mapper_id`, emits a `task_state_changed` audit event\n for every task whose status actually changed.\n - If the project was `done`, transitions it back to `open`.\n - Emits one `project_reset` audit event for the project.\n\nTasks themselves (geometry, history of changesets and remap\nfeedback) are NOT deleted \u2014 only their lifecycle state is\nrewound. Stats and audit history remain intact.\n", + "operationId": "resetProject", + "responses": { + "200": { + "description": "Reset complete.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "description": "Project is in `draft` (nothing to reset).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/aoi": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "aoi" + ], + "summary": "Get the AOI of a project.", + "description": "Returns the AOI as a GeoJSON Feature (Polygon, EPSG:4326).\n`404` if no AOI is set.\n", + "operationId": "getProjectAoi", + "responses": { + "200": { + "description": "AOI.", + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + }, + "post": { + "tags": [ + "aoi" + ], + "summary": "Upload or replace the AOI.", + "description": "LEAD only. Accepts a GeoJSON `Feature`, `FeatureCollection`\n(single feature), or bare geometry. Geometry may be either\n`Polygon` or `MultiPolygon` (EPSG:4326). Single-Polygon inputs\nare upcast to MultiPolygon on insert; the storage column is\n`GEOMETRY(MultiPolygon, 4326)`.\n\nProject must be in `draft`. Replacing an AOI hard-deletes any\nsaved tasks.\n", + "operationId": "uploadProjectAoi", + "requestBody": { + "required": true, + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/AoiUploadRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AoiUploadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "AOI stored.", + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + }, + "delete": { + "tags": [ + "aoi" + ], + "summary": "Delete the AOI.", + "description": "LEAD only. Project must be in `draft`. Clears the AOI, hard-\ndeletes any saved tasks (releasing their locks in the same\ntransaction), clears `taskBoundaryType`. Returns 404 if no AOI\nis set.\n", + "operationId": "deleteProjectAoi", + "responses": { + "204": { + "description": "AOI deleted." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "description": "Project not found, or project has no AOI.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Project is not in `draft`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/validate": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "tasks" + ], + "summary": "Validate a GeoJSON FeatureCollection of candidate task boundaries.", + "description": "LEAD only. Does NOT persist anything \u2014 the client must POST each\nvalidated Feature to `/tasks/save` to commit.\n\nProject preconditions: status `draft`, AOI uploaded.\n\nHard validation (422 on failure):\n - Top-level type must be `FeatureCollection`.\n - Every feature geometry must be `Polygon` (no MultiPolygon).\n - Coordinates valid lon/lat (EPSG:4326).\n - Every polygon lies within the project AOI (centroid-inside\n is not sufficient).\n - At least one feature.\n\nNon-blocking warning:\n - `polygon_exceeds_grid_size` \u2014 area exceeds\n `TM_TASKING_GRID_SIZE_METERS`\u00b2 (km\u00b2).\n", + "operationId": "validateTasks", + "requestBody": { + "required": true, + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + } + } + }, + "responses": { + "200": { + "description": "Validation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidatePreviewResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/save": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/IdempotencyKeyHeader" + } + ], + "post": { + "tags": [ + "tasks" + ], + "summary": "Persist a validated FeatureCollection as the project's tasks (bulk).", + "description": "LEAD only. Commits the **entire** FeatureCollection as\n`tasking_tasks` rows in a single transaction.\n\nEffect (single transaction; all-or-nothing):\n 1. Re-validates every feature against the project AOI (same\n rules as `POST /tasks/validate`). Any hard failure aborts\n the transaction \u2014 no rows are written.\n 2. Allocates sequential `taskNumber`s starting at 1, in the\n order features appear in the request.\n 3. Bulk-inserts the `tasking_tasks` rows.\n 4. Sets `tasking_projects.task_boundary_type` to `source`.\n 5. Emits one `task_created` audit event per task.\n\nProject preconditions:\n - Project must be in `draft`.\n - Project must have an AOI set.\n - Project must currently have **no tasks** (a second save is\n rejected with 409 `\"Tasks already saved\"`). To replace\n tasks, re-upload the AOI in `draft` \u2014 that wipes existing\n tasks and re-enables save.\n\nIdempotency: `Idempotency-Key` is honoured at the batch level.\nSame key + same body within the configured TTL replays the\noriginal response (HTTP 200, `replayed: true`). Same key with\ndifferent body returns 409\n`\"Idempotency key reused with a different request\"`. See the\ntop-level idempotency contract.\n", + "operationId": "saveTasks", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveTasksRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Tasks created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveTasksResponse" + } + } + } + }, + "200": { + "description": "Idempotent replay (same key + same body); `replayed: true`.\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveTasksResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "One of:\n - \"Tasks already saved\" \u2014 project already has tasks; re-upload AOI to start over.\n - \"Idempotency key reused with a different request\".\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "tasks" + ], + "summary": "List tasks (geometry always included; serves list + map views).", + "description": "Paginated. Any workspace contributor.\n", + "operationId": "listTasks", + "parameters": [ + { + "in": "query", + "name": "status", + "schema": { + "$ref": "#/components/schemas/TaskStatus" + } + }, + { + "in": "query", + "name": "lockedByUserId", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "lastMapperId", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200 + } + } + ], + "responses": { + "200": { + "description": "Tasks.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "get": { + "tags": [ + "tasks" + ], + "summary": "Get task detail (geometry, status, lock, last mapper).", + "operationId": "getTask", + "responses": { + "200": { + "description": "Task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Workspace, project, or task not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/lock": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "locks" + ], + "summary": "Acquire a lock on a task.", + "description": "Eligibility:\n\n| Task status | Allowed roles |\n|-------------|---------------------------------------------------|\n| `to_map` | CONTRIBUTOR, LEAD |\n| `to_remap` | CONTRIBUTOR, LEAD |\n| `to_review` | VALIDATOR, LEAD \u2014 and NOT this task's last_mapper |\n| `completed` | (none) |\n\nOther rules:\n - No other active lock on this task.\n - Caller holds no other active lock in this project (one-lock-\n per-project rule). 409 response includes `existingLock`.\n - Project status is `open`.\n\nSide effects:\n - INSERT `tasking_locks` with\n `expires_at = NOW() + project.lock_timeout_hours`.\n - Emit `task_locked` audit event.\n", + "operationId": "lockTask", + "responses": { + "200": { + "description": "Lock acquired. Body is the updated task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Role does not permit locking in this status, or self-validation guard hit.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "409": { + "description": "One of:\n - \"Task is already locked\".\n - \"User already holds a lock in this project\" \u2014 body includes `existingLock`.\n - \"Project is not open\".\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LockConflictError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "locks" + ], + "summary": "Release a lock on a task.", + "description": "Default: caller must be the active lock holder\n(`release_reason = manual`).\n\nWith `?force=true`: caller must be LEAD\n(`release_reason = lead_release`).\n\nTask `status` is unchanged in both modes. Force-release does\nnot relock \u2014 LEAD must POST `/lock` separately to take over.\n", + "operationId": "unlockTask", + "parameters": [ + { + "in": "query", + "name": "force", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "204": { + "description": "Released." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Not the lock holder (default mode) or not LEAD (force mode).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "409": { + "description": "Task has no active lock.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/extend": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "locks" + ], + "summary": "Extend the expiry of your own lock.", + "description": "Caller must hold the active lock. Adds the project's\n`lock_timeout_hours` to the current `expires_at` (slides from\nthe existing expiry, not from `NOW()`). Emits\n`task_lock_extended`.\n", + "operationId": "extendTaskLock", + "responses": { + "200": { + "description": "Lock extended.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller does not hold the active lock.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "409": { + "description": "Task has no active lock to extend.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/reset": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "locks" + ], + "summary": "Reset a single task back to `to_map`.", + "description": "LEAD only. Restarts work on a task \u2014 useful when a contributor\nor validator pushed a task into a wrong terminal state and\nsomeone needs to redo it.\n\nEffect (single transaction):\n - If an active lock exists, it is released with\n `release_reason = 'reset'` and a `task_unlocked` audit\n event is emitted.\n - Task `status` is set to `to_map`, `last_mapper_id` is\n cleared, and a `task_state_changed` audit event is emitted\n (only when the status actually changed).\n - A `task_reset` audit event is emitted.\n\nExisting changeset rows and remap-feedback rows are NOT\ndeleted \u2014 only the live task state is rewound.\n\nPre-conditions: project status is `open` or `done`.\n", + "operationId": "resetTask", + "responses": { + "200": { + "description": "Task reset.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "422": { + "description": "Project is in `draft` (no tasks to reset yet).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/submit": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "submit" + ], + "summary": "Submit an OSM changeset and optionally transition state.", + "description": "The \"Done?\" flow. Caller must hold the active lock.\n\nEffective actor role is inferred from current task status:\n - `to_map` / `to_remap` \u2192 mapper context\n - `to_review` \u2192 validator context\n (LEAD may act in either context.)\n\nState transition (when `done:true`):\n\n| Current | Effective role | Feedback? | New status |\n|-------------|--------------------|-----------|---------------------------------------------------------|\n| `to_map` | contributor / lead | n/a | `to_review` if `review_required` else `completed` |\n| `to_remap` | contributor / lead | n/a | `to_review` |\n| `to_review` | validator / lead | no | `completed` |\n| `to_review` | validator / lead | yes | `to_remap` (and INSERT `tasking_feedback` with `reasonCategory` set) |\n\nWith `done:false` the state is unchanged but the lock's\n`expires_at` slides forward to `submitted_at + lock_timeout_hours`\n(emits `task_lock_renewed`).\n\n`osmChangesetId` is supplied by the client. In practice it is\nauto-populated by the editor (Rapid in the iframe) on save \u2014\nthe UI does not ask the user to type it. The id may optionally\nbe verified by looking it up in the internal `osm-web` service\n(same network); a verification miss returns `422` with detail\n\"Invalid OSM changeset id\" and nothing is written.\n\n`last_mapper_id` is updated only when the effective role is\nmapper.\n\nFeedback rules:\n - `feedback` is meaningful only in validator context on\n `to_review`. Otherwise its presence returns 422.\n - For `to_review` \u2192 `to_remap`, `feedback.reasonCategory` is\n required (422 if missing).\n", + "operationId": "submitTask", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Submission accepted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller does not hold the active lock.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/users": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "List privileged workspace members (lead + validator role rows).", + "description": "Returns the rows in `user_workspace_roles` for this workspace,\njoined with their `users` record. Visible to any workspace\ncontributor (`isWorkspaceContributor`).\n\nDoes NOT include implicit contributors \u2014 only users with an\nexplicit LEAD or VALIDATOR row. The UI uses this for the team\nmanagement table; broader user listings come from the TDEI\nproxy (see `/assignable-users`).\n", + "operationId": "listWorkspaceMembers", + "responses": { + "200": { + "description": "Privileged members.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkspaceUserRoleItem" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller is not a member of the workspace's project group.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/users/{user_id}/role": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "put": { + "tags": [ + "roles" + ], + "summary": "Assign (upsert) a privileged role for a user on a workspace.", + "description": "LEAD only. Idempotent upsert into `user_workspace_roles`.\n\nThe body's `role` must be `lead` or `validator` \u2014 assigning\n`contributor` is rejected with 422 (\"cannot assign implicit\nrole 'contributor' directly\"). Contributor is the default for\nany project-group member; to remove an explicit role and fall\nback to contributor, call `DELETE /users/{user_id}` below.\n\nThe named user must have signed in to Workspaces at least once\n(i.e. exist in `users.auth_uid`). If they have not, returns\n404 with detail \"User {uuid} has not signed in to Workspaces\nyet\".\n\nSide effect: the target user's `UserInfo` cache entry is\nevicted (`evict_user_from_cache`) so their next request\nreflects the new role immediately.\n", + "operationId": "assignWorkspaceMemberRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetRoleRequest" + } + } + } + }, + "responses": { + "204": { + "description": "Role assigned (or updated)." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller is not LEAD on this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Workspace not found, or the named user has not signed in to Workspaces yet.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "\"cannot assign implicit role 'contributor' directly\".", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/users/{user_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "delete": { + "tags": [ + "roles" + ], + "summary": "Remove a user's privileged role on a workspace.", + "description": "LEAD only. Deletes the row from `user_workspace_roles`; the\nuser's role falls back to implicit `contributor` (or to\nwhatever TDEI grants \u2014 `poc` still maps to LEAD).\n\nSide effect: the target user's `UserInfo` cache entry is\nevicted.\n", + "operationId": "removeWorkspaceMemberRole", + "responses": { + "204": { + "description": "Role row removed." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller is not LEAD on this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No role row exists for this user/workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/roles": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "List role assignments on a project.", + "description": "Returns every row in `tasking_project_roles` for the project,\njoined with `users.display_name` for human-readable labels.\nAny workspace contributor; the workspace tenancy gate still\napplies (404 for outsiders).\n", + "operationId": "listProjectRoles", + "responses": { + "200": { + "description": "All role assignments.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + }, + "post": { + "tags": [ + "roles" + ], + "summary": "Add a role assignment to a project.", + "description": "Workspace LEAD **or** project LEAD only. Inserts a row in\n`tasking_project_roles`. Duplicate `(project_id, user_id)` pairs\nreturn 409 \u2014 use PATCH to change a role. The `user_id` must\nalready exist in `users` (i.e. the user has signed in to\nWorkspaces at least once); otherwise 422 with a\n`missing_user_ids` list.\n", + "operationId": "addProjectRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleAddRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Role added.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "User already has a role on this project \u2014 use PATCH.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "`user_id` does not exist in the `users` table.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "missing_user_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/roles/{user_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "Get a single user's role on a project.", + "description": "Returns the single `tasking_project_roles` row for this user on\nthis project. 404 if no assignment exists. Any workspace\ncontributor; the tenancy gate still applies.\n", + "operationId": "getProjectRole", + "responses": { + "200": { + "description": "Role assignment.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "User has no role on this project, or project not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "tags": [ + "roles" + ], + "summary": "Upsert a user's role on a project.", + "description": "Workspace LEAD **or** project LEAD only. Idempotent insert-or-\nupdate on `tasking_project_roles`. Returns **201** when the row\nis created, **200** when an existing row is updated.\n\n**Last-LEAD guard**: a PUT that demotes the only remaining LEAD\nreturns 422 \u2014 assign another LEAD first.\n\nFor partial-update semantics, prefer PATCH.\n", + "operationId": "putProjectRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Existing assignment updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "201": { + "description": "New assignment created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "description": "One of:\n - `user_id` does not exist in `users` (insert path).\n - Would remove the last LEAD (demote path).\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "tags": [ + "roles" + ], + "summary": "Change a user's role on a project.", + "description": "Workspace LEAD **or** project LEAD only. Updates the row in\n`tasking_project_roles`. Returns 404 if the user has no role\nassignment yet (use POST to add one).\n\n**Last-LEAD guard**: demoting the only remaining LEAD on the\nproject returns 422 \u2014 assign another LEAD first.\n", + "operationId": "updateProjectRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Role updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "description": "User has no role on this project, or project not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Would remove the last LEAD on the project.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "roles" + ], + "summary": "Remove a role assignment from a project.", + "description": "Workspace LEAD **or** project LEAD only. Deletes the row from\n`tasking_project_roles`.\n\n**Last-LEAD guard**: removing the only remaining LEAD on the\nproject returns 422 \u2014 assign another LEAD first.\n", + "operationId": "removeProjectRole", + "responses": { + "204": { + "description": "Role removed." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "description": "User has no role on this project, or project not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Would remove the last LEAD on the project.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/me/workspaces/{workspace_id}/tasking/projects/roles": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "List the caller's role for every project in a workspace.", + "description": "Single round-trip for the project-list page: returns one entry\nper project with the caller's role on that project.\n", + "operationId": "listSelfProjectRoles", + "responses": { + "200": { + "description": "Project roles for the caller.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelfProjectRolesResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/WorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/audit": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "audit" + ], + "summary": "List project-level audit events.", + "description": "Paginated, newest first. Any workspace contributor. Soft-deleted\nprojects are visible only with `include_deleted=true`.\n", + "operationId": "listProjectAudit", + "parameters": [ + { + "in": "query", + "name": "event_type", + "schema": { + "$ref": "#/components/schemas/AuditEventType" + } + }, + { + "in": "query", + "name": "task_number", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "in": "query", + "name": "actor_user_id", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "occurred_from", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "occurred_to", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "include_deleted", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "in": "query", + "name": "order_by_type", + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + } + } + ], + "responses": { + "200": { + "description": "Events.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditEventListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/audit": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "get": { + "tags": [ + "audit" + ], + "summary": "List task-level audit events.", + "description": "Newest first. Any workspace contributor.", + "operationId": "listTaskAudit", + "parameters": [ + { + "in": "query", + "name": "event_type", + "schema": { + "$ref": "#/components/schemas/AuditEventType" + } + }, + { + "in": "query", + "name": "actor_user_id", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "occurred_from", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "occurred_to", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 25 + } + }, + { + "in": "query", + "name": "order_by_type", + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + } + } + ], + "responses": { + "200": { + "description": "Events for the task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditEventListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/stats": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "stats" + ], + "summary": "Project statistics summary.", + "description": "Live-computed. Any workspace contributor.\n\nField semantics:\n - `tasksByStatus` always zero-fills all four states.\n - `percentMapped` \u2014 share of tasks past `to_map` at least\n once. Rounded to nearest integer.\n - `percentCompleted` \u2014 share whose current status is\n `completed`.\n - `avgTaskDurationMinutes` \u2014 mean across completed tasks of\n the sum of `(released_at - locked_at)` over that task's\n locks. `null` when no task is completed yet.\n - `uniqueMappers` \u2014 distinct submitters with at least one\n changeset while task was in `to_map` or `to_remap`.\n - `uniqueValidators` \u2014 distinct submitters with at least one\n changeset while task was in `to_review`.\n", + "operationId": "getProjectStats", + "responses": { + "200": { + "description": "Stats.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStats" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/users/{user_id}/stats": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "get": { + "tags": [ + "stats" + ], + "summary": "Cross-project stats for a user within a workspace.", + "description": "`totals` sums across the workspace's non-deleted projects.\n`byProject` lists only projects with non-zero contribution.\n\nCounting rules:\n - `tasksMapped` \u2014 distinct tasks where the user submitted at\n least one `done:true` changeset while the task was in\n `to_map` or `to_remap`.\n - `tasksValidated` \u2014 distinct tasks where the user submitted\n at least one `done:true` changeset while in `to_review`.\n - `totalMappingMinutes` / `totalValidationMinutes` \u2014 sum of\n `(released_at - locked_at)` over the user's lock sessions\n in mapping/validation context.\n - `totalAreaSqkm` \u2014 sum of `tasking_tasks.area_sqkm` over\n tasks the user mapped (each task counted once).\n", + "operationId": "getUserStats", + "responses": { + "200": { + "description": "User stats.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStats" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Workspace not found, user has no activity, or outside tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "parameters": { + "WorkspaceIdPath": { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + "ProjectIdPath": { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + "TaskNumberPath": { + "name": "task_number", + "in": "path", + "required": true, + "description": "Sequential, human-readable id scoped to the project. Starts at 1.", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + "UserIdPath": { + "name": "user_id", + "in": "path", + "required": true, + "description": "TDEI auth user uuid.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "IdempotencyKeyHeader": { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "description": "Client-generated unique value (UUID recommended). Scoped per project.", + "schema": { + "type": "string", + "minLength": 8, + "maxLength": 128 + } + } + }, + "schemas": { + "ProjectStatus": { + "type": "string", + "enum": [ + "draft", + "open", + "done" + ] + }, + "TaskStatus": { + "type": "string", + "enum": [ + "to_map", + "to_review", + "to_remap", + "completed" + ] + }, + "TaskBoundaryType": { + "type": "string", + "enum": [ + "grid", + "import" + ], + "nullable": true + }, + "GridSource": { + "type": "string", + "enum": [ + "grid", + "import" + ] + }, + "WorkspaceUserRoleType": { + "type": "string", + "enum": [ + "lead", + "validator", + "contributor" + ] + }, + "LockReleaseReason": { + "type": "string", + "enum": [ + "auto_unlock", + "manual", + "lead_release", + "stale_timeout", + "reset" + ], + "description": "`auto_unlock` \u2014 released by a successful `/submit` with `done:true`.\n`manual` \u2014 caller released their own lock via `DELETE /lock`.\n`lead_release` \u2014 LEAD force-released via `DELETE /lock?force=true`.\n`stale_timeout` \u2014 released by the background job (`expires_at < NOW()`).\n`reset` \u2014 released by `POST /reset` on the task or its project.\n" + }, + "FeedbackReason": { + "type": "string", + "enum": [ + "incomplete_mapping", + "data_quality_issue", + "wrong_area", + "other" + ], + "description": "Reason categories persisted to `tasking_feedback.reason_category`.\nRequired when feedback is used to drive a `to_review \u2192 to_remap`\ntransition (see `POST /submit`); optional for generic free-form\nnotes on a task.\n" + }, + "AuditEventType": { + "type": "string", + "enum": [ + "project_created", + "project_activated", + "project_closed", + "project_edited", + "project_deleted", + "aoi_uploaded", + "aoi_deleted", + "task_created", + "task_state_changed", + "task_locked", + "task_lock_extended", + "task_lock_renewed", + "task_unlocked", + "task_reset", + "project_reset", + "changeset_submitted", + "feedback_submitted" + ] + }, + "GeoJsonPolygon": { + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Polygon" + ] + }, + "coordinates": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "items": { + "type": "number" + } + } + } + } + } + }, + "GeoJsonMultiPolygon": { + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiPolygon" + ] + }, + "coordinates": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "items": { + "type": "number" + } + } + } + } + } + } + }, + "AoiGeometry": { + "description": "AOI accepts either a single Polygon or a MultiPolygon (EPSG:4326). Servers store both as MultiPolygon (single-Polygon inputs are upcast on insert).", + "oneOf": [ + { + "$ref": "#/components/schemas/GeoJsonPolygon" + }, + { + "$ref": "#/components/schemas/GeoJsonMultiPolygon" + } + ] + }, + "AoiFeature": { + "type": "object", + "required": [ + "type", + "geometry", + "properties" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Feature" + ] + }, + "geometry": { + "$ref": "#/components/schemas/AoiGeometry" + }, + "properties": { + "type": "object", + "additionalProperties": true + } + } + }, + "AoiFeatureCollection": { + "type": "object", + "required": [ + "type", + "features" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "FeatureCollection" + ] + }, + "features": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "$ref": "#/components/schemas/AoiFeature" + } + } + } + }, + "AoiUploadRequest": { + "oneOf": [ + { + "$ref": "#/components/schemas/AoiFeature" + }, + { + "$ref": "#/components/schemas/AoiFeatureCollection" + }, + { + "$ref": "#/components/schemas/AoiGeometry" + } + ] + }, + "TaskBoundaryFeature": { + "type": "object", + "required": [ + "type", + "geometry" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Feature" + ] + }, + "geometry": { + "$ref": "#/components/schemas/GeoJsonPolygon" + }, + "properties": { + "type": "object", + "additionalProperties": true + } + } + }, + "TaskBoundariesFeatureCollection": { + "type": "object", + "required": [ + "type", + "features" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "FeatureCollection" + ] + }, + "features": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/TaskBoundaryFeature" + } + } + } + }, + "Project": { + "type": "object", + "required": [ + "id", + "workspaceId", + "name", + "status", + "reviewRequired", + "lockTimeoutHours", + "createdBy", + "createdAt", + "updatedAt", + "hasAoi", + "taskCount" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "workspaceId": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string", + "maxLength": 255 + }, + "instructions": { + "type": "string", + "nullable": true + }, + "status": { + "$ref": "#/components/schemas/ProjectStatus" + }, + "reviewRequired": { + "type": "boolean", + "default": true + }, + "lockTimeoutHours": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "default": 8 + }, + "taskBoundaryType": { + "$ref": "#/components/schemas/TaskBoundaryType" + }, + "hasAoi": { + "type": "boolean" + }, + "taskCount": { + "type": "integer", + "minimum": 0 + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "createdByName": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ProjectListItem": { + "type": "object", + "required": [ + "id", + "name", + "status", + "taskCount", + "percentCompleted", + "createdAt" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ProjectStatus" + }, + "taskCount": { + "type": "integer", + "minimum": 0 + }, + "percentCompleted": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "createdByName": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ProjectListResponse": { + "type": "object", + "required": [ + "results", + "pagination" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectListItem" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "ProjectCreateRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Unique within the workspace (case-insensitive) among non-deleted projects." + }, + "instructions": { + "type": "string", + "maxLength": 10000, + "nullable": true + }, + "reviewRequired": { + "type": "boolean", + "default": true, + "description": "If false, tasks transition straight to `completed` on mapper submit. Immutable after activation." + }, + "lockTimeoutHours": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "default": 8 + }, + "aoi": { + "allOf": [ + { + "$ref": "#/components/schemas/AoiUploadRequest" + } + ], + "nullable": true, + "description": "Optional inline AOI (same validation as `POST .../aoi`)." + }, + "roleAssignments": { + "type": "array", + "description": "Optional list of project-level role assignments to seed at\ncreation. Each entry is upserted into `tasking_project_roles`\nin the same transaction as the project insert.\n\nThe creator is auto-added as `lead` on `tasking_project_roles`\nand does not need to appear here.\n\nEach `userId` must be a member of the workspace's TDEI\nproject group; entries failing this check are rejected with\n422 and no rows are written.\n", + "items": { + "$ref": "#/components/schemas/ProjectRoleAssignment" + } + } + } + }, + "ProjectRoleAssignment": { + "type": "object", + "required": [ + "userId", + "role" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "ProjectUpdateRequest": { + "type": "object", + "description": "All fields optional. Server enforces per-status mutability.", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "instructions": { + "type": "string", + "maxLength": 10000, + "nullable": true + }, + "lockTimeoutHours": { + "type": "integer", + "minimum": 1, + "maximum": 720 + }, + "reviewRequired": { + "type": "boolean" + } + } + }, + "TaskLockSummary": { + "type": "object", + "required": [ + "userId", + "lockedAt", + "expiresAt" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "userName": { + "type": "string", + "nullable": true + }, + "lockedAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "LastMapper": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "userName": { + "type": "string", + "nullable": true + } + } + }, + "Task": { + "type": "object", + "required": [ + "id", + "taskNumber", + "status", + "geometry", + "areaSqkm", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "taskNumber": { + "type": "integer", + "minimum": 1 + }, + "status": { + "$ref": "#/components/schemas/TaskStatus" + }, + "geometry": { + "$ref": "#/components/schemas/GeoJsonPolygon" + }, + "areaSqkm": { + "type": "number" + }, + "lock": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/TaskLockSummary" + } + ] + }, + "lastMapper": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/LastMapper" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "TaskListResponse": { + "type": "object", + "required": [ + "tasks", + "pagination" + ], + "properties": { + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Task" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "ValidateWarning": { + "type": "object", + "required": [ + "taskIndex", + "issue" + ], + "properties": { + "taskIndex": { + "type": "integer", + "description": "0-based index into the submitted `features` array." + }, + "issue": { + "type": "string", + "enum": [ + "polygon_exceeds_grid_size" + ] + }, + "areaSqkm": { + "type": "number" + } + } + }, + "ValidatePreviewResponse": { + "type": "object", + "required": [ + "valid", + "warnings", + "source", + "featureCollection" + ], + "properties": { + "valid": { + "type": "boolean", + "description": "True when no hard failures. Warnings do not affect this." + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidateWarning" + } + }, + "source": { + "$ref": "#/components/schemas/GridSource", + "description": "Always `import` for this response. Pass through unchanged to `/save`." + }, + "featureCollection": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + } + }, + "SaveTasksRequest": { + "type": "object", + "required": [ + "source", + "featureCollection" + ], + "properties": { + "source": { + "$ref": "#/components/schemas/GridSource", + "description": "Sets `tasking_projects.task_boundary_type` for the project." + }, + "featureCollection": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + } + }, + "SaveTasksResponse": { + "type": "object", + "required": [ + "projectId", + "taskBoundaryType", + "taskCount", + "tasks" + ], + "properties": { + "projectId": { + "type": "integer", + "format": "int64" + }, + "taskBoundaryType": { + "$ref": "#/components/schemas/GridSource" + }, + "taskCount": { + "type": "integer", + "minimum": 1, + "description": "Number of tasks created (== `featureCollection.features.length`)." + }, + "tasks": { + "type": "array", + "description": "All created tasks in the same order as the request's `features[]`. `taskNumber` is sequential starting at 1.", + "items": { + "$ref": "#/components/schemas/Task" + } + }, + "idempotencyKey": { + "type": "string", + "nullable": true + }, + "replayed": { + "type": "boolean", + "default": false, + "description": "True on idempotent replay (HTTP 200 case)." + } + } + }, + "Feedback": { + "type": "object", + "description": "Feedback payload \u2014 persisted to `tasking_feedback`. Used by\n`POST /submit` to attach a validator's remap-rejection note,\nand reusable by any future endpoint that records free-form\ntask feedback.\n", + "required": [ + "notes" + ], + "properties": { + "reasonCategory": { + "allOf": [ + { + "$ref": "#/components/schemas/FeedbackReason" + } + ], + "nullable": true, + "description": "Required when the feedback drives `to_review \u2192 to_remap`; optional otherwise." + }, + "notes": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + } + } + }, + "SubmitRequest": { + "type": "object", + "required": [ + "osmChangesetId", + "done" + ], + "properties": { + "osmChangesetId": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "done": { + "type": "boolean", + "description": "`true` \u2014 auto-unlocks and transitions state per the table.\n`false` \u2014 records the changeset, state unchanged, lock\n`expires_at` slides forward to `submitted_at +\nlock_timeout_hours`.\n" + }, + "feedback": { + "allOf": [ + { + "$ref": "#/components/schemas/Feedback" + } + ], + "description": "Meaningful only in validator context on `to_review`. When\nprovided here, `feedback.reasonCategory` is required (drives\nthe `to_remap` transition) and the row is persisted to\n`tasking_feedback`.\n" + } + } + }, + "ExistingLockSummary": { + "type": "object", + "required": [ + "taskNumber", + "taskStatus", + "lockedAt", + "expiresAt" + ], + "properties": { + "taskNumber": { + "type": "integer", + "minimum": 1 + }, + "taskStatus": { + "$ref": "#/components/schemas/TaskStatus" + }, + "lockedAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "LockConflictError": { + "allOf": [ + { + "$ref": "#/components/schemas/Error" + }, + { + "type": "object", + "properties": { + "existingLock": { + "$ref": "#/components/schemas/ExistingLockSummary" + } + } + } + ] + }, + "UserSummary": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "displayName": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "nullable": true + } + } + }, + "AssignableRoleType": { + "type": "string", + "enum": [ + "lead", + "validator" + ], + "description": "Roles assignable via `PUT /workspaces/{wid}/users/{uid}/role`.\nCONTRIBUTOR is implicit (project-group membership grants it\nautomatically) and cannot be set by this endpoint.\n" + }, + "SetRoleRequest": { + "type": "object", + "required": [ + "role" + ], + "properties": { + "role": { + "$ref": "#/components/schemas/AssignableRoleType" + } + } + }, + "WorkspaceUserRoleItem": { + "type": "object", + "description": "Privileged-member DTO returned by `GET /workspaces/{wid}/users`.\nMirrors the shipped `WorkspaceUserRoleItem` from\n`api/src/users/schemas.py` on the `roles` branch.\n", + "required": [ + "id", + "auth_uid", + "email", + "display_name", + "role" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "Internal OSM-DB `users.id`." + }, + "auth_uid": { + "type": "string", + "description": "TDEI auth user uuid as string (`users.auth_uid`)." + }, + "email": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "ProjectRoleItem": { + "type": "object", + "description": "One row of `tasking_project_roles`, joined with `users` for the\ndisplay name. Returned by every `/projects/{pid}/roles[/{uid}]`\nendpoint.\n", + "required": [ + "user_id", + "role", + "updated_at" + ], + "properties": { + "user_id": { + "type": "string", + "format": "uuid", + "description": "Maps to `users.auth_uid`." + }, + "user_name": { + "type": "string", + "nullable": true, + "description": "Mirror of `users.display_name`, joined at read time." + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "ProjectRoleListResponse": { + "type": "object", + "required": [ + "results" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "ProjectRoleAddRequest": { + "type": "object", + "description": "Body for `POST /projects/{pid}/roles`.", + "required": [ + "user_id", + "role" + ], + "additionalProperties": false, + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "ProjectRoleUpdateRequest": { + "type": "object", + "description": "Body for `PATCH /projects/{pid}/roles/{user_id}`.", + "required": [ + "role" + ], + "additionalProperties": false, + "properties": { + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "SelfProjectRoleItem": { + "type": "object", + "required": [ + "project_id", + "project_name", + "project_status", + "role" + ], + "properties": { + "project_id": { + "type": "integer", + "format": "int64" + }, + "project_name": { + "type": "string" + }, + "project_status": { + "$ref": "#/components/schemas/ProjectStatus" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "SelfProjectRolesResponse": { + "type": "object", + "required": [ + "workspace_role", + "projects" + ], + "properties": { + "workspace_role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelfProjectRoleItem" + } + } + } + }, + "ActorRef": { + "type": "object", + "required": [ + "user_id" + ], + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "display_name": { + "type": "string", + "nullable": true + } + } + }, + "AuditEvent": { + "type": "object", + "required": [ + "id", + "event_type", + "project_id", + "actor", + "occurred_at", + "details" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "event_type": { + "$ref": "#/components/schemas/AuditEventType" + }, + "project_id": { + "type": "integer", + "format": "int64" + }, + "task_id": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "task_number": { + "type": "integer", + "nullable": true, + "description": "Convenience copy from `details` (so list renderers don't peek inside JSONB)." + }, + "actor": { + "$ref": "#/components/schemas/ActorRef" + }, + "occurred_at": { + "type": "string", + "format": "date-time" + }, + "details": { + "type": "object", + "additionalProperties": true + }, + "project_deleted": { + "type": "boolean", + "default": false + } + } + }, + "AuditEventListResponse": { + "type": "object", + "required": [ + "results", + "pagination" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuditEvent" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "TaskStatusCounts": { + "type": "object", + "required": [ + "to_map", + "to_review", + "to_remap", + "completed" + ], + "properties": { + "to_map": { + "type": "integer", + "minimum": 0 + }, + "to_review": { + "type": "integer", + "minimum": 0 + }, + "to_remap": { + "type": "integer", + "minimum": 0 + }, + "completed": { + "type": "integer", + "minimum": 0 + } + } + }, + "ProjectStats": { + "type": "object", + "required": [ + "projectId", + "totalTasks", + "tasksByStatus", + "percentMapped", + "percentCompleted", + "uniqueMappers", + "uniqueValidators" + ], + "properties": { + "projectId": { + "type": "integer", + "format": "int64" + }, + "totalTasks": { + "type": "integer", + "minimum": 0 + }, + "tasksByStatus": { + "$ref": "#/components/schemas/TaskStatusCounts" + }, + "percentMapped": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "percentCompleted": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "avgTaskDurationMinutes": { + "type": "number", + "nullable": true + }, + "uniqueMappers": { + "type": "integer", + "minimum": 0 + }, + "uniqueValidators": { + "type": "integer", + "minimum": 0 + } + } + }, + "UserStatsTotals": { + "type": "object", + "required": [ + "tasksMapped", + "tasksValidated", + "totalMappingMinutes", + "totalValidationMinutes", + "totalChangesets", + "totalAreaSqkm" + ], + "properties": { + "tasksMapped": { + "type": "integer", + "minimum": 0 + }, + "tasksValidated": { + "type": "integer", + "minimum": 0 + }, + "totalMappingMinutes": { + "type": "integer", + "minimum": 0 + }, + "totalValidationMinutes": { + "type": "integer", + "minimum": 0 + }, + "totalChangesets": { + "type": "integer", + "minimum": 0 + }, + "totalAreaSqkm": { + "type": "number", + "minimum": 0 + } + } + }, + "UserStatsByProject": { + "type": "object", + "required": [ + "projectId", + "projectName", + "tasksMapped", + "tasksValidated" + ], + "properties": { + "projectId": { + "type": "integer", + "format": "int64" + }, + "projectName": { + "type": "string" + }, + "tasksMapped": { + "type": "integer", + "minimum": 0 + }, + "tasksValidated": { + "type": "integer", + "minimum": 0 + }, + "totalChangesets": { + "type": "integer", + "minimum": 0 + }, + "totalAreaSqkm": { + "type": "number", + "minimum": 0 + } + } + }, + "UserStats": { + "type": "object", + "required": [ + "workspaceId", + "userId", + "totals", + "byProject" + ], + "properties": { + "workspaceId": { + "type": "integer", + "format": "int64" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "totals": { + "$ref": "#/components/schemas/UserStatsTotals" + }, + "byProject": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserStatsByProject" + } + } + } + }, + "Pagination": { + "type": "object", + "required": [ + "page", + "pageSize", + "total" + ], + "properties": { + "page": { + "type": "integer", + "minimum": 1 + }, + "pageSize": { + "type": "integer", + "minimum": 1 + }, + "total": { + "type": "integer", + "minimum": 0 + } + } + }, + "Error": { + "type": "object", + "required": [ + "detail" + ], + "properties": { + "detail": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + ], + "description": "FastAPI default error shape \u2014 string for raised `HTTPException`, structured list for pydantic validation errors." + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Malformed JSON or schema-level failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Unauthorized": { + "description": "Missing or invalid bearer token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "ForbiddenLeadRequired": { + "description": "Caller is a workspace contributor but does not hold LEAD.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "WorkspaceNotFound": { + "description": "Workspace does not exist, or is outside the caller's tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "ProjectOrWorkspaceNotFound": { + "description": "Workspace or project does not exist, or is outside the caller's tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "TaskOrProjectOrWorkspaceNotFound": { + "description": "Workspace, project, or task does not exist, or is outside the caller's tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "UnprocessableEntity": { + "description": "Well-formed request that violates a business rule.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/docs/tasking-mvp/tasking-mvp.openapi.yaml b/docs/tasking-mvp/tasking-mvp.openapi.yaml new file mode 100644 index 0000000..8076441 --- /dev/null +++ b/docs/tasking-mvp/tasking-mvp.openapi.yaml @@ -0,0 +1,2029 @@ +openapi: 3.0.3 +info: + title: Workspaces Tasking Manager API + version: 1.0.0 + description: | + Tasking Manager API for the Workspaces Tasking Manager MVP. + +servers: + - url: /api/v1 + description: workspaces-backend FastAPI app — all routers are mounted under `/api/v1` (see `api/main.py`). + +tags: + - name: projects + description: Tasking project CRUD and lifecycle. + - name: aoi + description: Project area-of-interest upload, retrieval, deletion. + - name: tasks + description: Task validation, persistence and read access. + - name: locks + description: Lock lifecycle on a task. + - name: submit + description: Done? submit flow — changeset + state transition. + - name: roles + description: Workspace and project role management. + - name: audit + description: Audit trail. + - name: stats + description: Project and user statistics. + +security: + - bearerAuth: [] + +paths: + # ========================================================================= + # Projects + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + get: + tags: [projects] + summary: List tasking projects in a workspace. + description: Paginated list of non-deleted projects. Any workspace contributor. + operationId: listProjects + parameters: + - in: query + name: status + schema: { $ref: "#/components/schemas/ProjectStatus" } + - in: query + name: textSearch + schema: { type: string, maxLength: 255 } + description: Case-insensitive substring match on `name`. + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: pageSize + schema: { type: integer, minimum: 1, maximum: 200, default: 20 } + - in: query + name: orderBy + schema: + type: string + enum: [createdAt, updatedAt, name] + default: createdAt + - in: query + name: orderByType + schema: { type: string, enum: [ASC, DESC], default: DESC } + responses: + "200": + description: Paginated list. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + post: + tags: [projects] + summary: Create a tasking project (optionally with AOI and role assignments). + description: | + LEAD only. Creates a project in `draft` status. + + Optional inline AOI may be provided (same validation as + `POST .../aoi`). + + Optional `roleAssignments` may be provided to seed the + project-level role overrides table (`tasking_project_roles`) in + the same transaction. The creator is auto-added with + `role = lead`; other users may be added with any of `lead`, + `validator`, `contributor`. + + Activation later requires at least one user assigned with + `contributor` or `validator` so the project has at least one + worker before it goes live — see `POST .../activate`. + operationId: createProject + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectCreateRequest" } + responses: + "201": + description: Created. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + "409": + description: A non-deleted project with this name already exists. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [projects] + summary: Get a tasking project. + operationId: getProject + responses: + "200": + description: Project detail. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + patch: + tags: [projects] + summary: Update editable settings. + description: | + LEAD only. Per-status mutability: + + | Field | draft | open | done | + |--------------------|-------|------|------| + | name | yes | yes | no | + | instructions | yes | yes | no | + | lockTimeoutHours | yes | yes | no | + | reviewRequired | yes | NO | no | + + AOI is updated through the dedicated AOI endpoints, not PATCH. + Immutable-field writes return 422. + operationId: updateProject + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectUpdateRequest" } + responses: + "200": + description: Updated. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + delete: + tags: [projects] + summary: Soft-delete a tasking project. + description: | + LEAD only. Sets `deletedAt`, hard-deletes child tasks, retains + audit rows (flagged with `project_deleted=true`). Returns 409 + if any task currently has an active lock — force-release first. + operationId: deleteProject + responses: + "204": + description: Soft-deleted. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: Project has active task locks. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/activate: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [projects] + summary: Activate a draft project (`draft` → `open`). + description: | + LEAD only. Pre-conditions (all must hold; 422 with a clear + `detail` otherwise): + - Project has a `name`. + - Project has an AOI set. + - Project has at least one saved task. + - At least one user is allocated to the project with role + `contributor` or `validator` in `tasking_project_roles` + (the creator's auto-`lead` row does not satisfy this — a + worker must be assigned). + operationId: activateProject + responses: + "200": + description: Activated. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/close: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [projects] + summary: Close an open project (`open` → `done`). + description: | + LEAD only. Requires every task to be `completed` and no active + locks. + operationId: closeProject + responses: + "200": + description: Closed. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: One or more tasks are still locked or not completed. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/reset: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [projects] + summary: Reset all tasks in a project back to `to_map`. + description: | + LEAD only. Restarts work on the whole project so contributors + can re-map and re-validate. + + Effect (single transaction): + - Releases every active lock with `release_reason = 'reset'`, + emitting one `task_unlocked` audit event per released lock. + - Sets every task's `status` to `to_map`, clears + `last_mapper_id`, emits a `task_state_changed` audit event + for every task whose status actually changed. + - If the project was `done`, transitions it back to `open`. + - Emits one `project_reset` audit event for the project. + + Tasks themselves (geometry, history of changesets and remap + feedback) are NOT deleted — only their lifecycle state is + rewound. Stats and audit history remain intact. + operationId: resetProject + responses: + "200": + description: Reset complete. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": + description: Project is in `draft` (nothing to reset). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + # ========================================================================= + # AOI + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/aoi: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [aoi] + summary: Get the AOI of a project. + description: | + Returns the AOI as a GeoJSON Feature (Polygon, EPSG:4326). + `404` if no AOI is set. + operationId: getProjectAoi + responses: + "200": + description: AOI. + content: + application/geo+json: + schema: { $ref: "#/components/schemas/AoiFeature" } + application/json: + schema: { $ref: "#/components/schemas/AoiFeature" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + post: + tags: [aoi] + summary: Upload or replace the AOI. + description: | + LEAD only. Accepts a GeoJSON `Feature`, `FeatureCollection` + (single feature), or bare geometry. Geometry may be either + `Polygon` or `MultiPolygon` (EPSG:4326). Single-Polygon inputs + are upcast to MultiPolygon on insert; the storage column is + `GEOMETRY(MultiPolygon, 4326)`. + + Project must be in `draft`. Replacing an AOI hard-deletes any + saved tasks. + operationId: uploadProjectAoi + requestBody: + required: true + content: + application/geo+json: + schema: { $ref: "#/components/schemas/AoiUploadRequest" } + application/json: + schema: { $ref: "#/components/schemas/AoiUploadRequest" } + responses: + "200": + description: AOI stored. + content: + application/geo+json: + schema: { $ref: "#/components/schemas/AoiFeature" } + application/json: + schema: { $ref: "#/components/schemas/AoiFeature" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + delete: + tags: [aoi] + summary: Delete the AOI. + description: | + LEAD only. Project must be in `draft`. Clears the AOI, hard- + deletes any saved tasks (releasing their locks in the same + transaction), clears `taskBoundaryType`. Returns 404 if no AOI + is set. + operationId: deleteProjectAoi + responses: + "204": + description: AOI deleted. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + description: Project not found, or project has no AOI. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: Project is not in `draft`. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + # ========================================================================= + # Tasks (validate + save + read) + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/validate: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [tasks] + summary: Validate a GeoJSON FeatureCollection of candidate task boundaries. + description: | + LEAD only. Does NOT persist anything — the client must POST each + validated Feature to `/tasks/save` to commit. + + Project preconditions: status `draft`, AOI uploaded. + + Hard validation (422 on failure): + - Top-level type must be `FeatureCollection`. + - Every feature geometry must be `Polygon` (no MultiPolygon). + - Coordinates valid lon/lat (EPSG:4326). + - Every polygon lies within the project AOI (centroid-inside + is not sufficient). + - At least one feature. + + Non-blocking warning: + - `polygon_exceeds_grid_size` — area exceeds + `TM_TASKING_GRID_SIZE_METERS`² (km²). + operationId: validateTasks + requestBody: + required: true + content: + application/geo+json: + schema: + { $ref: "#/components/schemas/TaskBoundariesFeatureCollection" } + application/json: + schema: + { $ref: "#/components/schemas/TaskBoundariesFeatureCollection" } + responses: + "200": + description: Validation result. + content: + application/json: + schema: { $ref: "#/components/schemas/ValidatePreviewResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/save: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/IdempotencyKeyHeader" + post: + tags: [tasks] + summary: Persist a validated FeatureCollection as the project's tasks (bulk). + description: | + LEAD only. Commits the **entire** FeatureCollection as + `tasking_tasks` rows in a single transaction. + + Effect (single transaction; all-or-nothing): + 1. Re-validates every feature against the project AOI (same + rules as `POST /tasks/validate`). Any hard failure aborts + the transaction — no rows are written. + 2. Allocates sequential `taskNumber`s starting at 1, in the + order features appear in the request. + 3. Bulk-inserts the `tasking_tasks` rows. + 4. Sets `tasking_projects.task_boundary_type` to `source`. + 5. Emits one `task_created` audit event per task. + + Project preconditions: + - Project must be in `draft`. + - Project must have an AOI set. + - Project must currently have **no tasks** (a second save is + rejected with 409 `"Tasks already saved"`). To replace + tasks, re-upload the AOI in `draft` — that wipes existing + tasks and re-enables save. + + Idempotency: `Idempotency-Key` is honoured at the batch level. + Same key + same body within the configured TTL replays the + original response (HTTP 200, `replayed: true`). Same key with + different body returns 409 + `"Idempotency key reused with a different request"`. See the + top-level idempotency contract. + operationId: saveTasks + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SaveTasksRequest" } + responses: + "201": + description: Tasks created. + content: + application/json: + schema: { $ref: "#/components/schemas/SaveTasksResponse" } + "200": + description: | + Idempotent replay (same key + same body); `replayed: true`. + content: + application/json: + schema: { $ref: "#/components/schemas/SaveTasksResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: | + One of: + - "Tasks already saved" — project already has tasks; re-upload AOI to start over. + - "Idempotency key reused with a different request". + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [tasks] + summary: List tasks (geometry always included; serves list + map views). + description: | + Paginated. Any workspace contributor. + operationId: listTasks + parameters: + - in: query + name: status + schema: { $ref: "#/components/schemas/TaskStatus" } + - in: query + name: lockedByUserId + schema: { type: string, format: uuid } + - in: query + name: lastMapperId + schema: { type: string, format: uuid } + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: pageSize + schema: { type: integer, minimum: 1, maximum: 1000, default: 200 } + responses: + "200": + description: Tasks. + content: + application/json: + schema: { $ref: "#/components/schemas/TaskListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + get: + tags: [tasks] + summary: Get task detail (geometry, status, lock, last mapper). + operationId: getTask + responses: + "200": + description: Task. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: Workspace, project, or task not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + # ========================================================================= + # Locking + Submit + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/lock: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [locks] + summary: Acquire a lock on a task. + description: | + Eligibility: + + | Task status | Allowed roles | + |-------------|---------------------------------------------------| + | `to_map` | CONTRIBUTOR, LEAD | + | `to_remap` | CONTRIBUTOR, LEAD | + | `to_review` | VALIDATOR, LEAD — and NOT this task's last_mapper | + | `completed` | (none) | + + Other rules: + - No other active lock on this task. + - Caller holds no other active lock in this project (one-lock- + per-project rule). 409 response includes `existingLock`. + - Project status is `open`. + + Side effects: + - INSERT `tasking_locks` with + `expires_at = NOW() + project.lock_timeout_hours`. + - Emit `task_locked` audit event. + operationId: lockTask + responses: + "200": + description: Lock acquired. Body is the updated task. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Role does not permit locking in this status, or self-validation guard hit. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "409": + description: | + One of: + - "Task is already locked". + - "User already holds a lock in this project" — body includes `existingLock`. + - "Project is not open". + content: + application/json: + schema: { $ref: "#/components/schemas/LockConflictError" } + delete: + tags: [locks] + summary: Release a lock on a task. + description: | + Default: caller must be the active lock holder + (`release_reason = manual`). + + With `?force=true`: caller must be LEAD + (`release_reason = lead_release`). + + Task `status` is unchanged in both modes. Force-release does + not relock — LEAD must POST `/lock` separately to take over. + operationId: unlockTask + parameters: + - in: query + name: force + schema: { type: boolean, default: false } + responses: + "204": + description: Released. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Not the lock holder (default mode) or not LEAD (force mode). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "409": + description: Task has no active lock. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/extend: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [locks] + summary: Extend the expiry of your own lock. + description: | + Caller must hold the active lock. Adds the project's + `lock_timeout_hours` to the current `expires_at` (slides from + the existing expiry, not from `NOW()`). Emits + `task_lock_extended`. + operationId: extendTaskLock + responses: + "200": + description: Lock extended. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller does not hold the active lock. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "409": + description: Task has no active lock to extend. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/reset: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [locks] + summary: Reset a single task back to `to_map`. + description: | + LEAD only. Restarts work on a task — useful when a contributor + or validator pushed a task into a wrong terminal state and + someone needs to redo it. + + Effect (single transaction): + - If an active lock exists, it is released with + `release_reason = 'reset'` and a `task_unlocked` audit + event is emitted. + - Task `status` is set to `to_map`, `last_mapper_id` is + cleared, and a `task_state_changed` audit event is emitted + (only when the status actually changed). + - A `task_reset` audit event is emitted. + + Existing changeset rows and remap-feedback rows are NOT + deleted — only the live task state is rewound. + + Pre-conditions: project status is `open` or `done`. + operationId: resetTask + responses: + "200": + description: Task reset. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "422": + description: Project is in `draft` (no tasks to reset yet). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/submit: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [submit] + summary: Submit an OSM changeset and optionally transition state. + description: | + The "Done?" flow. Caller must hold the active lock. + + Effective actor role is inferred from current task status: + - `to_map` / `to_remap` → mapper context + - `to_review` → validator context + (LEAD may act in either context.) + + State transition (when `done:true`): + + | Current | Effective role | Feedback? | New status | + |-------------|--------------------|-----------|---------------------------------------------------------| + | `to_map` | contributor / lead | n/a | `to_review` if `review_required` else `completed` | + | `to_remap` | contributor / lead | n/a | `to_review` | + | `to_review` | validator / lead | no | `completed` | + | `to_review` | validator / lead | yes | `to_remap` (and INSERT `tasking_feedback` with `reasonCategory` set) | + + With `done:false` the state is unchanged but the lock's + `expires_at` slides forward to `submitted_at + lock_timeout_hours` + (emits `task_lock_renewed`). + + `osmChangesetId` is supplied by the client. In practice it is + auto-populated by the editor (Rapid in the iframe) on save — + the UI does not ask the user to type it. The id may optionally + be verified by looking it up in the internal `osm-web` service + (same network); a verification miss returns `422` with detail + "Invalid OSM changeset id" and nothing is written. + + `last_mapper_id` is updated only when the effective role is + mapper. + + Feedback rules: + - `feedback` is meaningful only in validator context on + `to_review`. Otherwise its presence returns 422. + - For `to_review` → `to_remap`, `feedback.reasonCategory` is + required (422 if missing). + operationId: submitTask + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SubmitRequest" } + responses: + "200": + description: Submission accepted. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller does not hold the active lock. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + # ========================================================================= + # Roles + # ========================================================================= + + # NOTE: the workspace-level role surface below mirrors the shipped + # endpoints on the `roles` branch (api/src/users/routes.py). It uses + # `/workspaces/{workspace_id}/users/...`, not `/.../roles/...`. Only + # privileged role rows (LEAD, VALIDATOR) are stored; CONTRIBUTOR is + # implicit for any project-group member and cannot be assigned via PUT. + + /workspaces/{workspace_id}/users: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + get: + tags: [roles] + summary: List privileged workspace members (lead + validator role rows). + description: | + Returns the rows in `user_workspace_roles` for this workspace, + joined with their `users` record. Visible to any workspace + contributor (`isWorkspaceContributor`). + + Does NOT include implicit contributors — only users with an + explicit LEAD or VALIDATOR row. The UI uses this for the team + management table; broader user listings come from the TDEI + proxy (see `/assignable-users`). + operationId: listWorkspaceMembers + responses: + "200": + description: Privileged members. + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/WorkspaceUserRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller is not a member of the workspace's project group. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/users/{user_id}/role: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/UserIdPath" + put: + tags: [roles] + summary: Assign (upsert) a privileged role for a user on a workspace. + description: | + LEAD only. Idempotent upsert into `user_workspace_roles`. + + The body's `role` must be `lead` or `validator` — assigning + `contributor` is rejected with 422 ("cannot assign implicit + role 'contributor' directly"). Contributor is the default for + any project-group member; to remove an explicit role and fall + back to contributor, call `DELETE /users/{user_id}` below. + + The named user must have signed in to Workspaces at least once + (i.e. exist in `users.auth_uid`). If they have not, returns + 404 with detail "User {uuid} has not signed in to Workspaces + yet". + + Side effect: the target user's `UserInfo` cache entry is + evicted (`evict_user_from_cache`) so their next request + reflects the new role immediately. + operationId: assignWorkspaceMemberRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SetRoleRequest" } + responses: + "204": + description: Role assigned (or updated). + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller is not LEAD on this workspace. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: Workspace not found, or the named user has not signed in to Workspaces yet. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: '"cannot assign implicit role ''contributor'' directly".' + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/users/{user_id}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/UserIdPath" + delete: + tags: [roles] + summary: Remove a user's privileged role on a workspace. + description: | + LEAD only. Deletes the row from `user_workspace_roles`; the + user's role falls back to implicit `contributor` (or to + whatever TDEI grants — `poc` still maps to LEAD). + + Side effect: the target user's `UserInfo` cache entry is + evicted. + operationId: removeWorkspaceMemberRole + responses: + "204": + description: Role row removed. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller is not LEAD on this workspace. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: No role row exists for this user/workspace. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/roles: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [roles] + summary: List role assignments on a project. + description: | + Returns every row in `tasking_project_roles` for the project, + joined with `users.display_name` for human-readable labels. + Any workspace contributor; the workspace tenancy gate still + applies (404 for outsiders). + operationId: listProjectRoles + responses: + "200": + description: All role assignments. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + post: + tags: [roles] + summary: Add a role assignment to a project. + description: | + Workspace LEAD **or** project LEAD only. Inserts a row in + `tasking_project_roles`. Duplicate `(project_id, user_id)` pairs + return 409 — use PATCH to change a role. The `user_id` must + already exist in `users` (i.e. the user has signed in to + Workspaces at least once); otherwise 422 with a + `missing_user_ids` list. + operationId: addProjectRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleAddRequest" } + responses: + "201": + description: Role added. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: User already has a role on this project — use PATCH. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: "`user_id` does not exist in the `users` table." + content: + application/json: + schema: + type: object + properties: + detail: + type: object + properties: + message: { type: string } + missing_user_ids: + type: array + items: { type: string, format: uuid } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/roles/{user_id}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/UserIdPath" + get: + tags: [roles] + summary: Get a single user's role on a project. + description: | + Returns the single `tasking_project_roles` row for this user on + this project. 404 if no assignment exists. Any workspace + contributor; the tenancy gate still applies. + operationId: getProjectRole + responses: + "200": + description: Role assignment. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: User has no role on this project, or project not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + put: + tags: [roles] + summary: Upsert a user's role on a project. + description: | + Workspace LEAD **or** project LEAD only. Idempotent insert-or- + update on `tasking_project_roles`. Returns **201** when the row + is created, **200** when an existing row is updated. + + **Last-LEAD guard**: a PUT that demotes the only remaining LEAD + returns 422 — assign another LEAD first. + + For partial-update semantics, prefer PATCH. + operationId: putProjectRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleUpdateRequest" } + responses: + "200": + description: Existing assignment updated. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "201": + description: New assignment created. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": + description: | + One of: + - `user_id` does not exist in `users` (insert path). + - Would remove the last LEAD (demote path). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + patch: + tags: [roles] + summary: Change a user's role on a project. + description: | + Workspace LEAD **or** project LEAD only. Updates the row in + `tasking_project_roles`. Returns 404 if the user has no role + assignment yet (use POST to add one). + + **Last-LEAD guard**: demoting the only remaining LEAD on the + project returns 422 — assign another LEAD first. + operationId: updateProjectRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleUpdateRequest" } + responses: + "200": + description: Role updated. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + description: User has no role on this project, or project not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: Would remove the last LEAD on the project. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + delete: + tags: [roles] + summary: Remove a role assignment from a project. + description: | + Workspace LEAD **or** project LEAD only. Deletes the row from + `tasking_project_roles`. + + **Last-LEAD guard**: removing the only remaining LEAD on the + project returns 422 — assign another LEAD first. + operationId: removeProjectRole + responses: + "204": + description: Role removed. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + description: User has no role on this project, or project not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: Would remove the last LEAD on the project. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /me/workspaces/{workspace_id}/tasking/projects/roles: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + get: + tags: [roles] + summary: List the caller's role for every project in a workspace. + description: | + Single round-trip for the project-list page: returns one entry + per project with the caller's role on that project. + operationId: listSelfProjectRoles + responses: + "200": + description: Project roles for the caller. + content: + application/json: + schema: { $ref: "#/components/schemas/SelfProjectRolesResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + + # ========================================================================= + # Audit + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/audit: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [audit] + summary: List project-level audit events. + description: | + Paginated, newest first. Any workspace contributor. Soft-deleted + projects are visible only with `include_deleted=true`. + operationId: listProjectAudit + parameters: + - in: query + name: event_type + schema: { $ref: "#/components/schemas/AuditEventType" } + - in: query + name: task_number + schema: { type: integer, minimum: 1 } + - in: query + name: actor_user_id + schema: { type: string, format: uuid } + - in: query + name: occurred_from + schema: { type: string, format: date-time } + - in: query + name: occurred_to + schema: { type: string, format: date-time } + - in: query + name: include_deleted + schema: { type: boolean, default: false } + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: page_size + schema: { type: integer, minimum: 1, maximum: 200, default: 50 } + - in: query + name: order_by_type + schema: { type: string, enum: [ASC, DESC], default: DESC } + responses: + "200": + description: Events. + content: + application/json: + schema: { $ref: "#/components/schemas/AuditEventListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/audit: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + get: + tags: [audit] + summary: List task-level audit events. + description: Newest first. Any workspace contributor. + operationId: listTaskAudit + parameters: + - in: query + name: event_type + schema: { $ref: "#/components/schemas/AuditEventType" } + - in: query + name: actor_user_id + schema: { type: string, format: uuid } + - in: query + name: occurred_from + schema: { type: string, format: date-time } + - in: query + name: occurred_to + schema: { type: string, format: date-time } + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: page_size + schema: { type: integer, minimum: 1, maximum: 200, default: 25 } + - in: query + name: order_by_type + schema: { type: string, enum: [ASC, DESC], default: DESC } + responses: + "200": + description: Events for the task. + content: + application/json: + schema: { $ref: "#/components/schemas/AuditEventListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + + # ========================================================================= + # Stats + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/stats: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [stats] + summary: Project statistics summary. + description: | + Live-computed. Any workspace contributor. + + Field semantics: + - `tasksByStatus` always zero-fills all four states. + - `percentMapped` — share of tasks past `to_map` at least + once. Rounded to nearest integer. + - `percentCompleted` — share whose current status is + `completed`. + - `avgTaskDurationMinutes` — mean across completed tasks of + the sum of `(released_at - locked_at)` over that task's + locks. `null` when no task is completed yet. + - `uniqueMappers` — distinct submitters with at least one + changeset while task was in `to_map` or `to_remap`. + - `uniqueValidators` — distinct submitters with at least one + changeset while task was in `to_review`. + operationId: getProjectStats + responses: + "200": + description: Stats. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectStats" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + + /workspaces/{workspace_id}/tasking/users/{user_id}/stats: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/UserIdPath" + get: + tags: [stats] + summary: Cross-project stats for a user within a workspace. + description: | + `totals` sums across the workspace's non-deleted projects. + `byProject` lists only projects with non-zero contribution. + + Counting rules: + - `tasksMapped` — distinct tasks where the user submitted at + least one `done:true` changeset while the task was in + `to_map` or `to_remap`. + - `tasksValidated` — distinct tasks where the user submitted + at least one `done:true` changeset while in `to_review`. + - `totalMappingMinutes` / `totalValidationMinutes` — sum of + `(released_at - locked_at)` over the user's lock sessions + in mapping/validation context. + - `totalAreaSqkm` — sum of `tasking_tasks.area_sqkm` over + tasks the user mapped (each task counted once). + operationId: getUserStats + responses: + "200": + description: User stats. + content: + application/json: + schema: { $ref: "#/components/schemas/UserStats" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: Workspace not found, user has no activity, or outside tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + parameters: + WorkspaceIdPath: + name: workspace_id + in: path + required: true + schema: { type: integer, format: int64 } + ProjectIdPath: + name: project_id + in: path + required: true + schema: { type: integer, format: int64 } + TaskNumberPath: + name: task_number + in: path + required: true + description: Sequential, human-readable id scoped to the project. Starts at 1. + schema: { type: integer, minimum: 1 } + UserIdPath: + name: user_id + in: path + required: true + description: TDEI auth user uuid. + schema: { type: string, format: uuid } + IdempotencyKeyHeader: + name: Idempotency-Key + in: header + required: false + description: Client-generated unique value (UUID recommended). Scoped per project. + schema: + type: string + minLength: 8 + maxLength: 128 + + schemas: + # ---------- Enums ---------- + + ProjectStatus: + type: string + enum: [draft, open, done] + + TaskStatus: + type: string + enum: [to_map, to_review, to_remap, completed] + + TaskBoundaryType: + type: string + enum: [grid, import] + nullable: true + + GridSource: + type: string + enum: [grid, import] + + WorkspaceUserRoleType: + type: string + enum: [lead, validator, contributor] + + LockReleaseReason: + type: string + enum: [auto_unlock, manual, lead_release, stale_timeout, reset] + description: | + `auto_unlock` — released by a successful `/submit` with `done:true`. + `manual` — caller released their own lock via `DELETE /lock`. + `lead_release` — LEAD force-released via `DELETE /lock?force=true`. + `stale_timeout` — released by the background job (`expires_at < NOW()`). + `reset` — released by `POST /reset` on the task or its project. + + FeedbackReason: + type: string + enum: [incomplete_mapping, data_quality_issue, wrong_area, other] + description: | + Reason categories persisted to `tasking_feedback.reason_category`. + Required when feedback is used to drive a `to_review → to_remap` + transition (see `POST /submit`); optional for generic free-form + notes on a task. + + AuditEventType: + type: string + enum: + - project_created + - project_activated + - project_closed + - project_edited + - project_deleted + - aoi_uploaded + - aoi_deleted + - task_created + - task_state_changed + - task_locked + - task_lock_extended + - task_lock_renewed + - task_unlocked + - task_reset + - project_reset + - changeset_submitted + - feedback_submitted + + # ---------- GeoJSON ---------- + + GeoJsonPolygon: + type: object + required: [type, coordinates] + properties: + type: { type: string, enum: [Polygon] } + coordinates: + type: array + minItems: 1 + items: + type: array + minItems: 4 + items: + type: array + minItems: 2 + maxItems: 3 + items: { type: number } + + GeoJsonMultiPolygon: + type: object + required: [type, coordinates] + properties: + type: { type: string, enum: [MultiPolygon] } + coordinates: + type: array + minItems: 1 + items: + type: array + minItems: 1 + items: + type: array + minItems: 4 + items: + type: array + minItems: 2 + maxItems: 3 + items: { type: number } + + AoiGeometry: + description: AOI accepts either a single Polygon or a MultiPolygon (EPSG:4326). Servers store both as MultiPolygon (single-Polygon inputs are upcast on insert). + oneOf: + - $ref: "#/components/schemas/GeoJsonPolygon" + - $ref: "#/components/schemas/GeoJsonMultiPolygon" + + AoiFeature: + type: object + required: [type, geometry, properties] + properties: + type: { type: string, enum: [Feature] } + geometry: { $ref: "#/components/schemas/AoiGeometry" } + properties: + type: object + additionalProperties: true + + AoiFeatureCollection: + type: object + required: [type, features] + properties: + type: { type: string, enum: [FeatureCollection] } + features: + type: array + minItems: 1 + maxItems: 1 + items: { $ref: "#/components/schemas/AoiFeature" } + + AoiUploadRequest: + oneOf: + - $ref: "#/components/schemas/AoiFeature" + - $ref: "#/components/schemas/AoiFeatureCollection" + - $ref: "#/components/schemas/AoiGeometry" + + TaskBoundaryFeature: + type: object + required: [type, geometry] + properties: + type: { type: string, enum: [Feature] } + geometry: { $ref: "#/components/schemas/GeoJsonPolygon" } + properties: + type: object + additionalProperties: true + + TaskBoundariesFeatureCollection: + type: object + required: [type, features] + properties: + type: { type: string, enum: [FeatureCollection] } + features: + type: array + minItems: 1 + items: { $ref: "#/components/schemas/TaskBoundaryFeature" } + + # ---------- Projects ---------- + + Project: + type: object + required: + - id + - workspaceId + - name + - status + - reviewRequired + - lockTimeoutHours + - createdBy + - createdAt + - updatedAt + - hasAoi + - taskCount + properties: + id: { type: integer, format: int64 } + workspaceId: { type: integer, format: int64 } + name: { type: string, maxLength: 255 } + instructions: { type: string, nullable: true } + status: { $ref: "#/components/schemas/ProjectStatus" } + reviewRequired: { type: boolean, default: true } + lockTimeoutHours: + { type: integer, minimum: 1, maximum: 720, default: 8 } + taskBoundaryType: { $ref: "#/components/schemas/TaskBoundaryType" } + hasAoi: { type: boolean } + taskCount: { type: integer, minimum: 0 } + createdBy: { type: string, format: uuid } + createdByName: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + ProjectListItem: + type: object + required: [id, name, status, taskCount, percentCompleted, createdAt] + properties: + id: { type: integer, format: int64 } + name: { type: string } + status: { $ref: "#/components/schemas/ProjectStatus" } + taskCount: { type: integer, minimum: 0 } + percentCompleted: { type: integer, minimum: 0, maximum: 100 } + createdBy: { type: string, format: uuid } + createdByName: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + ProjectListResponse: + type: object + required: [results, pagination] + properties: + results: + type: array + items: { $ref: "#/components/schemas/ProjectListItem" } + pagination: { $ref: "#/components/schemas/Pagination" } + + ProjectCreateRequest: + type: object + required: [name] + properties: + name: + type: string + minLength: 1 + maxLength: 255 + description: Unique within the workspace (case-insensitive) among non-deleted projects. + instructions: + type: string + maxLength: 10000 + nullable: true + reviewRequired: + type: boolean + default: true + description: If false, tasks transition straight to `completed` on mapper submit. Immutable after activation. + lockTimeoutHours: + type: integer + minimum: 1 + maximum: 720 + default: 8 + aoi: + allOf: + - $ref: "#/components/schemas/AoiUploadRequest" + nullable: true + description: Optional inline AOI (same validation as `POST .../aoi`). + roleAssignments: + type: array + description: | + Optional list of project-level role assignments to seed at + creation. Each entry is upserted into `tasking_project_roles` + in the same transaction as the project insert. + + The creator is auto-added as `lead` on `tasking_project_roles` + and does not need to appear here. + + Each `userId` must be a member of the workspace's TDEI + project group; entries failing this check are rejected with + 422 and no rows are written. + items: { $ref: "#/components/schemas/ProjectRoleAssignment" } + + ProjectRoleAssignment: + type: object + required: [userId, role] + properties: + userId: { type: string, format: uuid } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + ProjectUpdateRequest: + type: object + description: All fields optional. Server enforces per-status mutability. + properties: + name: { type: string, minLength: 1, maxLength: 255 } + instructions: { type: string, maxLength: 10000, nullable: true } + lockTimeoutHours: { type: integer, minimum: 1, maximum: 720 } + reviewRequired: { type: boolean } + + # ---------- Tasks ---------- + + TaskLockSummary: + type: object + required: [userId, lockedAt, expiresAt] + properties: + userId: { type: string, format: uuid } + userName: { type: string, nullable: true } + lockedAt: { type: string, format: date-time } + expiresAt: { type: string, format: date-time } + + LastMapper: + type: object + required: [userId] + properties: + userId: { type: string, format: uuid } + userName: { type: string, nullable: true } + + Task: + type: object + required: + [id, taskNumber, status, geometry, areaSqkm, createdAt, updatedAt] + properties: + id: { type: integer, format: int64 } + taskNumber: { type: integer, minimum: 1 } + status: { $ref: "#/components/schemas/TaskStatus" } + geometry: { $ref: "#/components/schemas/GeoJsonPolygon" } + areaSqkm: { type: number } + lock: + nullable: true + allOf: + - $ref: "#/components/schemas/TaskLockSummary" + lastMapper: + nullable: true + allOf: + - $ref: "#/components/schemas/LastMapper" + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + TaskListResponse: + type: object + required: [tasks, pagination] + properties: + tasks: + type: array + items: { $ref: "#/components/schemas/Task" } + pagination: { $ref: "#/components/schemas/Pagination" } + + ValidateWarning: + type: object + required: [taskIndex, issue] + properties: + taskIndex: + type: integer + description: 0-based index into the submitted `features` array. + issue: + type: string + enum: [polygon_exceeds_grid_size] + areaSqkm: + type: number + + ValidatePreviewResponse: + type: object + required: [valid, warnings, source, featureCollection] + properties: + valid: + type: boolean + description: True when no hard failures. Warnings do not affect this. + warnings: + type: array + items: { $ref: "#/components/schemas/ValidateWarning" } + source: + $ref: "#/components/schemas/GridSource" + description: Always `import` for this response. Pass through unchanged to `/save`. + featureCollection: + $ref: "#/components/schemas/TaskBoundariesFeatureCollection" + + SaveTasksRequest: + type: object + required: [source, featureCollection] + properties: + source: + $ref: "#/components/schemas/GridSource" + description: Sets `tasking_projects.task_boundary_type` for the project. + featureCollection: + $ref: "#/components/schemas/TaskBoundariesFeatureCollection" + + SaveTasksResponse: + type: object + required: [projectId, taskBoundaryType, taskCount, tasks] + properties: + projectId: { type: integer, format: int64 } + taskBoundaryType: { $ref: "#/components/schemas/GridSource" } + taskCount: + type: integer + minimum: 1 + description: Number of tasks created (== `featureCollection.features.length`). + tasks: + type: array + description: All created tasks in the same order as the request's `features[]`. `taskNumber` is sequential starting at 1. + items: { $ref: "#/components/schemas/Task" } + idempotencyKey: + type: string + nullable: true + replayed: + type: boolean + default: false + description: True on idempotent replay (HTTP 200 case). + + # ---------- Submit / Lock ---------- + + Feedback: + type: object + description: | + Feedback payload — persisted to `tasking_feedback`. Used by + `POST /submit` to attach a validator's remap-rejection note, + and reusable by any future endpoint that records free-form + task feedback. + required: [notes] + properties: + reasonCategory: + allOf: + - $ref: "#/components/schemas/FeedbackReason" + nullable: true + description: Required when the feedback drives `to_review → to_remap`; optional otherwise. + notes: + type: string + minLength: 1 + maxLength: 4000 + + SubmitRequest: + type: object + required: [osmChangesetId, done] + properties: + osmChangesetId: + type: integer + format: int64 + minimum: 1 + done: + type: boolean + description: | + `true` — auto-unlocks and transitions state per the table. + `false` — records the changeset, state unchanged, lock + `expires_at` slides forward to `submitted_at + + lock_timeout_hours`. + feedback: + allOf: + - $ref: "#/components/schemas/Feedback" + description: | + Meaningful only in validator context on `to_review`. When + provided here, `feedback.reasonCategory` is required (drives + the `to_remap` transition) and the row is persisted to + `tasking_feedback`. + + ExistingLockSummary: + type: object + required: [taskNumber, taskStatus, lockedAt, expiresAt] + properties: + taskNumber: { type: integer, minimum: 1 } + taskStatus: { $ref: "#/components/schemas/TaskStatus" } + lockedAt: { type: string, format: date-time } + expiresAt: { type: string, format: date-time } + + LockConflictError: + allOf: + - $ref: "#/components/schemas/Error" + - type: object + properties: + existingLock: + $ref: "#/components/schemas/ExistingLockSummary" + + # ---------- Roles ---------- + + UserSummary: + type: object + required: [userId] + properties: + userId: { type: string, format: uuid } + displayName: { type: string, nullable: true } + email: { type: string, nullable: true } + + AssignableRoleType: + type: string + enum: [lead, validator] + description: | + Roles assignable via `PUT /workspaces/{wid}/users/{uid}/role`. + CONTRIBUTOR is implicit (project-group membership grants it + automatically) and cannot be set by this endpoint. + + SetRoleRequest: + type: object + required: [role] + properties: + role: { $ref: "#/components/schemas/AssignableRoleType" } + + WorkspaceUserRoleItem: + type: object + description: | + Privileged-member DTO returned by `GET /workspaces/{wid}/users`. + Mirrors the shipped `WorkspaceUserRoleItem` from + `api/src/users/schemas.py` on the `roles` branch. + required: [id, auth_uid, email, display_name, role] + properties: + id: + type: integer + format: int64 + description: Internal OSM-DB `users.id`. + auth_uid: + type: string + description: TDEI auth user uuid as string (`users.auth_uid`). + email: { type: string } + display_name: { type: string } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + ProjectRoleItem: + type: object + description: | + One row of `tasking_project_roles`, joined with `users` for the + display name. Returned by every `/projects/{pid}/roles[/{uid}]` + endpoint. + required: [user_id, role, updated_at] + properties: + user_id: + type: string + format: uuid + description: Maps to `users.auth_uid`. + user_name: + type: string + nullable: true + description: Mirror of `users.display_name`, joined at read time. + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + updated_at: { type: string, format: date-time } + + ProjectRoleListResponse: + type: object + required: [results] + properties: + results: + type: array + items: { $ref: "#/components/schemas/ProjectRoleItem" } + + ProjectRoleAddRequest: + type: object + description: Body for `POST /projects/{pid}/roles`. + required: [user_id, role] + additionalProperties: false + properties: + user_id: + type: string + format: uuid + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + ProjectRoleUpdateRequest: + type: object + description: Body for `PATCH /projects/{pid}/roles/{user_id}`. + required: [role] + additionalProperties: false + properties: + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + SelfProjectRoleItem: + type: object + required: [project_id, project_name, project_status, role] + properties: + project_id: { type: integer, format: int64 } + project_name: { type: string } + project_status: { $ref: "#/components/schemas/ProjectStatus" } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + SelfProjectRolesResponse: + type: object + required: [workspace_role, projects] + properties: + workspace_role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + projects: + type: array + items: { $ref: "#/components/schemas/SelfProjectRoleItem" } + + # ---------- Audit ---------- + + ActorRef: + type: object + required: [user_id] + properties: + user_id: { type: string, format: uuid } + display_name: { type: string, nullable: true } + + AuditEvent: + type: object + required: [id, event_type, project_id, actor, occurred_at, details] + properties: + id: { type: integer, format: int64 } + event_type: { $ref: "#/components/schemas/AuditEventType" } + project_id: { type: integer, format: int64 } + task_id: + type: integer + format: int64 + nullable: true + task_number: + type: integer + nullable: true + description: Convenience copy from `details` (so list renderers don't peek inside JSONB). + actor: { $ref: "#/components/schemas/ActorRef" } + occurred_at: { type: string, format: date-time } + details: + type: object + additionalProperties: true + project_deleted: + type: boolean + default: false + + AuditEventListResponse: + type: object + required: [results, pagination] + properties: + results: + type: array + items: { $ref: "#/components/schemas/AuditEvent" } + pagination: { $ref: "#/components/schemas/Pagination" } + + # ---------- Stats ---------- + + TaskStatusCounts: + type: object + required: [to_map, to_review, to_remap, completed] + properties: + to_map: { type: integer, minimum: 0 } + to_review: { type: integer, minimum: 0 } + to_remap: { type: integer, minimum: 0 } + completed: { type: integer, minimum: 0 } + + ProjectStats: + type: object + required: + - projectId + - totalTasks + - tasksByStatus + - percentMapped + - percentCompleted + - uniqueMappers + - uniqueValidators + properties: + projectId: { type: integer, format: int64 } + totalTasks: { type: integer, minimum: 0 } + tasksByStatus: { $ref: "#/components/schemas/TaskStatusCounts" } + percentMapped: { type: integer, minimum: 0, maximum: 100 } + percentCompleted: { type: integer, minimum: 0, maximum: 100 } + avgTaskDurationMinutes: + type: number + nullable: true + uniqueMappers: { type: integer, minimum: 0 } + uniqueValidators: { type: integer, minimum: 0 } + + UserStatsTotals: + type: object + required: + - tasksMapped + - tasksValidated + - totalMappingMinutes + - totalValidationMinutes + - totalChangesets + - totalAreaSqkm + properties: + tasksMapped: { type: integer, minimum: 0 } + tasksValidated: { type: integer, minimum: 0 } + totalMappingMinutes: { type: integer, minimum: 0 } + totalValidationMinutes: { type: integer, minimum: 0 } + totalChangesets: { type: integer, minimum: 0 } + totalAreaSqkm: { type: number, minimum: 0 } + + UserStatsByProject: + type: object + required: [projectId, projectName, tasksMapped, tasksValidated] + properties: + projectId: { type: integer, format: int64 } + projectName: { type: string } + tasksMapped: { type: integer, minimum: 0 } + tasksValidated: { type: integer, minimum: 0 } + totalChangesets: { type: integer, minimum: 0 } + totalAreaSqkm: { type: number, minimum: 0 } + + UserStats: + type: object + required: [workspaceId, userId, totals, byProject] + properties: + workspaceId: { type: integer, format: int64 } + userId: { type: string, format: uuid } + totals: { $ref: "#/components/schemas/UserStatsTotals" } + byProject: + type: array + items: { $ref: "#/components/schemas/UserStatsByProject" } + + # ---------- Shared ---------- + + Pagination: + type: object + required: [page, pageSize, total] + properties: + page: { type: integer, minimum: 1 } + pageSize: { type: integer, minimum: 1 } + total: { type: integer, minimum: 0 } + + Error: + type: object + required: [detail] + properties: + detail: + oneOf: + - type: string + - type: array + items: { type: object, additionalProperties: true } + description: FastAPI default error shape — string for raised `HTTPException`, structured list for pydantic validation errors. + + responses: + BadRequest: + description: Malformed JSON or schema-level failure. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Unauthorized: + description: Missing or invalid bearer token. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + ForbiddenLeadRequired: + description: Caller is a workspace contributor but does not hold LEAD. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + WorkspaceNotFound: + description: Workspace does not exist, or is outside the caller's tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + ProjectOrWorkspaceNotFound: + description: Workspace or project does not exist, or is outside the caller's tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + TaskOrProjectOrWorkspaceNotFound: + description: Workspace, project, or task does not exist, or is outside the caller's tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + UnprocessableEntity: + description: Well-formed request that violates a business rule. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } diff --git a/pyproject.toml b/pyproject.toml index b4e4b1f..4f507b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,8 @@ dependencies = [ "requests-cache>=1.2.1", "pyjwt", "sqlmodel>=0.0.8", - "cachetools" + "cachetools", + "shapely>=2.1.2", ] [tool.pytest.ini_options] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..309c430 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator +from typing import Callable +from uuid import UUID, uuid4 + +import pytest + +# Constants — referenced by both unit and integration suites. +SEED_WORKSPACE_ID = 1899 +SEED_PROJECT_GROUP_ID = UUID("00000000-0000-0000-0000-000000001899") + + +@pytest.fixture +def seeded_workspace_id() -> int: + """Workspace id used by route URLs. + + Unit tests pretend this exists via a FakeWorkspaceRepository. + Integration overrides this fixture (and seeds a real workspaces + row with the same id) in ``tests/integration/conftest.py``. + """ + return SEED_WORKSPACE_ID + + +# --------------------------------------------------------------------------- +# HTTP client — ASGI transport (no socket); shared by unit and +# integration suites. The `request` / `response` event hooks log every +# call through stdlib `logging` so pytest-html captures the full HTTP +# trace per test in the report. +# --------------------------------------------------------------------------- + + +import logging # noqa: E402 (kept near the fixture for locality) + +_http_log = logging.getLogger("tests.http") + + +# --------------------------------------------------------------------------- +# pytest-html: surface each test's docstring as a "Description" column +# so the report is self-explanatory. +# --------------------------------------------------------------------------- + + +def _test_description(item) -> str: + """Pull the first non-empty docstring line off a pytest item.""" + fn = getattr(item, "function", None) or getattr(item, "obj", None) + doc = (getattr(fn, "__doc__", None) or "").strip() + if not doc: + return "" + return doc.splitlines()[0].strip() + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + """Annotate the test report with the docstring for pytest-html to render.""" + outcome = yield + report = outcome.get_result() + report.description = _test_description(item) + + +# The two hooks below are owned by the `pytest-html` plugin. When the +# plugin isn't installed (e.g. `uv sync` doesn't include it as a base +# dependency), pluggy refuses to register them and aborts collection. +# Declare them conditionally so the suite runs either way. +try: + import pytest_html # noqa: F401 + + def pytest_html_results_table_header(cells): + """Inject a 'Description' column header next to the test name.""" + cells.insert(2, "Description") + + 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 '—'}") + +except ImportError: + pass + + +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() + ) + + +@pytest.fixture +async def client() -> AsyncIterator: + from httpx import ASGITransport, AsyncClient + + from api.main import app + + async def _on_request(request): + body_preview = "" + try: + if request.content: + raw = request.content.decode(errors="replace") + body_preview = f" body={raw[:500]}{'…' if len(raw) > 500 else ''}" + except Exception: + pass + _http_log.info( + "→ %s %s%s%s", + request.method, + request.url.path, + f"?{request.url.query.decode()}" if request.url.query else "", + body_preview, + ) + + async def _on_response(response): + try: + await response.aread() + except Exception: + pass + body_preview = "" + if response.content: + try: + raw = response.content.decode(errors="replace") + body_preview = f" body={raw[:500]}{'…' if len(raw) > 500 else ''}" + except Exception: + pass + _http_log.info( + "← %s %s %s (%dB)%s", + response.status_code, + response.request.method, + response.request.url.path, + len(response.content), + body_preview, + ) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + event_hooks={"request": [_on_request], "response": [_on_response]}, + ) as c: + yield c + + +# --------------------------------------------------------------------------- +# Fake users — bypass TDEI/Keycloak by overriding `validate_token`. +# --------------------------------------------------------------------------- + + +def _make_user( + *, + role: str | None, + workspace_id: int, + pg_id: UUID, + is_poc: bool = False, +): + """Construct a UserInfo with the minimum fields the gates inspect.""" + from api.core.security import TdeiProjectGroupRole, UserInfo, UserInfoPGMembership + from api.src.users.schemas import WorkspaceUserRoleType + + u = UserInfo() + u.credentials = "fake-token" + u.user_uuid = uuid4() + u.user_name = f"test-{role or 'outsider'}-{u.user_uuid.hex[:6]}" + + if role == "lead": + u.osmWorkspaceRoles = {workspace_id: [WorkspaceUserRoleType.LEAD]} + elif role == "validator": + u.osmWorkspaceRoles = {workspace_id: [WorkspaceUserRoleType.VALIDATOR]} + elif role == "contributor": + u.osmWorkspaceRoles = {workspace_id: [WorkspaceUserRoleType.CONTRIBUTOR]} + else: + u.osmWorkspaceRoles = {} + + pg_roles = [TdeiProjectGroupRole.MEMBER] + if is_poc: + pg_roles.append(TdeiProjectGroupRole.POINT_OF_CONTACT) + + # Outsiders belong to no project group at all -> 404 on tenancy gate. + if role is None and not is_poc: + u.projectGroups = [] + u.accessibleWorkspaceIds = {} + else: + u.projectGroups = [ + UserInfoPGMembership( + project_group_name="Test PG", + project_group_id=str(pg_id), + tdeiRoles=pg_roles, + ) + ] + u.accessibleWorkspaceIds = {str(pg_id): [workspace_id]} + + return u + + +@pytest.fixture +def override_user() -> Iterator[Callable]: + """Yields a setter that swaps `validate_token` for the duration of a test.""" + from api.core.security import validate_token + from api.main import app + + def _set(user) -> None: + app.dependency_overrides[validate_token] = lambda: user + + yield _set + app.dependency_overrides.pop(validate_token, None) + + +@pytest.fixture +def as_lead(override_user, seeded_workspace_id): + user = _make_user( + role="lead", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + override_user(user) + return user + + +@pytest.fixture +def as_contributor(override_user, seeded_workspace_id): + user = _make_user( + role="contributor", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + override_user(user) + return user + + +@pytest.fixture +def as_validator(override_user, seeded_workspace_id): + user = _make_user( + role="validator", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + override_user(user) + return user + + +@pytest.fixture +def as_outsider(override_user, seeded_workspace_id): + """User with no project-group association — tenancy gate should 404.""" + user = _make_user( + role=None, + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + override_user(user) + return user diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..be2be67 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,538 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from collections.abc import AsyncIterator, Iterator + +import pytest + +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. +# --------------------------------------------------------------------------- + + +def _docker_status() -> tuple[bool, str]: + """Returns (ok, reason). 'reason' is empty when ok, otherwise diagnostic text.""" + try: + r = subprocess.run( + ["docker", "info", "--format", "{{.ServerVersion}}"], + capture_output=True, + text=True, + timeout=10, + ) + except FileNotFoundError: + return False, ( + "`docker` CLI not on PATH for subprocess. " + "If you use colima/OrbStack/Rancher Desktop, confirm the docker " + "binary is in /usr/local/bin or symlinked there." + ) + except subprocess.TimeoutExpired: + return False, ( + "`docker info` timed out after 10s — daemon is probably not " + "running. Start Docker Desktop (or your runtime of choice)." + ) + + if r.returncode == 0: + return True, "" + + err = (r.stderr or r.stdout or "").strip().splitlines() + # Keep the skip message readable: first non-empty line is usually enough. + first = next((ln for ln in err if ln.strip()), "") + return False, f"`docker info` exit {r.returncode}: {first[:200]}" + + +_DOCKER_OK, _DOCKER_REASON = _docker_status() + + +# --------------------------------------------------------------------------- +# PostGIS container. +# +# Booted in `pytest_configure` so the TASK_DATABASE_URL and +# OSM_DATABASE_URL environment variables are set before any test file +# imports `api.*` — the engines in `api.core.database` are constructed +# at module load and would otherwise bind to the URLs from `.env`. +# +# Each alembic tree runs against its own database to avoid colliding +# on the default `alembic_version` table: +# - default container DB → TASK_DATABASE_URL (workspaces, workspaces_*) +# - provisioned `/osm_test` → OSM_DATABASE_URL (users, teams, tasking_*) +# --------------------------------------------------------------------------- + + +async def _provision_osm_db(task_url: str, db_name: str) -> str: + """Create an additional database on the same Postgres instance. + + Returns the connection URL for the new DB. Uses AUTOCOMMIT isolation so + ``CREATE DATABASE`` doesn't error inside a transaction. + """ + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + engine = create_async_engine(task_url, isolation_level="AUTOCOMMIT") + try: + async with engine.connect() as conn: + result = await conn.execute( + text("SELECT 1 FROM pg_database WHERE datname = :n"), + {"n": db_name}, + ) + if result.scalar() is None: + await conn.execute(text(f'CREATE DATABASE "{db_name}"')) + finally: + await engine.dispose() + return task_url.rsplit("/", 1)[0] + "/" + db_name + + +# Module-level state owned by pytest_configure / pytest_unconfigure. +_INTEGRATION_CONTAINER = None + + +def _wants_integration(config) -> bool: + """Inspect the markexpr to decide whether to boot the container this run.""" + m = (config.option.markexpr or "").strip() + if not m: + # Bare `pytest` runs with `addopts = -m 'not integration'`, so m + # will be 'not integration' — handled below. Defensive default. + return False + if m == "not integration": + return False + # Anything that mentions integration positively triggers a boot. + return "integration" in m + + +def pytest_configure(config): + """Boot the testcontainer BEFORE test files are imported (collection phase). + + This sets ``TASK_DATABASE_URL`` and ``OSM_DATABASE_URL`` in ``os.environ`` + so that ``api.core.config.settings`` — read at module load by + ``api.core.database`` to build engines — picks up the container URLs. + """ + global _INTEGRATION_CONTAINER + + if not _wants_integration(config): + return + if not _DOCKER_OK: + return # let the fixture surface the skip with a clean reason + try: + from testcontainers.postgres import PostgresContainer + except ImportError: + return # ditto — fixture-time skip with install instructions + + import asyncio + + container = PostgresContainer("postgis/postgis:16-3.4", driver="asyncpg") + container.start() + task_url = container.get_connection_url() + osm_url = asyncio.run(_provision_osm_db(task_url, "osm_test")) + + os.environ["TASK_DATABASE_URL"] = task_url + os.environ["OSM_DATABASE_URL"] = osm_url + _INTEGRATION_CONTAINER = container + + +def pytest_unconfigure(config): + """Tear the container down at the end of the pytest session.""" + global _INTEGRATION_CONTAINER + if _INTEGRATION_CONTAINER is not None: + try: + _INTEGRATION_CONTAINER.stop() + finally: + _INTEGRATION_CONTAINER = None + + +@pytest.fixture(scope="session") +def _pg_urls() -> Iterator[tuple[str, str]]: + """Yield (task_url, osm_url) populated by ``pytest_configure``.""" + if not _DOCKER_OK: + pytest.skip(f"Docker not available — {_DOCKER_REASON}") + try: + import testcontainers.postgres # noqa: F401 + except ImportError: + pytest.skip( + "testcontainers not installed; install with " + "`uv sync --extra integration`" + ) + + task_url = os.environ.get("TASK_DATABASE_URL") + osm_url = os.environ.get("OSM_DATABASE_URL") + if not task_url or not osm_url or _INTEGRATION_CONTAINER is None: + pytest.skip( + "Integration container is not running — invoke pytest with " + "`-m integration` so pytest_configure can boot it." + ) + yield task_url, osm_url + + +# Backwards-compatible alias for fixtures that only need one URL (the +# tasking_* tables live in the OSM database). +@pytest.fixture(scope="session") +def _pg_url(_pg_urls: tuple[str, str]) -> str: + return _pg_urls[1] + + +# --------------------------------------------------------------------------- +# Alembic upgrade — each tree against its own database. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def _migrated_db(_pg_urls: tuple[str, str]) -> tuple[str, str]: + """Run alembic upgrades for both trees against their own databases. + + Returns ``(task_url, osm_url)`` after both heads are reached. + """ + task_url, osm_url = _pg_urls + repo_root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + env = os.environ.copy() + env["TASK_DATABASE_URL"] = task_url + env["OSM_DATABASE_URL"] = osm_url + for db_name in ("task", "osm"): + result = subprocess.run( + [sys.executable, "-m", "alembic", "-n", db_name, "upgrade", "head"], + capture_output=True, + text=True, + cwd=repo_root, + env=env, + ) + if result.returncode != 0: + pytest.fail( + f"alembic '{db_name}' upgrade failed:\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + return task_url, osm_url + + +# --------------------------------------------------------------------------- +# Seed: one workspace row matching SEED_WORKSPACE_ID. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +async def _seed_workspace_row(_migrated_db: tuple[str, str]) -> int: + """Insert one workspace row so tenancy/permission gates resolve. + + Workspaces live in the TASK database (alongside teams, project groups), + so we point this at task_url. + """ + from uuid import uuid4 + + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + task_url, _osm_url = _migrated_db + engine = create_async_engine(task_url, future=True) + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO workspaces " + '(id, type, title, "tdeiProjectGroupId", "createdAt", ' + ' "createdBy", "createdByName", "externalAppAccess") ' + "VALUES (:id, :type, :title, :pgid, NOW(), :uid, :uname, 0) " + "ON CONFLICT (id) DO NOTHING" + ), + { + "id": SEED_WORKSPACE_ID, + "type": "osw", + "title": "Test Workspace", + "pgid": str(SEED_PROJECT_GROUP_ID), + "uid": str(uuid4()), + "uname": "seed", + }, + ) + await engine.dispose() + return SEED_WORKSPACE_ID + + +# Override the shared `seeded_workspace_id` fixture so that, inside the +# integration suite, requesting it triggers the testcontainer + migration +# + seed pipeline. Unit tests get the bare constant from tests/conftest.py. +@pytest.fixture +async def seeded_workspace_id(_seed_workspace_row: int) -> int: + return _seed_workspace_row + + +# --------------------------------------------------------------------------- +# User-row seeding — `tasking_project_roles.user_auth_uid` has a FK to +# `users.auth_uid`, so every fake user that hits the DB needs a matching +# row. Override the shared auth fixtures here to handle that. +# --------------------------------------------------------------------------- + + +async def _insert_user_row(osm_url: str, user) -> None: + """Insert a row into ``users`` matching a fabricated UserInfo.""" + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + engine = create_async_engine(osm_url) + try: + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO users (auth_uid, email, display_name) " + "VALUES (:uid, :email, :name) " + "ON CONFLICT (auth_uid) DO NOTHING" + ), + { + "uid": str(user.user_uuid), + "email": f"{user.user_uuid}@test.local", + "name": user.user_name, + }, + ) + finally: + await engine.dispose() + + +def _override_token(user) -> None: + from api.core.security import validate_token + from api.main import app + + app.dependency_overrides[validate_token] = lambda: user + + +def _clear_token() -> None: + from api.core.security import validate_token + from api.main import app + + app.dependency_overrides.pop(validate_token, None) + + +# --------------------------------------------------------------------------- +# Per-test DB session override. +# +# `api/core/database.py` constructs its asyncpg engines at module load, +# binding their pool connections to the event loop active at that +# moment. pytest-asyncio uses a fresh event loop per function-scoped +# test, so a shared engine would carry stale connections across loops +# and asyncpg would raise ``InterfaceError: cannot perform operation: +# another operation is in progress``. +# +# The autouse fixture below replaces `get_task_session` and +# `get_osm_session` with per-test engines (NullPool, disposed on +# teardown), so each test opens its connections on its own loop. +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +async def _per_test_db_sessions(_pg_urls): + from sqlalchemy.ext.asyncio import create_async_engine + from sqlalchemy.orm import sessionmaker + from sqlalchemy.pool import NullPool + from sqlmodel.ext.asyncio.session import AsyncSession + + from api.core.database import get_osm_session, get_task_session + from api.main import app + + task_url, osm_url = _pg_urls + + # NullPool disables connection reuse: each session checkout opens a + # fresh connection on the current event loop and disposes it on + # exit, preventing cross-loop pooled-connection errors. + task_engine = create_async_engine(task_url, future=True, poolclass=NullPool) + osm_engine = create_async_engine(osm_url, future=True, poolclass=NullPool) + + task_factory = sessionmaker( + class_=AsyncSession, expire_on_commit=False, bind=task_engine + ) + osm_factory = sessionmaker( + class_=AsyncSession, expire_on_commit=False, bind=osm_engine + ) + + async def _get_task(): + async with task_factory() as session: + try: + yield session + finally: + await session.close() + + async def _get_osm(): + async with osm_factory() as session: + try: + yield session + finally: + await session.close() + + app.dependency_overrides[get_task_session] = _get_task + app.dependency_overrides[get_osm_session] = _get_osm + + try: + yield + finally: + app.dependency_overrides.pop(get_task_session, None) + app.dependency_overrides.pop(get_osm_session, None) + await task_engine.dispose() + await osm_engine.dispose() + + +# Role fixtures — each inserts a matching `users` row and overrides +# `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 SEED_PROJECT_GROUP_ID, _make_user + + _, osm_url = _pg_urls + user = _make_user( + role="lead", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + await _insert_user_row(osm_url, user) + _override_token(user) + yield user + _clear_token() + + +@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 SEED_PROJECT_GROUP_ID, _make_user + + _, osm_url = _pg_urls + user = _make_user( + role="contributor", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + await _insert_user_row(osm_url, user) + _override_token(user) + yield user + _clear_token() + + +@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 SEED_PROJECT_GROUP_ID, _make_user + + _, osm_url = _pg_urls + user = _make_user( + role="validator", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + await _insert_user_row(osm_url, user) + _override_token(user) + yield user + _clear_token() + + +@pytest.fixture +async def as_outsider(_pg_urls, seeded_workspace_id): + """Outsider — no PG membership. + + 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 SEED_PROJECT_GROUP_ID, _make_user + + _, osm_url = _pg_urls + user = _make_user( + role=None, + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + await _insert_user_row(osm_url, user) + _override_token(user) + yield user + _clear_token() + + +# --------------------------------------------------------------------------- +# Extra-user factory — for tests that need additional users beyond the +# single "active" caller. Inserts the matching `users` row so FKs hold, +# but does NOT swap `validate_token`. Tests that want to act-as the new +# user should pair this with the shared `override_user` fixture. +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def extra_user_factory(_pg_urls, seeded_workspace_id): + """Factory: ``await make_user("contributor")`` returns a fresh UserInfo + persisted in the OSM `users` table. + + The caller's `validate_token` override is left untouched — pair with + the `override_user` fixture from ``tests/conftest.py`` to act as the + returned user. + """ + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user + + _, osm_url = _pg_urls + + async def _make(role: str | None): + user = _make_user( + role=role, + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + await _insert_user_row(osm_url, user) + return user + + return _make + + +# --------------------------------------------------------------------------- +# TDEI stub — no real TDEI backend is available in integration. The default +# stub returns an empty member list, so any role_assignments[].user_id that +# is not already in `users` stays missing and surfaces as 422. +# +# Tests that exercise the auto-provisioning path append to +# `tdei_project_group_users` to simulate TDEI returning specific members. +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def tdei_project_group_users(monkeypatch): + """Stub `fetch_project_group_users` to return a controllable list.""" + members: list = [] + + async def _fake_fetch(project_group_id: str, bearer_token: str): + return list(members) + + import api.core.security + import api.src.tasking.projects.repository as proj_repo + + 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) + return members + + +# --------------------------------------------------------------------------- +# Per-test cleanup of tasking_* tables (opt-in). +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def reset_tasking(_pg_url: str) -> AsyncIterator[None]: + """Truncate tasking_* tables between tests that opt in. + + Workflow tests inside a class share state by design (class-scoped + project id passed between steps), so this fixture is *opt-in* — + only request it from tests that need a clean slate. + """ + yield + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + engine = create_async_engine(_pg_url, future=True) + async with engine.begin() as conn: + await conn.execute( + text( + "TRUNCATE TABLE " + " tasking_audit_events, tasking_feedback, " + " tasking_task_save_idempotency, tasking_locks, " + " tasking_tasks, tasking_project_roles, tasking_projects " + "RESTART IDENTITY CASCADE" + ) + ) + await engine.dispose() 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 new file mode 100644 index 0000000..c1575de --- /dev/null +++ b/tests/integration/test_projects_flow.py @@ -0,0 +1,773 @@ +from __future__ import annotations + +import pytest + +# Mark the whole module — every test in this file requires the +# testcontainer + migrated DB + seeded workspace fixtures. +pytestmark = pytest.mark.integration + + +API = "/api/v1/workspaces/{wid}/tasking/projects" + +# A simple unit-square polygon in WGS84 — well-formed, non-self-intersecting. +SQUARE_POLY = { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], +} + +SQUARE_MULTI = { + "type": "MultiPolygon", + "coordinates": [ + [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + [[[2, 2], [3, 2], [3, 3], [2, 3], [2, 2]]], + ], +} + + +# --------------------------------------------------------------------------- +# Workflow 1 — happy path through the full lifecycle. +# --------------------------------------------------------------------------- + + +class TestProjectLifecycle: + """draft -> upload AOI -> patch -> activate (still 422 w/o tasks).""" + + project_id: int | None = None + + async def test_01_create_draft(self, client, as_lead, seeded_workspace_id): + """Create a draft project — fresh row, status=draft, no AOI, no tasks.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "Pilot project", "review_required": True}, + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["status"] == "draft" + assert body["has_aoi"] is False + assert body["task_count"] == 0 + TestProjectLifecycle.project_id = body["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}") + assert r.status_code == 200 + assert r.json()["id"] == self.project_id + + async def test_03_patch_name(self, client, as_lead, seeded_workspace_id): + """PATCH the project name and confirm the update is reflected.""" + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}", + json={"name": "Pilot project (renamed)"}, + ) + assert r.status_code == 200 + assert r.json()["name"] == "Pilot project (renamed)" + + async def test_04_upload_polygon_aoi_is_upcast( + self, client, as_lead, seeded_workspace_id + ): + """Upload a Polygon AOI — storage column is MULTIPOLYGON so server upcasts.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/aoi", + json=SQUARE_POLY, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["geometry"]["type"] == "MultiPolygon" + assert body["geometry"]["coordinates"][0] == SQUARE_POLY["coordinates"] + + async def test_05_activate_blocked_without_tasks( + self, client, as_lead, seeded_workspace_id + ): + """Activate must fail with 422 when the project has no tasks yet.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/activate" + ) + assert r.status_code == 422 + assert "task" in r.json()["detail"].lower() + + async def test_06_aoi_get_returns_feature( + self, client, as_lead, seeded_workspace_id + ): + """GET /aoi returns a GeoJSON Feature wrapping a MultiPolygon.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/aoi" + ) + assert r.status_code == 200 + body = r.json() + assert body["type"] == "Feature" + assert body["geometry"]["type"] == "MultiPolygon" + + async def test_07_soft_delete_clears_listing( + self, client, as_lead, seeded_workspace_id + ): + """DELETE soft-removes the project; it vanishes from list and direct GET 404s.""" + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}" + ) + assert r.status_code == 204 + + r = await client.get(API.format(wid=seeded_workspace_id)) + 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}") + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Workflow 2 — AOI input variants on a fresh project. +# --------------------------------------------------------------------------- + + +class TestAoiInputShapes: + """Polygon / MultiPolygon / Feature / FeatureCollection all accepted.""" + + @pytest.fixture + async def fresh_project(self, client, as_lead, seeded_workspace_id): + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": f"AOI shapes {id(self)}"}, + ) + assert r.status_code == 201, r.text + return r.json()["id"] + + @pytest.mark.parametrize( + "shape", + [ + SQUARE_POLY, + SQUARE_MULTI, + { + "type": "Feature", + "geometry": SQUARE_POLY, + "properties": {"note": "wrapped"}, + }, + { + "type": "FeatureCollection", + "features": [{"type": "Feature", "geometry": SQUARE_POLY}], + }, + ], + ids=["polygon", "multipolygon", "feature", "feature_collection"], + ) + async def test_aoi_shape_accepted( + self, client, as_lead, seeded_workspace_id, fresh_project, shape + ): + """Each of Polygon / MultiPolygon / Feature / FeatureCollection is accepted and normalised.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{fresh_project}/aoi", + json=shape, + ) + assert r.status_code == 200, r.text + assert r.json()["geometry"]["type"] == "MultiPolygon" + + async def test_invalid_aoi_rejected( + self, client, as_lead, seeded_workspace_id, fresh_project + ): + """Self-intersecting bowtie polygon is rejected with 422 (Shapely is_valid=False).""" + bowtie = { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]], + } + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{fresh_project}/aoi", + json=bowtie, + ) + assert r.status_code == 422 + + +# --------------------------------------------------------------------------- +# Workflow 3 — permission + tenancy gates. +# --------------------------------------------------------------------------- + + +class TestProjectPermissions: + async def test_contributor_cannot_create( + self, client, as_contributor, seeded_workspace_id + ): + """Contributor is forbidden from creating projects (403, LEAD-only endpoint).""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "should not exist"}, + ) + assert r.status_code == 403 + + async def test_contributor_can_list( + self, client, as_contributor, seeded_workspace_id + ): + """Contributor can list projects in their workspace (read access).""" + r = await client.get(API.format(wid=seeded_workspace_id)) + assert r.status_code == 200 + assert "results" in r.json() + + async def test_outsider_404s_on_list( + self, client, as_outsider, seeded_workspace_id + ): + """Outsider with no project-group membership gets 404 — workspace existence hidden.""" + r = await client.get(API.format(wid=seeded_workspace_id)) + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Workflow 3b — error mapping (constraint violations → precise HTTP status). +# --------------------------------------------------------------------------- + + +class TestProjectCreateErrors: + async def test_role_assignment_with_unknown_user_returns_422( + self, client, as_lead, seeded_workspace_id + ): + """A uuid that TDEI does not list as a PG member → 422 + missing list.""" + bogus = "ffffffff-ffff-ffff-ffff-ffffffffffff" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={ + "name": "role-fk-error", + "role_assignments": [{"user_id": bogus, "role": "contributor"}], + }, + ) + assert r.status_code == 422, r.text + body = r.json() + # FastAPI nests structured `detail` payloads under the `detail` key. + assert "missing_user_ids" in body["detail"] + assert bogus in body["detail"]["missing_user_ids"] + assert "project group" in body["detail"]["message"].lower() + + async def test_role_assignment_auto_provisions_from_tdei( + self, + client, + as_lead, + seeded_workspace_id, + tdei_project_group_users, + ): + """A uuid that's not in `users` but IS a TDEI PG member is auto-provisioned + the project is created.""" + from api.core.security import TdeiProjectGroupUser + + new_user_uuid = "1abfdb85-54c0-449b-965c-0abfd835d6fa" + tdei_project_group_users.append( + TdeiProjectGroupUser( + auth_uid=new_user_uuid, + email=f"{new_user_uuid}@test.local", + display_name="Auto Provisioned", + ) + ) + + r = await client.post( + API.format(wid=seeded_workspace_id), + json={ + "name": "role-auto-provision", + "role_assignments": [ + {"user_id": new_user_uuid, "role": "validator"}, + ], + }, + ) + assert r.status_code == 201, r.text + pid = r.json()["id"] + + # Confirm the role assignment landed. + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/{new_user_uuid}" + ) + assert r.status_code == 200, r.text + assert r.json()["role"] == "validator" + + async def test_duplicate_project_name_returns_409_with_specific_message( + self, client, as_lead, seeded_workspace_id + ): + """A duplicate name surfaces as 409 with the name-conflict message — NOT the generic constraint hint.""" + name = "duplicate-name-test" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": name}, + ) + assert r.status_code == 201, r.text + + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": name}, + ) + assert r.status_code == 409, r.text + assert "already exists" in r.json()["detail"].lower() + + +# --------------------------------------------------------------------------- +# Workflow 4 — AOI delete / replace clears tasks. +# --------------------------------------------------------------------------- + + +class TestAoiReplaceSemantics: + async def test_aoi_replace_resets_boundary_type( + self, client, as_lead, seeded_workspace_id + ): + """Replacing the AOI clears task_boundary_type (per spec — geometry no longer matches).""" + # Create + first AOI. + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "replace-aoi"}, + ) + pid = r.json()["id"] + r1 = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=SQUARE_POLY, + ) + assert r1.status_code == 200 + + # Replace AOI with a different one. + r2 = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=SQUARE_MULTI, + ) + assert r2.status_code == 200 + 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() + assert proj["task_boundary_type"] is None + + 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), + json={"name": "delete-aoi"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=SQUARE_POLY, + ) + + 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") + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Workflow 5 — project role management +# - LEAD can list/add/update/remove role assignments +# - workspace-LEAD passes the manage-roles gate even without a project row +# - contributor cannot manage roles (403) +# - 422 mapping for unknown user_id, duplicate (409), missing assignment (404) +# - last-LEAD guard blocks the demote / delete that would orphan the project +# --------------------------------------------------------------------------- + + +class TestProjectRoles: + async def test_list_includes_creator_auto_lead( + self, client, as_lead, seeded_workspace_id + ): + """Project creator is auto-seeded as LEAD and appears in GET /roles.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-list-1"}, + ) + pid = r.json()["id"] + 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 + assert rows[0]["role"] == "lead" + assert rows[0]["user_id"] == str(as_lead.user_uuid) + + async def test_add_role_round_trip( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """POST /roles inserts a row; GET reflects it.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-add"}, + ) + pid = r.json()["id"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + assert r.status_code == 201, r.text + body = r.json() + 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") + ids = {row["user_id"] for row in r.json()["results"]} + assert str(contrib.user_uuid) in ids + + async def test_add_duplicate_returns_409( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """Re-adding the same user returns 409 with an actionable hint.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-dup"}, + ) + pid = r.json()["id"] + + path = f"{API.format(wid=seeded_workspace_id)}/{pid}/roles" + await client.post( + path, + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + r2 = await client.post( + path, + json={"user_id": str(contrib.user_uuid), "role": "validator"}, + ) + assert r2.status_code == 409, r2.text + assert "patch" in r2.json()["detail"].lower() + + async def test_add_unknown_user_returns_422( + self, client, as_lead, seeded_workspace_id + ): + """An unknown user_id surfaces as 422 + missing_user_ids list.""" + bogus = "ffffffff-ffff-ffff-ffff-ffffffffffff" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-unknown"}, + ) + pid = r.json()["id"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": bogus, "role": "contributor"}, + ) + assert r.status_code == 422, r.text + assert bogus in r.json()["detail"]["missing_user_ids"] + + async def test_update_role_promotes_contributor_to_validator( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """PATCH /roles/{uid} changes the stored role.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-patch"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{contrib.user_uuid}", + json={"role": "validator"}, + ) + assert r.status_code == 200, r.text + assert r.json()["role"] == "validator" + + async def test_update_unknown_user_returns_404( + self, client, as_lead, seeded_workspace_id + ): + """PATCH on a user without a role row returns 404.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-patch-404"}, + ) + pid = r.json()["id"] + absent = "deadbeef-dead-dead-dead-deadbeefdead" + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/{absent}", + json={"role": "validator"}, + ) + assert r.status_code == 404 + + async def test_remove_role_round_trip( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """DELETE /roles/{uid} removes the row; subsequent PATCH 404s.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-delete"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" f"{contrib.user_uuid}" + ) + assert r.status_code == 204 + + # Subsequent PATCH is a 404 — row is gone. + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{contrib.user_uuid}", + json={"role": "validator"}, + ) + assert r.status_code == 404 + + 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), + json={"name": "roles-last-lead-demote"}, + ) + pid = r.json()["id"] + + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{as_lead.user_uuid}", + json={"role": "contributor"}, + ) + 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): + """Cannot delete the only LEAD — would orphan the project.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-last-lead-delete"}, + ) + pid = r.json()["id"] + + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{as_lead.user_uuid}", + ) + assert r.status_code == 422 + assert "last lead" in r.json()["detail"].lower() + + async def test_demote_lead_works_when_two_leads_exist( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """With two LEADs, demoting one is allowed.""" + lead2 = await extra_user_factory("lead") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-two-leads"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(lead2.user_uuid), "role": "lead"}, + ) + + # 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}", + json={"role": "contributor"}, + ) + assert r.status_code == 200, r.text + assert r.json()["role"] == "contributor" + + async def test_get_single_role( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """GET /roles/{uid} returns just that user's row.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-get-single"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" f"{contrib.user_uuid}" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["user_id"] == str(contrib.user_uuid) + assert body["role"] == "contributor" + + async def test_get_single_role_404_when_absent( + self, client, as_lead, seeded_workspace_id + ): + """GET /roles/{uid} 404s when the user has no row on the project.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-get-404"}, + ) + pid = r.json()["id"] + absent = "feedf00d-feed-feed-feed-feedfeedfeed" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/{absent}" + ) + assert r.status_code == 404 + + async def test_put_upsert_inserts_with_201( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """PUT on a fresh user creates the row and returns 201.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-put-create"}, + ) + pid = r.json()["id"] + + r = await client.put( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{contrib.user_uuid}", + json={"role": "validator"}, + ) + assert r.status_code == 201, r.text + assert r.json()["role"] == "validator" + + async def test_put_upsert_updates_with_200( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """PUT on an existing user updates the role and returns 200.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-put-update"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + + r = await client.put( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{contrib.user_uuid}", + json={"role": "validator"}, + ) + assert r.status_code == 200, r.text + assert r.json()["role"] == "validator" + + async def test_put_unknown_user_returns_422( + self, client, as_lead, seeded_workspace_id + ): + """PUT for a uuid with no `users` row returns 422 + missing list.""" + bogus = "ffffffff-ffff-ffff-ffff-ffffffffffff" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-put-unknown"}, + ) + pid = r.json()["id"] + + r = await client.put( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/{bogus}", + json={"role": "contributor"}, + ) + assert r.status_code == 422, r.text + assert bogus in r.json()["detail"]["missing_user_ids"] + + async def test_put_last_lead_demote_blocked( + self, client, as_lead, seeded_workspace_id + ): + """PUT cannot demote the only LEAD any more than PATCH can.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-put-last-lead"}, + ) + pid = r.json()["id"] + + r = await client.put( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{as_lead.user_uuid}", + json={"role": "contributor"}, + ) + assert r.status_code == 422 + assert "last lead" in r.json()["detail"].lower() + + async def test_self_project_roles_falls_back_to_workspace_role( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + ): + """`/me/.../roles` returns override where present, workspace role elsewhere.""" + # Project A with no explicit override — caller will get workspace role. + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "self-roles-A"}, + ) + pid_a = r.json()["id"] + # Project B where the contributor gets an explicit validator role. + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "self-roles-B"}, + ) + pid_b = r.json()["id"] + contributor = await extra_user_factory("contributor") + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid_b}/roles", + json={"user_id": str(contributor.user_uuid), "role": "validator"}, + ) + + # Switch to the contributor and call /me/.../roles. + override_user(contributor) + r = await client.get( + f"/api/v1/me/workspaces/{seeded_workspace_id}/tasking/projects/roles" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["workspace_role"] == "contributor" + by_pid = {p["project_id"]: p for p in body["projects"]} + # Project B has the explicit validator override. + assert by_pid[pid_b]["role"] == "validator" + # Project A falls back to workspace-level contributor. + assert by_pid[pid_a]["role"] == "contributor" + + async def test_contributor_cannot_manage_roles( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + ): + """A workspace contributor with no project-LEAD role is denied 403.""" + # Create project as LEAD, then act-as a contributor. + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-contrib-denied"}, + ) + pid = r.json()["id"] + + contributor = await extra_user_factory("contributor") + override_user(contributor) + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={ + "user_id": str(contributor.user_uuid), + "role": "contributor", + }, + ) + assert r.status_code == 403 diff --git a/tests/integration/test_tasks_flow.py b/tests/integration/test_tasks_flow.py new file mode 100644 index 0000000..5af5c50 --- /dev/null +++ b/tests/integration/test_tasks_flow.py @@ -0,0 +1,909 @@ +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.integration + + +API = "/api/v1/workspaces/{wid}/tasking/projects" + + +# AOI that comfortably contains all task polygons below. +AOI_UNIT_SQUARE = { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], +} + +# Small (~1 km²) task polygons that fit inside the AOI and stay below the +# default grid-size warning threshold (5000 m → 25 km²). +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]] + ], +} +TASK_C = { + "type": "Polygon", + "coordinates": [ + [[0.04, 0.04], [0.05, 0.04], [0.05, 0.05], [0.04, 0.05], [0.04, 0.04]] + ], +} + +# A "fat" polygon (1°×1° ≈ 12 000 km²) — triggers the grid-size warning. +TASK_FAT = AOI_UNIT_SQUARE + + +def _fc(*polys: dict) -> dict: + return { + "type": "FeatureCollection", + "features": [{"type": "Feature", "geometry": p} for p in polys], + } + + +# --------------------------------------------------------------------------- +# Common setup helper — used by most test classes to land a project in the +# ``open`` state with N tasks saved and a contributor + validator allocated. +# --------------------------------------------------------------------------- + + +async def _create_open_project( + client, + workspace_id: int, + *, + contributor_uuid, + validator_uuid, + task_polygons: list[dict], + review_required: bool = True, + name_suffix: str = "", +) -> int: + """Create -> AOI -> save tasks -> activate. Returns the project id. + + Caller must already be acting as a LEAD (the route is LEAD-only). + The two role UUIDs satisfy the activation pre-check (≥1 contributor + or validator). Both users must exist in the OSM ``users`` table. + """ + name = f"flow-{id(task_polygons)}{name_suffix}" + r = await client.post( + API.format(wid=workspace_id), + json={ + "name": name, + "review_required": review_required, + "role_assignments": [ + {"user_id": str(contributor_uuid), "role": "contributor"}, + {"user_id": str(validator_uuid), "role": "validator"}, + ], + }, + ) + 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={"source": "import", "feature_collection": _fc(*task_polygons)}, + ) + 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 + assert r.json()["status"] == "open" + return pid + + +# --------------------------------------------------------------------------- +# Workflow 0 — Server-side grid generation. +# --------------------------------------------------------------------------- + + +class TestGridGeneration: + """LEAD: upload AOI, generate a grid, post the grid back through /tasks/save.""" + + async def test_grid_then_save_round_trip( + self, client, as_lead, seeded_workspace_id, reset_tasking + ): + """Grid generates clipped cells over the AOI; same payload commits via /tasks/save.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "grid-flow"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=AOI_UNIT_SQUARE, + ) + + # 1 km² cells over a 1° × 1° AOI ≈ 110 × 110 grid → ~12 000 cells. + # Use 25 km cells (~5x5 grid) so the test stays fast. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid?cell_size_meters=25000" + ) + assert r.status_code == 200, r.text + fc = r.json() + assert fc["type"] == "FeatureCollection" + assert len(fc["features"]) > 0 + # Every cell is a Polygon and inside the unit-square AOI bounds. + for feat in fc["features"]: + assert feat["geometry"]["type"] == "Polygon" + for ring in feat["geometry"]["coordinates"]: + for lon, lat in ring: + assert 0 <= lon <= 1 and 0 <= lat <= 1 + + # Round-trip: hand the same payload to /tasks/save with source=grid. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/save", + json={"source": "grid", "feature_collection": fc}, + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["task_count"] == len(fc["features"]) + assert body["task_boundary_type"] == "grid" + + async def test_grid_blocked_without_aoi( + self, client, as_lead, seeded_workspace_id, reset_tasking + ): + """Project AOI must be set before /tasks/grid will produce cells.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "grid-no-aoi"}, + ) + pid = r.json()["id"] + + 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() + + async def test_grid_blocked_outside_draft( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + reset_tasking, + ): + """/tasks/grid is rejected once the project leaves draft.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + pid = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-grid-state", + ) + + 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() + + async def test_grid_multipolygon_straddling_cell_splits( + self, client, as_lead, seeded_workspace_id, reset_tasking + ): + """A cell that straddles two disjoint AOI lobes is split into one Polygon per lobe. + + AOI is two 0.1°×0.1° lobes separated by a 0.1° east-west gap + (total east-west extent 0.3° ≈ 33 km at the equator). With a + 50 km cell (~0.45°), the entire AOI fits inside a single grid + cell whose intersection with the AOI is a MultiPolygon of two + pieces — the endpoint must surface those as two separate + Polygon features (one per lobe), not a single MultiPolygon. + """ + two_lobe_aoi = { + "type": "MultiPolygon", + "coordinates": [ + # Lobe A — west. + [[[0.0, 0.0], [0.1, 0.0], [0.1, 0.1], [0.0, 0.1], [0.0, 0.0]]], + # Lobe B — same latitude band, east of the gap. + [[[0.2, 0.0], [0.3, 0.0], [0.3, 0.1], [0.2, 0.1], [0.2, 0.0]]], + ], + } + + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "grid-multipolygon"}, + ) + pid = r.json()["id"] + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=two_lobe_aoi, + ) + assert r.status_code == 200 + + # 50 km cell ≈ 0.45° — large enough to envelop both lobes + # (0.3° east-west extent) in a single straddling cell, forcing + # the MultiPolygon split path. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid?cell_size_meters=50000" + ) + assert r.status_code == 200, r.text + fc = r.json() + + assert fc["type"] == "FeatureCollection" + # The straddling cell produces exactly one Polygon per lobe. + 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']}" + ) + + # The two output polygons should align with the two lobes — + # check each lies entirely within one lobe's x-range. + lobe_a_xs, lobe_b_xs = [], [] + for feat in fc["features"]: + xs = [pt[0] for ring in feat["geometry"]["coordinates"] for pt in ring] + if max(xs) <= 0.11: + lobe_a_xs.append(xs) + elif min(xs) >= 0.19: + lobe_b_xs.append(xs) + assert len(lobe_a_xs) == 1 and len(lobe_b_xs) == 1, ( + "expected one polygon per lobe, got " + f"{len(lobe_a_xs)} in lobe A, {len(lobe_b_xs)} in lobe B" + ) + + # Round-trip: the split features must persist cleanly via /tasks/save. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/save", + json={"source": "grid", "feature_collection": fc}, + ) + assert r.status_code == 201, r.text + assert r.json()["task_count"] == 2 + + async def test_grid_contributor_forbidden( + self, + client, + as_contributor, + seeded_workspace_id, + reset_tasking, + ): + """/tasks/grid is LEAD-only — contributors get 403 (no project needed for the gate).""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/999999/tasks/grid" + ) + assert r.status_code == 403, r.text + + +# --------------------------------------------------------------------------- +# Workflow 1 — Validate + Save round-trip. +# --------------------------------------------------------------------------- + + +class TestValidateAndSave: + """LEAD: upload AOI, validate, save, list, get a task.""" + + project_id: int | None = None + + 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), + json={"name": "validate-save"}, + ) + assert r.status_code == 201, r.text + TestValidateAndSave.project_id = r.json()["id"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/aoi", + json=AOI_UNIT_SQUARE, + ) + assert r.status_code == 200, r.text + + 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", + json=_fc(TASK_A, TASK_B), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["valid"] is True + assert body["warnings"] == [] + assert body["source"] == "import" + + async def test_03_validate_polygon_outside_aoi_rejected( + self, client, as_lead, seeded_workspace_id + ): + """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]]], + } + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/validate", + json=_fc(outside), + ) + assert r.status_code == 422, r.text + + async def test_04_validate_oversize_warns( + self, client, as_lead, seeded_workspace_id + ): + """A polygon larger than the grid-size threshold emits a warning but stays valid.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/validate", + json=_fc(TASK_FAT), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["valid"] is True + assert len(body["warnings"]) == 1 + assert body["warnings"][0]["issue"] == "polygon_exceeds_grid_size" + assert body["warnings"][0]["task_index"] == 0 + + async def test_05_save_persists_two_tasks( + self, client, as_lead, seeded_workspace_id + ): + """Save round-trips: returns task count, sequential numbering, sets boundary type.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/save", + json={"source": "import", "feature_collection": _fc(TASK_A, TASK_B)}, + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["task_count"] == 2 + assert body["task_boundary_type"] == "import" + assert [t["task_number"] for t in body["tasks"]] == [1, 2] + # First task starts in to_map with no lock and no last_mapper. + assert body["tasks"][0]["status"] == "to_map" + 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): + """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", + json={"source": "import", "feature_collection": _fc(TASK_A)}, + ) + assert r.status_code == 409, r.text + + async def test_07_list_tasks_returns_geometry( + self, client, as_lead, seeded_workspace_id + ): + """GET /tasks paginates and always includes geometry.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["pagination"]["total"] == 2 + 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): + """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" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["task_number"] == 1 + assert body["status"] == "to_map" + + async def test_09_aoi_replace_wipes_tasks( + self, client, as_lead, seeded_workspace_id + ): + """Re-uploading the AOI hard-deletes existing tasks (matches spec).""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/aoi", + json=AOI_UNIT_SQUARE, + ) + assert r.status_code == 200 + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks" + ) + assert r.status_code == 200 + assert r.json()["pagination"]["total"] == 0 + + +# --------------------------------------------------------------------------- +# Workflow 2 — Idempotent save. +# --------------------------------------------------------------------------- + + +class TestSaveIdempotency: + """Idempotency-Key header: replay vs. key-reuse-with-different-body.""" + + async def test_idempotent_save_lifecycle( + self, client, as_lead, seeded_workspace_id, reset_tasking + ): + """Same key + body → 200 replayed; same key + different body → 409.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "idem"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=AOI_UNIT_SQUARE, + ) + + body_a = {"source": "import", "feature_collection": _fc(TASK_A)} + body_b = {"source": "import", "feature_collection": _fc(TASK_B)} + key = "idem-key-001" + + r1 = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/save", + json=body_a, + headers={"Idempotency-Key": key}, + ) + assert r1.status_code == 201 + first_tasks = r1.json()["tasks"] + + # Replay: same key + same body returns 200 + replayed=true + same payload. + r2 = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/save", + json=body_a, + headers={"Idempotency-Key": key}, + ) + assert r2.status_code == 200, r2.text + assert r2.json()["replayed"] is True + assert r2.json()["tasks"] == first_tasks + + # Same key with a different body → 409. + r3 = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/save", + json=body_b, + headers={"Idempotency-Key": key}, + ) + assert r3.status_code == 409, r3.text + + +# --------------------------------------------------------------------------- +# Workflow 3 — Lock acquire / release / extend / force-release / one-per-user. +# --------------------------------------------------------------------------- + + +class TestLockLifecycle: + """Lock acquire, extend, release; one-active-lock-per-user-per-project.""" + + project_id: int | None = None + contributor = None + validator = None + + async def test_01_setup_open_project( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """Set up a project with 3 tasks + contributor + validator, then activate.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + TestLockLifecycle.contributor = contributor + TestLockLifecycle.validator = validator + + TestLockLifecycle.project_id = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A, TASK_B, TASK_C], + name_suffix="-lock", + ) + + async def test_02_contributor_locks_task_1( + self, client, override_user, seeded_workspace_id + ): + """Contributor acquires the lock on task 1 — task now reports the lock.""" + override_user(self.contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["lock"] is not None + assert body["lock"]["user_id"] == str(self.contributor.user_uuid) + + async def test_03_contributor_cannot_lock_second_task( + self, client, override_user, seeded_workspace_id + ): + """One-active-lock-per-user-per-project: locking task 2 returns 409 with existing-lock summary.""" + override_user(self.contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/2/lock" + ) + assert r.status_code == 409, r.text + detail = r.json()["detail"] + assert detail["existing_lock"]["task_number"] == 1 + + async def test_04_another_contributor_cannot_lock_task_1( + self, + client, + override_user, + seeded_workspace_id, + extra_user_factory, + ): + """A different contributor cannot lock a task that is already locked (409).""" + other = await extra_user_factory("contributor") + override_user(other) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 409, r.text + + async def test_05_extend_slides_expiry( + self, client, override_user, seeded_workspace_id + ): + """The lock holder can extend; expires_at moves forward.""" + override_user(self.contributor) + before = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1" + ) + expires_before = before.json()["lock"]["expires_at"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/extend" + ) + assert r.status_code == 200, r.text + assert r.json()["lock"]["expires_at"] > expires_before + + async def test_06_release_own_lock( + self, client, override_user, seeded_workspace_id + ): + """Caller releases their own lock — 204, lock gone on the task.""" + override_user(self.contributor) + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 204 + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1" + ) + assert r.json()["lock"] is None + + async def test_07_force_release_requires_lead( + self, + client, + override_user, + seeded_workspace_id, + ): + """force=true is LEAD-only; contributor gets 403 even for someone else's lock.""" + # Contributor re-locks task 1 first. + override_user(self.contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 200 + + # Validator tries to force-release (not a workspace LEAD) → 403. + override_user(self.validator) + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock?force=true" + ) + assert r.status_code == 403, r.text + + async def test_08_lead_force_release_succeeds( + self, client, as_lead, seeded_workspace_id + ): + """LEAD force-releases the contributor's lock; release_reason='lead_release'.""" + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock?force=true" + ) + assert r.status_code == 204 + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1" + ) + assert r.json()["lock"] is None + + async def test_09_unlock_without_active_lock_is_409( + self, client, override_user, seeded_workspace_id + ): + """Releasing an unlocked task returns 409.""" + override_user(self.contributor) + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 409, r.text + + +# --------------------------------------------------------------------------- +# Workflow 4 — Submit "Done?" flow through review. +# --------------------------------------------------------------------------- + + +class TestSubmitReviewFlow: + """to_map -> contributor submit -> to_review -> validator submit -> completed.""" + + project_id: int | None = None + contributor = None + validator = None + + async def test_01_setup_open_project( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """Set up an open project with one task + a contributor and validator.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + TestSubmitReviewFlow.contributor = contributor + TestSubmitReviewFlow.validator = validator + TestSubmitReviewFlow.project_id = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-submit", + ) + + async def test_02_contributor_lock_and_submit_done( + self, client, override_user, seeded_workspace_id + ): + """Contributor locks task 1 then submits done=true → status becomes to_review, lock auto-released.""" + override_user(self.contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 200 + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/submit", + json={"done": True}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "to_review" + assert body["lock"] is None + assert body["last_mapper"]["user_id"] == str(self.contributor.user_uuid) + + async def test_03_contributor_cannot_lock_for_review( + self, client, override_user, seeded_workspace_id + ): + """to_review tasks reject contributor-role lock attempts (validator/lead only).""" + override_user(self.contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 403, r.text + + async def test_04_validator_locks_to_review( + self, client, override_user, seeded_workspace_id + ): + """Validator can lock a to_review task they did not last map.""" + override_user(self.validator) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 200, r.text + + async def test_05_validator_submit_done_no_feedback_completes( + self, client, override_user, seeded_workspace_id + ): + """Validator submit done=true + no feedback → status=completed.""" + override_user(self.validator) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/submit", + json={"done": True}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "completed" + assert body["lock"] is None + + +# --------------------------------------------------------------------------- +# Workflow 5 — Submit done=false slides the lock; status unchanged. +# --------------------------------------------------------------------------- + + +class TestSubmitDoneFalseSlides: + async def test_submit_done_false_slides_expiry( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + reset_tasking, + ): + """done=false: state unchanged, lock expires_at slides to NOW + lock_timeout_hours.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + pid = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-slide", + ) + + override_user(contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + assert r.status_code == 200 + expiry_before = r.json()["lock"]["expires_at"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", + json={"done": False}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "to_map" # unchanged + assert body["lock"] is not None # still locked + # New expiry is at-or-after submitted_at + lock_timeout, so > original. + assert body["lock"]["expires_at"] >= expiry_before + + +# --------------------------------------------------------------------------- +# Workflow 6 — Validator-feedback remap loop. +# --------------------------------------------------------------------------- + + +class TestRemapFlow: + async def test_remap_loop( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + reset_tasking, + ): + """to_review + feedback → to_remap; feedback row is persisted.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + pid = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-remap", + ) + + # Contributor maps → to_review. + override_user(contributor) + 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={"done": True}, + ) + assert r.json()["status"] == "to_review" + + # Validator validates with feedback → to_remap. + override_user(validator) + 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={ + "done": True, + "feedback": { + "reason_category": "incomplete_mapping", + "notes": "Please finish the missing footways on the north side.", + }, + }, + ) + assert r.status_code == 200, r.text + assert r.json()["status"] == "to_remap" + assert r.json()["lock"] is None + + # Contributor re-locks the remapped task (allowed on to_remap). + override_user(contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + assert r.status_code == 200, r.text + + +# --------------------------------------------------------------------------- +# Workflow 7 — Self-validation guard. +# --------------------------------------------------------------------------- + + +class TestSelfValidationGuard: + async def test_validator_cannot_validate_own_last_mapping( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + reset_tasking, + ): + """A validator who is also the task's last_mapper cannot lock to_review.""" + validator = await extra_user_factory("validator") + # Need a second worker to satisfy the activation pre-check; the + # validator is doing double-duty (validator + mapper). + contributor = await extra_user_factory("contributor") + pid = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-self", + ) + + # 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/submit", + json={"done": True}, + ) + + # Now they try to validate their own work → 403. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + assert r.status_code == 403, r.text + + +# --------------------------------------------------------------------------- +# Workflow 8 — Per-task reset by LEAD. +# --------------------------------------------------------------------------- + + +class TestTaskReset: + async def test_reset_releases_lock_and_resets_status( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + reset_tasking, + ): + """LEAD reset on a locked to_review task: lock cleared, status back to to_map.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + pid = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-rst", + ) + + # 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/submit", + json={"done": True}, + ) + # Validator picks it up. + override_user(validator) + 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 SEED_PROJECT_GROUP_ID, _make_user + + lead = _make_user( + role="lead", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + app.dependency_overrides[validate_token] = lambda: lead + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/reset" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "to_map" + assert body["lock"] is None + assert body["last_mapper"] is None diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..20ac3f5 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from typing import Any +from uuid import UUID, uuid4 + +import pytest + +# --------------------------------------------------------------------------- +# Fake workspace repository — tenancy gate without a DB. +# --------------------------------------------------------------------------- + + +class FakeWorkspaceRepo: + """Mirrors ``WorkspaceRepository.getById`` semantics. + + Returns a stub workspace if the caller's accessibleWorkspaceIds + contain the requested id; raises ``NotFoundException`` otherwise. + """ + + 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 + } + if workspace_id not in all_ids: + raise NotFoundException(f"Workspace {workspace_id} not found") + # Echo the project-group id the user is a member of (any one + # works for unit tests — only the project-create route reads it). + pg_id = ( + next(iter(current_user.accessibleWorkspaceIds.keys())) + if current_user.accessibleWorkspaceIds + else "00000000-0000-0000-0000-000000000000" + ) + return SimpleNamespace(id=workspace_id, tdeiProjectGroupId=pg_id) + + +# --------------------------------------------------------------------------- +# Fake project repository — in-memory storage of project rows. +# --------------------------------------------------------------------------- + + +class FakeProjectRepo: + """In-memory stand-in for ``TaskingProjectRepository``. + + Stores projects in a dict keyed by id; returns ``ProjectResponse`` / + ``ProjectListResponse`` exactly as the real repo would. Does NOT + exercise PostGIS, ``ON CONFLICT``, soft-delete predicates, or + cross-table joins — those are integration-suite territory. + """ + + def __init__(self) -> None: + self._projects: dict[int, dict[str, Any]] = {} + self._aois: dict[int, dict[str, Any]] = {} + self._next_id = 1 + + # ---- helpers -------------------------------------------------------- + + def _response(self, p: dict[str, Any]): + from api.src.tasking.projects.dtos import ProjectResponse + + return ProjectResponse( + id=p["id"], + workspace_id=p["workspace_id"], + name=p["name"], + instructions=p.get("instructions"), + status=p["status"], + review_required=p["review_required"], + lock_timeout_hours=p["lock_timeout_hours"], + task_boundary_type=p.get("task_boundary_type"), + has_aoi=p["id"] in self._aois, + task_count=p.get("task_count", 0), + created_by=p["created_by"], + created_by_name=p.get("created_by_name"), + created_at=p["created_at"], + updated_at=p["updated_at"], + ) + + # ---- create / list / get / patch / delete -------------------------- + + async def create( + self, + workspace_id: int, + current_user, + body, + tdei_project_group_id: str | None = None, + ): + from api.core.exceptions import AlreadyExistsException + + if any( + p["workspace_id"] == workspace_id and p["name"] == body.name + for p in self._projects.values() + ): + raise AlreadyExistsException( + "A project with this name already exists in the workspace" + ) + pid = self._next_id + self._next_id += 1 + now = datetime.now() + self._projects[pid] = { + "id": pid, + "workspace_id": workspace_id, + "name": body.name, + "instructions": body.instructions, + "status": "draft", + "review_required": body.review_required, + "lock_timeout_hours": body.lock_timeout_hours, + "task_boundary_type": None, + "created_by": current_user.user_uuid, + "created_by_name": current_user.user_name, + "created_at": now, + "updated_at": now, + "deleted_at": None, + } + if body.aoi is not None: + self._aois[pid] = {"upcast_to": "MultiPolygon"} + return self._response(self._projects[pid]) + + async def list_projects( + self, + workspace_id: int, + *, + status_filter=None, + text_search=None, + page: int = 1, + page_size: int = 20, + order_by: str = "created_at", + order_dir: str = "DESC", + ): + from api.src.tasking.projects.dtos import ( + Pagination, + ProjectListItem, + ProjectListResponse, + ) + + rows = [ + p + for p in self._projects.values() + if p["workspace_id"] == workspace_id and p["deleted_at"] is None + ] + if status_filter is not None: + rows = [p for p in rows if p["status"] == status_filter] + if text_search: + t = text_search.lower() + rows = [p for p in rows if t in p["name"].lower()] + + total = len(rows) + offset = (page - 1) * page_size + items = [ + ProjectListItem( + id=p["id"], + name=p["name"], + status=p["status"], + task_count=0, + percent_completed=0, + created_by=p["created_by"], + created_by_name=p.get("created_by_name"), + created_at=p["created_at"], + updated_at=p["updated_at"], + ) + for p in rows[offset : offset + page_size] + ] + return ProjectListResponse( + results=items, + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + async def get(self, workspace_id: int, project_id: int): + from api.core.exceptions import NotFoundException + + p = self._projects.get(project_id) + if p is None or p["workspace_id"] != workspace_id or p["deleted_at"]: + raise NotFoundException(f"Project {project_id} not found") + return self._response(p) + + async def patch(self, workspace_id, project_id, body, current_user): + p_resp = await self.get(workspace_id, project_id) + p = self._projects[project_id] + if body.name is not None: + p["name"] = body.name.strip() + if body.instructions is not None: + p["instructions"] = body.instructions + if body.lock_timeout_hours is not None: + p["lock_timeout_hours"] = body.lock_timeout_hours + if body.review_required is not None: + p["review_required"] = body.review_required + p["updated_at"] = datetime.now() + return self._response(p) + + async def soft_delete(self, workspace_id, project_id, current_user): + await self.get(workspace_id, project_id) + self._projects[project_id]["deleted_at"] = datetime.now() + + async def activate(self, workspace_id, project_id, current_user): + from fastapi import HTTPException, status + + # Mirrors the real repo's "needs tasks" rule. + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project must have at least one task", + ) + + async def close(self, workspace_id, project_id, current_user): + from fastapi import HTTPException, status + + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Only open projects can be closed", + ) + + async def reset(self, workspace_id, project_id, current_user): + await self.get(workspace_id, project_id) + return self._response(self._projects[project_id]) + + # ---- AOI ------------------------------------------------------------ + + async def get_aoi(self, workspace_id, project_id): + from api.core.exceptions import NotFoundException + from api.src.tasking.projects.dtos import AoiFeature + from api.src.tasking.projects.schemas import _MultiPolygon + + await self.get(workspace_id, project_id) + if project_id not in self._aois: + raise NotFoundException("AOI is not set on this project") + return AoiFeature( + type="Feature", + geometry=_MultiPolygon( + type="MultiPolygon", + coordinates=[[[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]], + ), + properties={}, + ) + + async def upload_aoi(self, workspace_id, project_id, aoi, current_user): + from api.src.tasking.projects.dtos import AoiFeature + from api.src.tasking.projects.schemas import _MultiPolygon + + await self.get(workspace_id, project_id) + self._aois[project_id] = {"raw": aoi} + return AoiFeature( + type="Feature", + geometry=_MultiPolygon( + type="MultiPolygon", + coordinates=[[[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]], + ), + properties={}, + ) + + async def delete_aoi(self, workspace_id, project_id, current_user): + from api.core.exceptions import NotFoundException + + await self.get(workspace_id, project_id) + if project_id not in self._aois: + raise NotFoundException("AOI is not set on this project") + del self._aois[project_id] + + +# --------------------------------------------------------------------------- +# Dependency override fixtures. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_project_repo(): + """Swap ``get_project_repo`` with an in-memory FakeProjectRepo.""" + from api.main import app + from api.src.tasking.projects.routes import get_project_repo + + repo = FakeProjectRepo() + app.dependency_overrides[get_project_repo] = lambda: repo + yield repo + app.dependency_overrides.pop(get_project_repo, None) + + +@pytest.fixture +def fake_workspace_repo(): + """Swap ``get_workspace_repo`` with the in-memory FakeWorkspaceRepo.""" + from api.main import app + from api.src.tasking.projects.routes import get_workspace_repo + + repo = FakeWorkspaceRepo() + app.dependency_overrides[get_workspace_repo] = lambda: repo + yield repo + app.dependency_overrides.pop(get_workspace_repo, None) + + +# Convenience: most route tests need both. One fixture, request once. +@pytest.fixture +def fake_repos(fake_project_repo, fake_workspace_repo): + return SimpleNamespace( + project=fake_project_repo, + workspace=fake_workspace_repo, + ) diff --git a/tests/unit/test_aoi_normalisation.py b/tests/unit/test_aoi_normalisation.py new file mode 100644 index 0000000..86b253b --- /dev/null +++ b/tests/unit/test_aoi_normalisation.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException +from shapely.geometry import MultiPolygon as ShapelyMultiPolygon + +from api.src.tasking.projects.repository import _aoi_to_shapely +from api.src.tasking.projects.schemas import ( + _Feature, + _FeatureCollection, + _MultiPolygon, + _Polygon, +) + +SQUARE = [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] +TWO_SQUARES = [ + [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + [[[2, 2], [3, 2], [3, 3], [2, 3], [2, 2]]], +] + + +class TestAoiUpcast: + def test_polygon_upcasts_to_multipolygon(self): + """A bare GeoJSON Polygon is upcast to a single-member MultiPolygon for storage.""" + out = _aoi_to_shapely(_Polygon(type="Polygon", coordinates=SQUARE)) + assert isinstance(out, ShapelyMultiPolygon) + assert len(out.geoms) == 1 + + def test_multipolygon_passes_through(self): + """A GeoJSON MultiPolygon round-trips with all its constituent polygons intact.""" + out = _aoi_to_shapely( + _MultiPolygon(type="MultiPolygon", coordinates=TWO_SQUARES) + ) + assert isinstance(out, ShapelyMultiPolygon) + assert len(out.geoms) == 2 + + def test_feature_unwrapped(self): + """A GeoJSON Feature wrapping a Polygon is unwrapped and upcast.""" + feat = _Feature( + type="Feature", + geometry=_Polygon(type="Polygon", coordinates=SQUARE), + properties={"k": "v"}, + ) + out = _aoi_to_shapely(feat) + assert isinstance(out, ShapelyMultiPolygon) + + def test_feature_collection_unwrapped(self): + """A single-Feature FeatureCollection is unwrapped (collections with >1 feature are rejected upstream).""" + fc = _FeatureCollection( + type="FeatureCollection", + features=[ + _Feature( + type="Feature", + geometry=_Polygon(type="Polygon", coordinates=SQUARE), + ) + ], + ) + out = _aoi_to_shapely(fc) + assert isinstance(out, ShapelyMultiPolygon) + + +class TestInvalidGeometryRejected: + def test_self_intersecting_polygon_rejected(self): + """A self-intersecting 'bowtie' polygon is rejected with HTTP 422.""" + bowtie = _Polygon( + type="Polygon", + coordinates=[[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]], + ) + with pytest.raises(HTTPException) as exc: + _aoi_to_shapely(bowtie) + assert exc.value.status_code == 422 diff --git a/tests/unit/test_dtos_validation.py b/tests/unit/test_dtos_validation.py new file mode 100644 index 0000000..8bad12f --- /dev/null +++ b/tests/unit/test_dtos_validation.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from api.src.tasking.projects.dtos import ( + ProjectCreateRequest, + ProjectRoleAssignment, + ProjectUpdateRequest, +) + +# --------------------------------------------------------------------------- +# ProjectCreateRequest +# --------------------------------------------------------------------------- + + +class TestProjectCreateRequest: + def test_minimal_body_accepted(self): + """A body with just `name` populates defaults (review_required=True, lock_timeout=8h).""" + m = ProjectCreateRequest(name="hello") + assert m.name == "hello" + assert m.review_required is True + assert m.lock_timeout_hours == 8 + assert m.aoi is None + assert m.role_assignments == [] + + def test_name_blank_rejected(self): + """Whitespace-only project names are rejected with a clear 'blank' error.""" + with pytest.raises(ValidationError) as exc: + ProjectCreateRequest(name=" ") + assert "blank" in str(exc.value).lower() + + def test_name_too_long_rejected(self): + """Project names longer than 255 characters are rejected.""" + with pytest.raises(ValidationError): + ProjectCreateRequest(name="x" * 256) + + @pytest.mark.parametrize("hours", [0, -1, 721]) + def test_lock_timeout_out_of_range_rejected(self, hours): + """lock_timeout_hours must be in [1, 720]; out-of-range values are rejected.""" + with pytest.raises(ValidationError): + ProjectCreateRequest(name="ok", lock_timeout_hours=hours) + + @pytest.mark.parametrize("hours", [1, 8, 720]) + def test_lock_timeout_in_range_accepted(self, hours): + """lock_timeout_hours boundary values (1, default, 720) are accepted.""" + m = ProjectCreateRequest(name="ok", lock_timeout_hours=hours) + assert m.lock_timeout_hours == hours + + def test_instructions_too_long_rejected(self): + """Instructions over 10,000 characters are rejected.""" + with pytest.raises(ValidationError): + ProjectCreateRequest(name="ok", instructions="x" * 10_001) + + +# --------------------------------------------------------------------------- +# ProjectUpdateRequest +# --------------------------------------------------------------------------- + + +class TestProjectUpdateRequest: + def test_all_fields_optional(self): + """An empty PATCH body is valid — every field is optional.""" + m = ProjectUpdateRequest() + assert m.name is None + assert m.instructions is None + assert m.lock_timeout_hours is None + assert m.review_required is None + + def test_partial_update(self): + """Only specified fields are populated; the rest stay None.""" + m = ProjectUpdateRequest(name="x") + assert m.name == "x" + assert m.review_required is None + + +# --------------------------------------------------------------------------- +# ProjectRoleAssignment +# --------------------------------------------------------------------------- + + +class TestProjectRoleAssignment: + def test_valid_roles(self): + """Each of the three role strings ('lead', 'validator', 'contributor') is accepted.""" + from uuid import uuid4 + + for role in ("lead", "validator", "contributor"): + m = ProjectRoleAssignment(user_id=uuid4(), role=role) + assert m.role == role + + def test_invalid_role_rejected(self): + """Unknown role strings (e.g. 'admin') are rejected by the Literal.""" + from uuid import uuid4 + + with pytest.raises(ValidationError): + ProjectRoleAssignment(user_id=uuid4(), role="admin") diff --git a/tests/unit/test_project_routes.py b/tests/unit/test_project_routes.py new file mode 100644 index 0000000..1620aba --- /dev/null +++ b/tests/unit/test_project_routes.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +API = "/api/v1/workspaces/{wid}/tasking/projects" + + +# --------------------------------------------------------------------------- +# Permission gates +# --------------------------------------------------------------------------- + + +class TestPermissionGates: + async def test_outsider_404s_on_list( + self, client, as_outsider, seeded_workspace_id, fake_repos + ): + """Outsider (no project-group membership) gets 404 on list — tenancy gate hides existence.""" + r = await client.get(API.format(wid=seeded_workspace_id)) + assert r.status_code == 404 + + async def test_contributor_can_list( + self, client, as_contributor, seeded_workspace_id, fake_repos + ): + """Contributor can list projects in their workspace (read access for any role).""" + r = await client.get(API.format(wid=seeded_workspace_id)) + assert r.status_code == 200 + body = r.json() + assert body["results"] == [] + assert body["pagination"]["total"] == 0 + + async def test_contributor_cannot_create( + self, client, as_contributor, seeded_workspace_id, fake_repos + ): + """Contributor cannot create projects — write endpoints require LEAD.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "x"}, + ) + assert r.status_code == 403 + + +# --------------------------------------------------------------------------- +# CRUD happy-path +# --------------------------------------------------------------------------- + + +class TestProjectCrud: + async def test_create_returns_201( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """LEAD creates a draft project — 201 with the persisted body.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "Pilot", "review_required": False}, + ) + assert r.status_code == 201 + body = r.json() + assert body["name"] == "Pilot" + assert body["status"] == "draft" + assert body["review_required"] is False + assert len(fake_repos.project._projects) == 1 + + async def test_create_blank_name_422( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """Whitespace-only name is rejected by the Pydantic validator (422).""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": " "}, + ) + assert r.status_code == 422 + + 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") + assert r.status_code == 404 + + async def test_create_then_get_round_trip( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """A project created via POST is readable via GET with the same id.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "RT"}, + ) + pid = r.json()["id"] + + 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): + """PATCH updates only specified fields — name change is reflected on GET.""" + pid = ( + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "before"}, + ) + ).json()["id"] + + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}", + json={"name": "after"}, + ) + assert r.status_code == 200 + assert r.json()["name"] == "after" + + async def test_soft_delete_204_then_404( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """DELETE soft-removes the project; subsequent GET returns 404.""" + pid = ( + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "to-delete"}, + ) + ).json()["id"] + + 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}") + assert r.status_code == 404 + + async def test_duplicate_name_409( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """Creating a project with a duplicate name in the same workspace returns 409.""" + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "dup"}, + ) + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "dup"}, + ) + assert r.status_code == 409 + + +# --------------------------------------------------------------------------- +# Lifecycle gates +# --------------------------------------------------------------------------- + + +class TestLifecycle: + async def test_activate_fake_always_422( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """Activate is gated — without tasks it returns 422 (FakeRepo mirrors the real rule).""" + pid = ( + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "act"}, + ) + ).json()["id"] + + r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/activate") + assert r.status_code == 422 + + +# --------------------------------------------------------------------------- +# AOI sub-resource +# --------------------------------------------------------------------------- + + +class TestAoiRoutes: + async def test_upload_aoi_polygon( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """Uploading a Polygon AOI returns a Feature wrapping a MultiPolygon (upcast).""" + pid = ( + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "aoi"}, + ) + ).json()["id"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json={ + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + }, + ) + assert r.status_code == 200 + assert r.json()["geometry"]["type"] == "MultiPolygon" + + async def test_get_aoi_404_when_unset( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """GET /aoi on a project with no AOI yet returns 404.""" + pid = ( + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "no-aoi"}, + ) + ).json()["id"] + + 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( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """DELETE /aoi clears the AOI; subsequent GET /aoi returns 404.""" + pid = ( + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "to-clear"}, + ) + ).json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json={ + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + }, + ) + + 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") + assert r.status_code == 404 diff --git a/tests/unit/test_user_info_gates.py b/tests/unit/test_user_info_gates.py new file mode 100644 index 0000000..32fcfa2 --- /dev/null +++ b/tests/unit/test_user_info_gates.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from uuid import UUID + +from api.core.security import TdeiProjectGroupRole, UserInfo, UserInfoPGMembership +from api.src.users.schemas import WorkspaceUserRoleType + +PG = "00000000-0000-0000-0000-000000000001" + + +def _user(*, osm_roles=None, pg_roles=None, accessible=None): + u = UserInfo() + u.credentials = "x" + 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.accessibleWorkspaceIds = accessible or {} + return u + + +class TestIsWorkspaceLead: + def test_explicit_lead_role(self): + """User with WorkspaceUserRoleType.LEAD on the workspace is a lead.""" + u = _user(osm_roles={42: [WorkspaceUserRoleType.LEAD]}) + assert u.isWorkspaceLead(42) is True + + def test_poc_in_owning_pg_grants_lead(self): + """TDEI POINT_OF_CONTACT in the project group that owns the workspace implies lead.""" + u = _user( + pg_roles=[TdeiProjectGroupRole.POINT_OF_CONTACT], + accessible={PG: [42]}, + ) + assert u.isWorkspaceLead(42) is True + + def test_member_only_is_not_lead(self): + """A plain TDEI MEMBER (no POC, no explicit LEAD) is NOT a lead.""" + u = _user( + pg_roles=[TdeiProjectGroupRole.MEMBER], + accessible={PG: [42]}, + ) + assert u.isWorkspaceLead(42) is False + + def test_no_membership_is_not_lead(self): + """A user with no project-group membership at all is NOT a lead.""" + assert _user().isWorkspaceLead(42) is False + + +class TestIsWorkspaceValidator: + def test_explicit_validator_role(self): + """User with WorkspaceUserRoleType.VALIDATOR on the workspace is a validator.""" + u = _user(osm_roles={42: [WorkspaceUserRoleType.VALIDATOR]}) + assert u.isWorkspaceValidator(42) is True + + def test_lead_is_not_implicit_validator(self): + """LEAD does NOT implicitly grant VALIDATOR — distinct roles by design.""" + u = _user(osm_roles={42: [WorkspaceUserRoleType.LEAD]}) + assert u.isWorkspaceValidator(42) is False + + +class TestIsWorkspaceContributor: + def test_any_pg_membership_is_contributor(self): + """Any project-group membership that owns the workspace makes the user a contributor.""" + u = _user( + pg_roles=[TdeiProjectGroupRole.MEMBER], + accessible={PG: [42]}, + ) + assert u.isWorkspaceContributor(42) is True + + def test_outsider_is_not_contributor(self): + """An outsider (no PG membership) is NOT a contributor.""" + assert _user().isWorkspaceContributor(42) is False diff --git a/uv.lock b/uv.lock index bbd35b2..519273f 100644 --- a/uv.lock +++ b/uv.lock @@ -627,6 +627,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, +] + [[package]] name = "packaging" version = "24.2" @@ -1055,6 +1116,57 @@ fastapi = [ { name = "fastapi" }, ] +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, + { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, + { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, + { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, + { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, + { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, + { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1215,6 +1327,7 @@ dependencies = [ { name = "requests" }, { name = "requests-cache" }, { name = "sentry-sdk", extra = ["fastapi"] }, + { name = "shapely" }, { name = "sqlalchemy" }, { name = "sqlmodel" }, { name = "uvicorn" }, @@ -1248,6 +1361,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.32.5" }, { name = "requests-cache", specifier = ">=1.2.1" }, { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.48.0" }, + { name = "shapely", specifier = ">=2.1.2" }, { name = "sqlalchemy", specifier = ">=2.0.36" }, { name = "sqlmodel", specifier = ">=0.0.8" }, { name = "uvicorn", specifier = ">=0.32.1" },