From 877bfec347624523518608241a8dd75d002fed02 Mon Sep 17 00:00:00 2001 From: MashB Date: Thu, 14 May 2026 23:45:59 +0530 Subject: [PATCH 01/26] open spec --- docs/tasking-mvp/tasking-mvp.openapi.yaml | 1825 +++++++++++++++++++++ 1 file changed, 1825 insertions(+) create mode 100644 docs/tasking-mvp/tasking-mvp.openapi.yaml diff --git a/docs/tasking-mvp/tasking-mvp.openapi.yaml b/docs/tasking-mvp/tasking-mvp.openapi.yaml new file mode 100644 index 0000000..5f6b0e9 --- /dev/null +++ b/docs/tasking-mvp/tasking-mvp.openapi.yaml @@ -0,0 +1,1825 @@ +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: / + description: workspaces-backend root. + +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 single previewed feature as a new task. + description: | + LEAD only. Commits one feature per call. The client iterates + the validated FeatureCollection and calls this endpoint once + per feature with a fresh `Idempotency-Key`. + + On each successful call the server: + 1. Re-validates the feature against the project AOI. + 2. Allocates the next sequential `taskNumber` (starts at 1). + 3. Inserts the `tasking_tasks` row. + 4. On the first feature, sets + `tasking_projects.task_boundary_type` to `source`. + Subsequent calls must use the same `source` — 409 + otherwise. + 5. Emits a `task_created` audit event. + + Project preconditions: status `draft`, AOI set. + + See the top-level idempotency contract for retry semantics. + operationId: saveTask + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SaveTaskRequest" } + responses: + "201": + description: Task created. + content: + application/json: + schema: { $ref: "#/components/schemas/SaveTaskResponse" } + "200": + description: | + Idempotent replay (same key + same body); `replayed: true`. + content: + application/json: + schema: { $ref: "#/components/schemas/SaveTaskResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: | + One of: + - "Task boundary source differs from a previous save". + - "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_remap_feedback`) | + + 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`). + + OSM validation: a `404` from `OSM_CHANGESET_API_URL` returns + `422` with detail "Invalid OSM changeset id"; nothing is + written. Network failure to OSM → `502`. + + `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" } + "502": + description: Could not reach the OSM Changeset API. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + # ========================================================================= + # Roles + # ========================================================================= + + /workspaces/{workspace_id}/roles: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + get: + tags: [roles] + summary: List all users associated with the workspace and their role. + description: | + Returns every user with a `user_workspace_roles` row OR a TDEI + role in the workspace's owning project group, each with the + role the server resolves for them. Any workspace contributor. + operationId: listWorkspaceRoles + responses: + "200": + description: Roles. + content: + application/json: + schema: { $ref: "#/components/schemas/WorkspaceRoleListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + + /workspaces/{workspace_id}/roles/{user_id}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/UserIdPath" + get: + tags: [roles] + summary: Get a user's workspace-level role. + operationId: getWorkspaceRole + responses: + "200": + description: Role. + content: + application/json: + schema: { $ref: "#/components/schemas/WorkspaceRoleEntry" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: Workspace not found, or user has no association. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + put: + tags: [roles] + summary: Set (upsert) the workspace-level role for a user. + description: | + LEAD only. Idempotent upsert into `user_workspace_roles`. + + Last-LEAD guard: the workspace must always retain at least one + LEAD (counting both `user_workspace_roles` rows and TDEI-derived + LEAD via `poc` / `osw_data_generator`). 422 if violated. + operationId: putWorkspaceRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RoleAssignmentBody" } + responses: + "200": + description: Upserted. + content: + application/json: + schema: { $ref: "#/components/schemas/WorkspaceRoleEntry" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + "422": + description: | + One of: + - "Cannot demote the last lead in this workspace". + - "User is not a member of the workspace's project group". + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + delete: + tags: [roles] + summary: Remove the workspace-level role row for a user. + description: | + LEAD only. Deletes the row from `user_workspace_roles`; the + user's role falls back to their TDEI-derived role. + operationId: deleteWorkspaceRole + responses: + "204": + description: Removed. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + description: No row exists for this user/workspace. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: Would leave the workspace without a LEAD. + 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 project-level role overrides on a project. + description: | + Returns rows in `tasking_project_roles`. Sparse — only users + with an explicit override appear. Any workspace contributor. + operationId: listProjectRoles + responses: + "200": + description: Overrides. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + + /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 user's project-level role. + description: Project override if set, otherwise the workspace-level role. + operationId: getProjectRole + responses: + "200": + description: Role. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleEntry" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + put: + tags: [roles] + summary: Set (upsert) a project-level role override. + description: | + LEAD only. Inserts/updates a row in `tasking_project_roles`. + Project must not be `done` (422 otherwise). User must be a + member of the workspace's project group (422 otherwise). + operationId: putProjectRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RoleAssignmentBody" } + responses: + "200": + description: Upserted. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleEntry" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": + description: | + One of: + - "User is not a member of the workspace's project group". + - "Project is closed". + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + delete: + tags: [roles] + summary: Clear a project-level role override. + description: LEAD only. Falls back to the workspace-level role. + operationId: deleteProjectRole + responses: + "204": + description: Removed. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + description: No override row for this user/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 `includeDeleted=true`. + operationId: listProjectAudit + parameters: + - in: query + name: eventType + schema: { $ref: "#/components/schemas/AuditEventType" } + - in: query + name: taskNumber + schema: { type: integer, minimum: 1 } + - in: query + name: actorUserId + schema: { type: string, format: uuid } + - in: query + name: occurredFrom + schema: { type: string, format: date-time } + - in: query + name: occurredTo + schema: { type: string, format: date-time } + - in: query + name: includeDeleted + schema: { type: boolean, default: false } + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: pageSize + schema: { type: integer, minimum: 1, maximum: 200, default: 50 } + - in: query + name: orderByType + 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: eventType + schema: { $ref: "#/components/schemas/AuditEventType" } + - in: query + name: actorUserId + schema: { type: string, format: uuid } + - in: query + name: occurredFrom + schema: { type: string, format: date-time } + - in: query + name: occurredTo + schema: { type: string, format: date-time } + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: pageSize + schema: { type: integer, minimum: 1, maximum: 200, default: 25 } + - in: query + name: orderByType + 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. + + RemapFeedbackReason: + type: string + enum: [incomplete_mapping, data_quality_issue, wrong_area, other] + + 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 + - remap_feedback + + # ---------- 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" + + SaveTaskRequest: + type: object + required: [source, feature] + properties: + source: + $ref: "#/components/schemas/GridSource" + description: First save sets `tasking_projects.task_boundary_type`; subsequent saves must use the same source. + feature: + $ref: "#/components/schemas/TaskBoundaryFeature" + + SaveTaskResponse: + type: object + required: [projectId, taskBoundaryType, task] + properties: + projectId: { type: integer, format: int64 } + taskBoundaryType: { $ref: "#/components/schemas/GridSource" } + task: { $ref: "#/components/schemas/Task" } + idempotencyKey: + type: string + nullable: true + replayed: + type: boolean + default: false + description: True on idempotent replay (HTTP 200 case). + + # ---------- Submit / Lock ---------- + + RemapFeedback: + type: object + required: [reasonCategory] + properties: + reasonCategory: { $ref: "#/components/schemas/RemapFeedbackReason" } + notes: + type: string + maxLength: 4000 + nullable: true + + 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/RemapFeedback" + description: Meaningful only in validator context on `to_review`. + + 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 } + + RoleAssignmentBody: + type: object + required: [role] + properties: + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + WorkspaceRoleEntry: + type: object + required: [user, role] + properties: + user: { $ref: "#/components/schemas/UserSummary" } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + WorkspaceRoleListResponse: + type: object + required: [results] + properties: + results: + type: array + items: { $ref: "#/components/schemas/WorkspaceRoleEntry" } + + ProjectRoleEntry: + type: object + required: [user, role] + properties: + user: { $ref: "#/components/schemas/UserSummary" } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + ProjectRoleListResponse: + type: object + required: [results] + properties: + results: + type: array + items: { $ref: "#/components/schemas/ProjectRoleEntry" } + + SelfProjectRolesItem: + type: object + required: [projectId, projectName, role] + properties: + projectId: { type: integer, format: int64 } + projectName: { type: string } + projectStatus: { $ref: "#/components/schemas/ProjectStatus" } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + SelfProjectRolesResponse: + type: object + required: [workspaceRole, projects] + properties: + workspaceRole: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + projects: + type: array + items: { $ref: "#/components/schemas/SelfProjectRolesItem" } + + # ---------- Audit ---------- + + ActorRef: + type: object + required: [userId] + properties: + userId: { type: string, format: uuid } + displayName: { type: string, nullable: true } + + AuditEvent: + type: object + required: [id, eventType, projectId, actor, occurredAt, details] + properties: + id: { type: integer, format: int64 } + eventType: { $ref: "#/components/schemas/AuditEventType" } + projectId: { type: integer, format: int64 } + taskId: + type: integer + format: int64 + nullable: true + taskNumber: + type: integer + nullable: true + description: Convenience copy from `details` (so list renderers don't peek inside JSONB). + actor: { $ref: "#/components/schemas/ActorRef" } + occurredAt: { type: string, format: date-time } + details: + type: object + additionalProperties: true + projectDeleted: + 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" } From 806d7e67d7704357863f27ff08028cbf94f1c797 Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 18 May 2026 20:31:10 +0530 Subject: [PATCH 02/26] DB schema --- docs/db-schema/db-schema.png | Bin 0 -> 547277 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/db-schema/db-schema.png diff --git a/docs/db-schema/db-schema.png b/docs/db-schema/db-schema.png new file mode 100644 index 0000000000000000000000000000000000000000..fbd1187f793cfe85b5dd1f2bb08a00c1a5db11d6 GIT binary patch literal 547277 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�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%0ARNxIF!a7pyp>6%wX@)iS>;8u)uJ+^B3>ZE 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
WHKFjvJQ`!xu0q^tt-iRI{$%l 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 Xjl93-_85#OdM#hFX7QDUI{*3~HFXqu literal 0 HcmV?d00001 From 7fa7619706c2749b22556166417e44c39684f0c9 Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 18 May 2026 20:51:56 +0530 Subject: [PATCH 03/26] updated feedback --- docs/tasking-mvp/tasking-mvp.openapi.yaml | 301 +++++++++++++--------- 1 file changed, 184 insertions(+), 117 deletions(-) diff --git a/docs/tasking-mvp/tasking-mvp.openapi.yaml b/docs/tasking-mvp/tasking-mvp.openapi.yaml index 5f6b0e9..bdb4e05 100644 --- a/docs/tasking-mvp/tasking-mvp.openapi.yaml +++ b/docs/tasking-mvp/tasking-mvp.openapi.yaml @@ -6,8 +6,8 @@ info: Tasking Manager API for the Workspaces Tasking Manager MVP. servers: - - url: / - description: workspaces-backend root. + - url: /api/v1 + description: workspaces-backend FastAPI app — all routers are mounted under `/api/v1` (see `api/main.py`). tags: - name: projects @@ -417,43 +417,53 @@ paths: - $ref: "#/components/parameters/IdempotencyKeyHeader" post: tags: [tasks] - summary: Persist a single previewed feature as a new task. + summary: Persist a validated FeatureCollection as the project's tasks (bulk). description: | - LEAD only. Commits one feature per call. The client iterates - the validated FeatureCollection and calls this endpoint once - per feature with a fresh `Idempotency-Key`. - - On each successful call the server: - 1. Re-validates the feature against the project AOI. - 2. Allocates the next sequential `taskNumber` (starts at 1). - 3. Inserts the `tasking_tasks` row. - 4. On the first feature, sets - `tasking_projects.task_boundary_type` to `source`. - Subsequent calls must use the same `source` — 409 - otherwise. - 5. Emits a `task_created` audit event. - - Project preconditions: status `draft`, AOI set. - - See the top-level idempotency contract for retry semantics. - operationId: saveTask + 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/SaveTaskRequest" } + schema: { $ref: "#/components/schemas/SaveTasksRequest" } responses: "201": - description: Task created. + description: Tasks created. content: application/json: - schema: { $ref: "#/components/schemas/SaveTaskResponse" } + schema: { $ref: "#/components/schemas/SaveTasksResponse" } "200": description: | Idempotent replay (same key + same body); `replayed: true`. content: application/json: - schema: { $ref: "#/components/schemas/SaveTaskResponse" } + schema: { $ref: "#/components/schemas/SaveTasksResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } @@ -461,7 +471,7 @@ paths: "409": description: | One of: - - "Task boundary source differs from a previous save". + - "Tasks already saved" — project already has tasks; re-upload AOI to start over. - "Idempotency key reused with a different request". content: application/json: @@ -715,15 +725,18 @@ paths: | `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_remap_feedback`) | + | `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`). - OSM validation: a `404` from `OSM_CHANGESET_API_URL` returns - `422` with detail "Invalid OSM changeset id"; nothing is - written. Network failure to OSM → `502`. + `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. @@ -755,108 +768,125 @@ paths: "404": { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } "422": { $ref: "#/components/responses/UnprocessableEntity" } - "502": - description: Could not reach the OSM Changeset API. - content: - application/json: - schema: { $ref: "#/components/schemas/Error" } # ========================================================================= # Roles # ========================================================================= - /workspaces/{workspace_id}/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 all users associated with the workspace and their role. + summary: List privileged workspace members (lead + validator role rows). description: | - Returns every user with a `user_workspace_roles` row OR a TDEI - role in the workspace's owning project group, each with the - role the server resolves for them. Any workspace contributor. - operationId: listWorkspaceRoles + 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: Roles. + description: Privileged members. content: application/json: - schema: { $ref: "#/components/schemas/WorkspaceRoleListResponse" } + schema: + type: array + items: { $ref: "#/components/schemas/WorkspaceUserRoleItem" } "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/WorkspaceNotFound" } + "403": + description: Caller is not a member of the workspace's project group. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } - /workspaces/{workspace_id}/roles/{user_id}: + /workspaces/{workspace_id}/users/{user_id}/role: parameters: - $ref: "#/components/parameters/WorkspaceIdPath" - $ref: "#/components/parameters/UserIdPath" - get: - tags: [roles] - summary: Get a user's workspace-level role. - operationId: getWorkspaceRole - responses: - "200": - description: Role. - content: - application/json: - schema: { $ref: "#/components/schemas/WorkspaceRoleEntry" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": - description: Workspace not found, or user has no association. - content: - application/json: - schema: { $ref: "#/components/schemas/Error" } put: tags: [roles] - summary: Set (upsert) the workspace-level role for a user. + summary: Assign (upsert) a privileged role for a user on a workspace. description: | LEAD only. Idempotent upsert into `user_workspace_roles`. - Last-LEAD guard: the workspace must always retain at least one - LEAD (counting both `user_workspace_roles` rows and TDEI-derived - LEAD via `poc` / `osw_data_generator`). 422 if violated. - operationId: putWorkspaceRole + 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/RoleAssignmentBody" } + schema: { $ref: "#/components/schemas/SetRoleRequest" } responses: - "200": - description: Upserted. - content: - application/json: - schema: { $ref: "#/components/schemas/WorkspaceRoleEntry" } + "204": + description: Role assigned (or updated). "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } - "404": { $ref: "#/components/responses/WorkspaceNotFound" } + "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: | - One of: - - "Cannot demote the last lead in this workspace". - - "User is not a member of the workspace's project group". + 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 the workspace-level role row for a user. + 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 their TDEI-derived role. - operationId: deleteWorkspaceRole + 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: Removed. + description: Role row removed. "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } - "404": - description: No row exists for this user/workspace. + "403": + description: Caller is not LEAD on this workspace. content: application/json: schema: { $ref: "#/components/schemas/Error" } - "422": - description: Would leave the workspace without a LEAD. + "404": + description: No role row exists for this user/workspace. content: application/json: schema: { $ref: "#/components/schemas/Error" } @@ -911,7 +941,7 @@ paths: required: true content: application/json: - schema: { $ref: "#/components/schemas/RoleAssignmentBody" } + schema: { $ref: "#/components/schemas/SetRoleRequest" } responses: "200": description: Upserted. @@ -1206,9 +1236,14 @@ components: `stale_timeout` — released by the background job (`expires_at < NOW()`). `reset` — released by `POST /reset` on the task or its project. - RemapFeedbackReason: + 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 @@ -1229,7 +1264,7 @@ components: - task_reset - project_reset - changeset_submitted - - remap_feedback + - feedback_submitted # ---------- GeoJSON ---------- @@ -1514,23 +1549,30 @@ components: featureCollection: $ref: "#/components/schemas/TaskBoundariesFeatureCollection" - SaveTaskRequest: + SaveTasksRequest: type: object - required: [source, feature] + required: [source, featureCollection] properties: source: $ref: "#/components/schemas/GridSource" - description: First save sets `tasking_projects.task_boundary_type`; subsequent saves must use the same source. - feature: - $ref: "#/components/schemas/TaskBoundaryFeature" + description: Sets `tasking_projects.task_boundary_type` for the project. + featureCollection: + $ref: "#/components/schemas/TaskBoundariesFeatureCollection" - SaveTaskResponse: + SaveTasksResponse: type: object - required: [projectId, taskBoundaryType, task] + required: [projectId, taskBoundaryType, taskCount, tasks] properties: projectId: { type: integer, format: int64 } taskBoundaryType: { $ref: "#/components/schemas/GridSource" } - task: { $ref: "#/components/schemas/Task" } + 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 @@ -1541,15 +1583,24 @@ components: # ---------- Submit / Lock ---------- - RemapFeedback: + Feedback: type: object - required: [reasonCategory] + 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: { $ref: "#/components/schemas/RemapFeedbackReason" } + 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 - nullable: true SubmitRequest: type: object @@ -1568,8 +1619,12 @@ components: lock_timeout_hours`. feedback: allOf: - - $ref: "#/components/schemas/RemapFeedback" - description: Meaningful only in validator context on `to_review`. + - $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 @@ -1598,27 +1653,39 @@ components: displayName: { type: string, nullable: true } email: { type: string, nullable: true } - RoleAssignmentBody: + 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/WorkspaceUserRoleType" } + role: { $ref: "#/components/schemas/AssignableRoleType" } - WorkspaceRoleEntry: + WorkspaceUserRoleItem: type: object - required: [user, role] + 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: - user: { $ref: "#/components/schemas/UserSummary" } + 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" } - WorkspaceRoleListResponse: - type: object - required: [results] - properties: - results: - type: array - items: { $ref: "#/components/schemas/WorkspaceRoleEntry" } - ProjectRoleEntry: type: object required: [user, role] From cab0cf0e1df6265e5dceec3e5479e025bca13fa0 Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 18 May 2026 21:03:23 +0530 Subject: [PATCH 04/26] spec in json format --- docs/tasking-mvp/tasking-mvp.openapi.json | 3157 +++++++++++++++++++++ 1 file changed, 3157 insertions(+) create mode 100644 docs/tasking-mvp/tasking-mvp.openapi.json diff --git a/docs/tasking-mvp/tasking-mvp.openapi.json b/docs/tasking-mvp/tasking-mvp.openapi.json new file mode 100644 index 0000000..275acc8 --- /dev/null +++ b/docs/tasking-mvp/tasking-mvp.openapi.json @@ -0,0 +1,3157 @@ +{ + "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 project-level role overrides on a project.", + "description": "Returns rows in `tasking_project_roles`. Sparse \u2014 only users\nwith an explicit override appear. Any workspace contributor.\n", + "operationId": "listProjectRoles", + "responses": { + "200": { + "description": "Overrides.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + } + }, + "/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 user's project-level role.", + "description": "Project override if set, otherwise the workspace-level role.", + "operationId": "getProjectRole", + "responses": { + "200": { + "description": "Role.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleEntry" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + }, + "put": { + "tags": [ + "roles" + ], + "summary": "Set (upsert) a project-level role override.", + "description": "LEAD only. Inserts/updates a row in `tasking_project_roles`.\nProject must not be `done` (422 otherwise). User must be a\nmember of the workspace's project group (422 otherwise).\n", + "operationId": "putProjectRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetRoleRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Upserted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleEntry" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "description": "One of:\n - \"User is not a member of the workspace's project group\".\n - \"Project is closed\".\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "roles" + ], + "summary": "Clear a project-level role override.", + "description": "LEAD only. Falls back to the workspace-level role.", + "operationId": "deleteProjectRole", + "responses": { + "204": { + "description": "Removed." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "description": "No override row for this user/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 `includeDeleted=true`.\n", + "operationId": "listProjectAudit", + "parameters": [ + { + "in": "query", + "name": "eventType", + "schema": { + "$ref": "#/components/schemas/AuditEventType" + } + }, + { + "in": "query", + "name": "taskNumber", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "in": "query", + "name": "actorUserId", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "occurredFrom", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "occurredTo", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "includeDeleted", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "in": "query", + "name": "orderByType", + "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": "eventType", + "schema": { + "$ref": "#/components/schemas/AuditEventType" + } + }, + { + "in": "query", + "name": "actorUserId", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "occurredFrom", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "occurredTo", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 25 + } + }, + { + "in": "query", + "name": "orderByType", + "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" + } + } + }, + "ProjectRoleEntry": { + "type": "object", + "required": [ + "user", + "role" + ], + "properties": { + "user": { + "$ref": "#/components/schemas/UserSummary" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "ProjectRoleListResponse": { + "type": "object", + "required": [ + "results" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectRoleEntry" + } + } + } + }, + "SelfProjectRolesItem": { + "type": "object", + "required": [ + "projectId", + "projectName", + "role" + ], + "properties": { + "projectId": { + "type": "integer", + "format": "int64" + }, + "projectName": { + "type": "string" + }, + "projectStatus": { + "$ref": "#/components/schemas/ProjectStatus" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "SelfProjectRolesResponse": { + "type": "object", + "required": [ + "workspaceRole", + "projects" + ], + "properties": { + "workspaceRole": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelfProjectRolesItem" + } + } + } + }, + "ActorRef": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "displayName": { + "type": "string", + "nullable": true + } + } + }, + "AuditEvent": { + "type": "object", + "required": [ + "id", + "eventType", + "projectId", + "actor", + "occurredAt", + "details" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "eventType": { + "$ref": "#/components/schemas/AuditEventType" + }, + "projectId": { + "type": "integer", + "format": "int64" + }, + "taskId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "taskNumber": { + "type": "integer", + "nullable": true, + "description": "Convenience copy from `details` (so list renderers don't peek inside JSONB)." + }, + "actor": { + "$ref": "#/components/schemas/ActorRef" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "details": { + "type": "object", + "additionalProperties": true + }, + "projectDeleted": { + "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 From 1625f36adfa7c937f57f236af628f1f87f73eccc Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 25 May 2026 00:20:33 +0530 Subject: [PATCH 05/26] project, aoi, tasks, lock apis, unit & integration tests --- .gitignore | 7 +- .vscode/extensions.json | 6 + .../a1b2c3d4e5f6_tasking_mvp_schema.py | 564 +++++++++ .../c5121cbba124_initial_task_schema.py | 112 ++ api/src/tasking/__init__.py | 0 api/src/tasking/projects/__init__.py | 0 api/src/tasking/projects/dtos.py | 144 +++ api/src/tasking/projects/repository.py | 764 ++++++++++++ api/src/tasking/projects/routes.py | 234 ++++ api/src/tasking/projects/schemas.py | 142 +++ api/src/tasking/tasks/__init__.py | 0 api/src/tasking/tasks/dtos.py | 145 +++ api/src/tasking/tasks/repository.py | 1101 +++++++++++++++++ api/src/tasking/tasks/routes.py | 259 ++++ api/src/tasking/tasks/schemas.py | 197 +++ tests/conftest.py | 242 ++++ tests/integration/__init__.py | 0 tests/integration/conftest.py | 510 ++++++++ tests/integration/test_projects_flow.py | 322 +++++ tests/integration/test_tasks_flow.py | 937 ++++++++++++++ tests/unit/__init__.py | 0 tests/unit/conftest.py | 286 +++++ tests/unit/test_aoi_normalisation.py | 74 ++ tests/unit/test_dtos_validation.py | 99 ++ tests/unit/test_project_routes.py | 244 ++++ tests/unit/test_user_info_gates.py | 85 ++ 26 files changed, 6473 insertions(+), 1 deletion(-) create mode 100644 .vscode/extensions.json create mode 100644 alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py create mode 100644 alembic_task/versions/c5121cbba124_initial_task_schema.py create mode 100644 api/src/tasking/__init__.py create mode 100644 api/src/tasking/projects/__init__.py create mode 100644 api/src/tasking/projects/dtos.py create mode 100644 api/src/tasking/projects/repository.py create mode 100644 api/src/tasking/projects/routes.py create mode 100644 api/src/tasking/projects/schemas.py create mode 100644 api/src/tasking/tasks/__init__.py create mode 100644 api/src/tasking/tasks/dtos.py create mode 100644 api/src/tasking/tasks/repository.py create mode 100644 api/src/tasking/tasks/routes.py create mode 100644 api/src/tasking/tasks/schemas.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_projects_flow.py create mode 100644 tests/integration/test_tasks_flow.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/conftest.py create mode 100644 tests/unit/test_aoi_normalisation.py create mode 100644 tests/unit/test_dtos_validation.py create mode 100644 tests/unit/test_project_routes.py create mode 100644 tests/unit/test_user_info_gates.py diff --git a/.gitignore b/.gitignore index 42694c4..cf7489e 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,9 @@ 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 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/a1b2c3d4e5f6_tasking_mvp_schema.py b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py new file mode 100644 index 0000000..8606ce9 --- /dev/null +++ b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py @@ -0,0 +1,564 @@ + + +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 _postgis_available(bind) -> bool: + return bool( + bind.execute( + text("SELECT 1 FROM pg_available_extensions WHERE name = 'postgis'") + ).scalar() + ) + + +def upgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + use_postgis = _postgis_available(bind) + if use_postgis: + op.execute("CREATE EXTENSION IF NOT EXISTS postgis") + + # ---- 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), + ) + + if use_postgis: + 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" + ), + ) + if use_postgis: + 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)" + ) + else: + op.execute( + "ALTER TABLE tasking_tasks " + "ADD COLUMN geometry BYTEA" + ) + 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/c5121cbba124_initial_task_schema.py b/alembic_task/versions/c5121cbba124_initial_task_schema.py new file mode 100644 index 0000000..ef9befa --- /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 _postgis_available(bind) -> bool: + return bool( + bind.execute( + text("SELECT 1 FROM pg_available_extensions WHERE name = 'postgis'") + ).scalar() + ) + + +def upgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + use_postgis = _postgis_available(bind) + if use_postgis: + op.execute("CREATE EXTENSION IF NOT EXISTS postgis") + + geometry_column = ( + sa.Column( + "geometry", + Geometry(geometry_type="MULTIPOLYGON", srid=4326), + nullable=True, + ) + if use_postgis + else sa.Column("geometry", sa.Text(), 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/src/tasking/__init__.py b/api/src/tasking/__init__.py new file mode 100644 index 0000000..e69de29 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..c2c266f --- /dev/null +++ b/api/src/tasking/projects/dtos.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal, Optional +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field as PydField, field_validator + +from api.src.tasking.projects.schemas import ( + AoiInput, + ProjectStatus, + TaskBoundaryType, + _MultiPolygon, +) + + +# --------------------------------------------------------------------------- +# Shared DTO base. +# --------------------------------------------------------------------------- + + +class WireModel(BaseModel): + """Common base for request and response DTOs. + + `extra="forbid"` rejects unknown keys on input bodies with a 422 so + callers get explicit feedback when they misspell a field or send a + property the endpoint does not accept (e.g. `aoi` on PATCH project, + which has its own sub-resource at `/aoi`). Responses are unaffected + because Pydantic only enforces `extra` during input validation. + """ + + 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) + + +__all__ = [ + "AoiFeature", + "Pagination", + "ProjectCreateRequest", + "ProjectListItem", + "ProjectListResponse", + "ProjectResponse", + "ProjectRoleAssignment", + "ProjectUpdateRequest", + "WireModel", +] diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py new file mode 100644 index 0000000..1a1c6bd --- /dev/null +++ b/api/src/tasking/projects/repository.py @@ -0,0 +1,764 @@ +from __future__ import annotations + +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, 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.projects.dtos import ( + AoiFeature, + Pagination, + ProjectCreateRequest, + ProjectListItem, + ProjectListResponse, + ProjectResponse, + ProjectUpdateRequest, +) +from api.src.tasking.projects.schemas import ( + AoiInput, + 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 + + # ---- 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 _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, + ) -> 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. Returns a 422 listing the + # missing ids instead of a generic FK violation. + 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: + # Signed-in caller is not yet provisioned in `users`; + # distinct from a bad role_assignments entry. + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "Your user record has not been provisioned yet. " + "Sign in to Workspaces once to create your `users` " + "row, then retry." + ), + ) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "One or more `role_assignments[].user_id` values " + "refer to a user that has not signed in to " + "Workspaces yet — no `users` row exists." + ), + "missing_user_ids": 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.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, + ) -> 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.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) -> 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}, + ) + 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 + ) -> 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.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 + ) -> 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.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 + ) -> 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.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 + ) -> 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.session.commit() + return _shapely_to_aoi_feature(geom) + + async def delete_aoi(self, workspace_id: int, project_id: int) -> 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.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..cd699ce --- /dev/null +++ b/api/src/tasking/projects/routes.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Body, Depends, HTTPException, Query, status +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.security import UserInfo, validate_token +from api.src.tasking.projects.repository import TaskingProjectRepository +from api.src.tasking.projects.dtos import ( + AoiFeature, + ProjectCreateRequest, + ProjectListResponse, + ProjectResponse, + ProjectUpdateRequest, +) +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"], +) + + +# --------------------------------------------------------------------------- +# 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), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.create(workspace_id, current_user, body) + + +@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) + + +@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) + + +@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) + + +@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) + + +@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) + + +# --------------------------------------------------------------------------- +# 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) + + +@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) diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py new file mode 100644 index 0000000..18cde11 --- /dev/null +++ b/api/src/tasking/projects/schemas.py @@ -0,0 +1,142 @@ +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, Field as PydField +from sqlalchemy import Column, 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) + + # Cross-DB reference to workspaces.id; no FK by design, matching + # the existing `user_workspace_roles` convention. + workspace_id: int = Field(nullable=False, index=True) + + name: str = Field(max_length=255, nullable=False) + instructions: Optional[str] = None + + # Bind to the Postgres enum from the migration. `name=` and + # `values_callable` are required so SQLAlchemy uses the existing + # `tasking_project_status` type (lowercase values) instead of + # auto-generating a new one keyed by member names. + 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 + + +# --------------------------------------------------------------------------- +# 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", + "ProjectStatus", + "TaskBoundaryType", + "TaskingProject", + "_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..edfec34 --- /dev/null +++ b/api/src/tasking/tasks/dtos.py @@ -0,0 +1,145 @@ +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 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..ab4ee13 --- /dev/null +++ b/api/src/tasking/tasks/repository.py @@ -0,0 +1,1101 @@ +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.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 + + +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: str, + 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_geom.covers(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_geom.covers(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(), + ) + ) + + # Audit one row per task. + for t in created: + await self._audit( + event_type="task_created", + project_id=project.id, # type: ignore[arg-type] + task_id=t.id, + actor_uuid=current_user.user_uuid, + details={"taskNumber": t.task_number}, + ) + + 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) + + # ---- 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="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="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="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="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="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="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="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="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="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="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="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) + + +__all__ = ["TaskingTaskRepository"] diff --git a/api/src/tasking/tasks/routes.py b/api/src/tasking/tasks/routes.py new file mode 100644 index 0000000..1d38565 --- /dev/null +++ b/api/src/tasking/tasks/routes.py @@ -0,0 +1,259 @@ +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, + 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) + + +# --------------------------------------------------------------------------- +# 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 + ) diff --git a/api/src/tasking/tasks/schemas.py b/api/src/tasking/tasks/schemas.py new file mode 100644 index 0000000..40e5799 --- /dev/null +++ b/api/src/tasking/tasks/schemas.py @@ -0,0 +1,197 @@ + +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, 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/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a79b530 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,242 @@ + +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) + + +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 '—'}") + + +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..b7f0bf5 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,510 @@ + +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 _make_user, SEED_PROJECT_GROUP_ID + + _, 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 _make_user, SEED_PROJECT_GROUP_ID + + _, 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 _make_user, SEED_PROJECT_GROUP_ID + + _, 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 _make_user, SEED_PROJECT_GROUP_ID + + _, 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 + + +# --------------------------------------------------------------------------- +# 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_projects_flow.py b/tests/integration/test_projects_flow.py new file mode 100644 index 0000000..c7ec969 --- /dev/null +++ b/tests/integration/test_projects_flow.py @@ -0,0 +1,322 @@ + +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 + ): + """`role_assignments` with a uuid that has no `users` row → 422 + missing list, not a 409 / 500.""" + 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 "users" in body["detail"]["message"].lower() + + 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 diff --git a/tests/integration/test_tasks_flow.py b/tests/integration/test_tasks_flow.py new file mode 100644 index 0000000..1cf712a --- /dev/null +++ b/tests/integration/test_tasks_flow.py @@ -0,0 +1,937 @@ + + +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={"osm_changeset_id": 1001, "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={"osm_changeset_id": 1002, "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={"osm_changeset_id": 5001, "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={"osm_changeset_id": 7001, "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={ + "osm_changeset_id": 7002, + "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={"osm_changeset_id": 9001, "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={"osm_changeset_id": 11001, "done": True}, + ) + # Validator picks it up. + override_user(validator) + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + + # Switch back to a LEAD token to invoke /reset. The integration + # `as_lead` fixture already inserted a lead users row, so the + # helper here just builds a UserInfo to bind to the override. + from api.core.security import validate_token + from api.main import app + from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + + 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..8dc9ca8 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,286 @@ + +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") + return SimpleNamespace(id=workspace_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): + 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): + 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): + await self.get(workspace_id, project_id) + self._projects[project_id]["deleted_at"] = datetime.now() + + async def activate(self, workspace_id, project_id): + 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): + 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): + 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): + 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): + 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..19d54a8 --- /dev/null +++ b/tests/unit/test_aoi_normalisation.py @@ -0,0 +1,74 @@ + + +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..b7395fd --- /dev/null +++ b/tests/unit/test_dtos_validation.py @@ -0,0 +1,99 @@ + + +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..86987ac --- /dev/null +++ b/tests/unit/test_project_routes.py @@ -0,0 +1,244 @@ + +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..dcce1a4 --- /dev/null +++ b/tests/unit/test_user_info_gates.py @@ -0,0 +1,85 @@ + + +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 From 4827934730ddf5d912c439ad13b5e06f6f6f61ff Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 25 May 2026 00:25:02 +0530 Subject: [PATCH 06/26] remove unwanted notes --- api/src/tasking/projects/dtos.py | 10 +--------- api/src/tasking/projects/schemas.py | 6 ------ 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py index c2c266f..286ef32 100644 --- a/api/src/tasking/projects/dtos.py +++ b/api/src/tasking/projects/dtos.py @@ -20,15 +20,7 @@ class WireModel(BaseModel): - """Common base for request and response DTOs. - - `extra="forbid"` rejects unknown keys on input bodies with a 422 so - callers get explicit feedback when they misspell a field or send a - property the endpoint does not accept (e.g. `aoi` on PATCH project, - which has its own sub-resource at `/aoi`). Responses are unaffected - because Pydantic only enforces `extra` during input validation. - """ - + model_config = ConfigDict(extra="forbid") diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py index 18cde11..829c142 100644 --- a/api/src/tasking/projects/schemas.py +++ b/api/src/tasking/projects/schemas.py @@ -39,17 +39,11 @@ class TaskingProject(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) - # Cross-DB reference to workspaces.id; no FK by design, matching - # the existing `user_workspace_roles` convention. workspace_id: int = Field(nullable=False, index=True) name: str = Field(max_length=255, nullable=False) instructions: Optional[str] = None - # Bind to the Postgres enum from the migration. `name=` and - # `values_callable` are required so SQLAlchemy uses the existing - # `tasking_project_status` type (lowercase values) instead of - # auto-generating a new one keyed by member names. status: ProjectStatus = Field( default=ProjectStatus.DRAFT, sa_column=Column( From 916dcfa83990033597c610c956845e00b1da48c2 Mon Sep 17 00:00:00 2001 From: MashB Date: Tue, 26 May 2026 15:51:14 +0530 Subject: [PATCH 07/26] env setup issue --- .../9221408912dd_add_user_role_table.py | 6 +- alembic_task/versions/add6266277c7_.py | 2 +- pyproject.toml | 3 +- uv.lock | 114 ++++++++++++++++++ 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/alembic_osm/versions/9221408912dd_add_user_role_table.py b/alembic_osm/versions/9221408912dd_add_user_role_table.py index 3963de9..53e94f2 100644 --- a/alembic_osm/versions/9221408912dd_add_user_role_table.py +++ b/alembic_osm/versions/9221408912dd_add_user_role_table.py @@ -11,6 +11,8 @@ 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" @@ -45,11 +47,11 @@ def upgrade() -> None: if not insp.has_table("user_workspace_roles"): op.create_table( "user_workspace_roles", - sa.Column("user_auth_uid", sa.Uuid(), nullable=False), + sa.Column("user_auth_uid", sa.String(), nullable=False), sa.Column("workspace_id", sa.BigInteger(), nullable=False), sa.Column( "role", - sa.Enum( + postgresql.ENUM( "lead", "validator", "contributor", 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/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/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" }, From fcfa8b1953fc3a3e5e97d2d0f6ef6dc72e31ae15 Mon Sep 17 00:00:00 2001 From: MashB Date: Wed, 27 May 2026 17:59:10 +0530 Subject: [PATCH 08/26] AOI fix --- api/src/tasking/tasks/repository.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index ab4ee13..bb6cf41 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -108,6 +108,29 @@ 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, @@ -430,7 +453,7 @@ async def validate( warnings: list[ValidateWarning] = [] for idx, feat in enumerate(fc.features): poly = _polygon_to_shapely(feat.geometry.model_dump()) - if not aoi_geom.covers(poly): + 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", @@ -523,7 +546,7 @@ async def save( created: list[TaskingTask] = [] for idx, feat in enumerate(body.feature_collection.features): poly = _polygon_to_shapely(feat.geometry.model_dump()) - if not aoi_geom.covers(poly): + 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", From baa60cb1b734e56c531e07143db38b2cdaed738a Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 1 Jun 2026 19:13:01 +0530 Subject: [PATCH 09/26] project roles apis --- .../9221408912dd_add_user_role_table.py | 21 + api/main.py | 20 + api/src/tasking/projects/dtos.py | 60 +++ api/src/tasking/projects/repository.py | 428 +++++++++++++++++ api/src/tasking/projects/routes.py | 174 ++++++- api/src/tasking/projects/schemas.py | 44 ++ docs/tasking-mvp/tasking-mvp.openapi.json | 273 +++++++++-- docs/tasking-mvp/tasking-mvp.openapi.yaml | 209 +++++++-- tests/conftest.py | 26 +- tests/integration/test_projects_flow.py | 442 ++++++++++++++++++ 10 files changed, 1616 insertions(+), 81 deletions(-) diff --git a/alembic_osm/versions/9221408912dd_add_user_role_table.py b/alembic_osm/versions/9221408912dd_add_user_role_table.py index 53e94f2..640453e 100644 --- a/alembic_osm/versions/9221408912dd_add_user_role_table.py +++ b/alembic_osm/versions/9221408912dd_add_user_role_table.py @@ -26,6 +26,27 @@ 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'") diff --git a/api/main.py b/api/main.py index d218e44..87a95bb 100644 --- a/api/main.py +++ b/api/main.py @@ -22,6 +22,9 @@ init_tdei_client, validate_token, ) +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 +94,9 @@ 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.get("/health") @@ -210,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/projects/dtos.py b/api/src/tasking/projects/dtos.py index 286ef32..ef8848f 100644 --- a/api/src/tasking/projects/dtos.py +++ b/api/src/tasking/projects/dtos.py @@ -8,6 +8,7 @@ from api.src.tasking.projects.schemas import ( AoiInput, + ProjectRoleType, ProjectStatus, TaskBoundaryType, _MultiPolygon, @@ -123,6 +124,59 @@ class AoiFeature(WireModel): 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", @@ -130,7 +184,13 @@ class AoiFeature(WireModel): "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 index 1a1c6bd..819203e 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -26,10 +26,17 @@ ProjectListItem, ProjectListResponse, ProjectResponse, + ProjectRoleAddRequest, + ProjectRoleItem, + ProjectRoleListResponse, + ProjectRoleUpdateRequest, ProjectUpdateRequest, + SelfProjectRoleItem, + SelfProjectRolesResponse, ) from api.src.tasking.projects.schemas import ( AoiInput, + ProjectRoleType, ProjectStatus, TaskingProject, _Feature, @@ -733,6 +740,427 @@ async def upload_aoi( 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) -> None: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.DRAFT: diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py index cd699ce..6f11a06 100644 --- a/api/src/tasking/projects/routes.py +++ b/api/src/tasking/projects/routes.py @@ -2,7 +2,7 @@ from typing import Annotated -from fastapi import APIRouter, Body, Depends, HTTPException, Query, status +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 @@ -13,12 +13,18 @@ ProjectCreateRequest, ProjectListResponse, ProjectResponse, + ProjectRoleAddRequest, + ProjectRoleItem, + ProjectRoleListResponse, + ProjectRoleUpdateRequest, ProjectUpdateRequest, + SelfProjectRolesResponse, ) from api.src.tasking.projects.schemas import ( AoiInput, ProjectStatus, ) +from uuid import UUID from api.src.workspaces.repository import WorkspaceRepository router = APIRouter( @@ -26,6 +32,13 @@ 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 @@ -221,6 +234,134 @@ async def upload_project_aoi( return await project_repo.upload_aoi(workspace_id, project_id, body) +# --------------------------------------------------------------------------- +# 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, @@ -232,3 +373,34 @@ async def delete_project_aoi( 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) + + +# --------------------------------------------------------------------------- +# /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 index 829c142..f0b4695 100644 --- a/api/src/tasking/projects/schemas.py +++ b/api/src/tasking/projects/schemas.py @@ -94,6 +94,48 @@ class TaskingProject(SQLModel, table=True): 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. @@ -126,9 +168,11 @@ class _FeatureCollection(BaseModel): __all__ = [ "AoiInput", + "ProjectRoleType", "ProjectStatus", "TaskBoundaryType", "TaskingProject", + "TaskingProjectRole", "_Feature", "_FeatureCollection", "_MultiPolygon", diff --git a/docs/tasking-mvp/tasking-mvp.openapi.json b/docs/tasking-mvp/tasking-mvp.openapi.json index 275acc8..6c003b8 100644 --- a/docs/tasking-mvp/tasking-mvp.openapi.json +++ b/docs/tasking-mvp/tasking-mvp.openapi.json @@ -1306,12 +1306,12 @@ "tags": [ "roles" ], - "summary": "List project-level role overrides on a project.", - "description": "Returns rows in `tasking_project_roles`. Sparse \u2014 only users\nwith an explicit override appear. Any workspace contributor.\n", + "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": "Overrides.", + "description": "All role assignments.", "content": { "application/json": { "schema": { @@ -1327,6 +1327,82 @@ "$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}": { @@ -1345,16 +1421,16 @@ "tags": [ "roles" ], - "summary": "Get a user's project-level role.", - "description": "Project override if set, otherwise the workspace-level role.", + "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.", + "description": "Role assignment.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectRoleEntry" + "$ref": "#/components/schemas/ProjectRoleItem" } } } @@ -1363,7 +1439,14 @@ "$ref": "#/components/responses/Unauthorized" }, "404": { - "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + "description": "User has no role on this project, or project not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } }, @@ -1371,32 +1454,39 @@ "tags": [ "roles" ], - "summary": "Set (upsert) a project-level role override.", - "description": "LEAD only. Inserts/updates a row in `tasking_project_roles`.\nProject must not be `done` (422 otherwise). User must be a\nmember of the workspace's project group (422 otherwise).\n", + "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/SetRoleRequest" + "$ref": "#/components/schemas/ProjectRoleUpdateRequest" } } } }, "responses": { "200": { - "description": "Upserted.", + "description": "Existing assignment updated.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectRoleEntry" + "$ref": "#/components/schemas/ProjectRoleItem" } } } }, - "400": { - "$ref": "#/components/responses/BadRequest" + "201": { + "description": "New assignment created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } }, "401": { "$ref": "#/components/responses/Unauthorized" @@ -1408,7 +1498,63 @@ "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" }, "422": { - "description": "One of:\n - \"User is not a member of the workspace's project group\".\n - \"Project is closed\".\n", + "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": { @@ -1423,12 +1569,12 @@ "tags": [ "roles" ], - "summary": "Clear a project-level role override.", - "description": "LEAD only. Falls back to the workspace-level role.", - "operationId": "deleteProjectRole", + "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": "Removed." + "description": "Role removed." }, "401": { "$ref": "#/components/responses/Unauthorized" @@ -1437,7 +1583,17 @@ "$ref": "#/components/responses/ForbiddenLeadRequired" }, "404": { - "description": "No override row for this user/project.", + "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": { @@ -2709,18 +2865,31 @@ } } }, - "ProjectRoleEntry": { + "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", - "role" + "user_id", + "role", + "updated_at" ], "properties": { - "user": { - "$ref": "#/components/schemas/UserSummary" + "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" } } }, @@ -2733,27 +2902,59 @@ "results": { "type": "array", "items": { - "$ref": "#/components/schemas/ProjectRoleEntry" + "$ref": "#/components/schemas/ProjectRoleItem" } } } }, - "SelfProjectRolesItem": { + "ProjectRoleAddRequest": { "type": "object", + "description": "Body for `POST /projects/{pid}/roles`.", "required": [ - "projectId", - "projectName", + "user_id", "role" ], + "additionalProperties": false, "properties": { - "projectId": { + "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" }, - "projectName": { + "project_name": { "type": "string" }, - "projectStatus": { + "project_status": { "$ref": "#/components/schemas/ProjectStatus" }, "role": { @@ -2764,17 +2965,17 @@ "SelfProjectRolesResponse": { "type": "object", "required": [ - "workspaceRole", + "workspace_role", "projects" ], "properties": { - "workspaceRole": { + "workspace_role": { "$ref": "#/components/schemas/WorkspaceUserRoleType" }, "projects": { "type": "array", "items": { - "$ref": "#/components/schemas/SelfProjectRolesItem" + "$ref": "#/components/schemas/SelfProjectRoleItem" } } } diff --git a/docs/tasking-mvp/tasking-mvp.openapi.yaml b/docs/tasking-mvp/tasking-mvp.openapi.yaml index bdb4e05..6eb502b 100644 --- a/docs/tasking-mvp/tasking-mvp.openapi.yaml +++ b/docs/tasking-mvp/tasking-mvp.openapi.yaml @@ -897,19 +897,65 @@ paths: - $ref: "#/components/parameters/ProjectIdPath" get: tags: [roles] - summary: List project-level role overrides on a project. + summary: List role assignments on a project. description: | - Returns rows in `tasking_project_roles`. Sparse — only users - with an explicit override appear. Any workspace contributor. + 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: Overrides. + 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: @@ -918,60 +964,120 @@ paths: - $ref: "#/components/parameters/UserIdPath" get: tags: [roles] - summary: Get a user's project-level role. - description: Project override if set, otherwise the workspace-level role. + 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. + description: Role assignment. content: application/json: - schema: { $ref: "#/components/schemas/ProjectRoleEntry" } + schema: { $ref: "#/components/schemas/ProjectRoleItem" } "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "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: Set (upsert) a project-level role override. + summary: Upsert a user's role on a project. description: | - LEAD only. Inserts/updates a row in `tasking_project_roles`. - Project must not be `done` (422 otherwise). User must be a - member of the workspace's project group (422 otherwise). + 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/SetRoleRequest" } + schema: { $ref: "#/components/schemas/ProjectRoleUpdateRequest" } responses: "200": - description: Upserted. + description: Existing assignment updated. content: application/json: - schema: { $ref: "#/components/schemas/ProjectRoleEntry" } - "400": { $ref: "#/components/responses/BadRequest" } + 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 is not a member of the workspace's project group". - - "Project is closed". + - `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: Clear a project-level role override. - description: LEAD only. Falls back to the workspace-level role. - operationId: deleteProjectRole + 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: Removed. + description: Role removed. "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } "404": - description: No override row for this user/project. + 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" } @@ -1686,12 +1792,24 @@ components: display_name: { type: string } role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } - ProjectRoleEntry: + ProjectRoleItem: type: object - required: [user, role] + 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: { $ref: "#/components/schemas/UserSummary" } + 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 @@ -1699,25 +1817,44 @@ components: properties: results: type: array - items: { $ref: "#/components/schemas/ProjectRoleEntry" } + items: { $ref: "#/components/schemas/ProjectRoleItem" } - SelfProjectRolesItem: + ProjectRoleAddRequest: type: object - required: [projectId, projectName, role] + description: Body for `POST /projects/{pid}/roles`. + required: [user_id, role] + additionalProperties: false properties: - projectId: { type: integer, format: int64 } - projectName: { type: string } - projectStatus: { $ref: "#/components/schemas/ProjectStatus" } + 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: [workspaceRole, projects] + required: [workspace_role, projects] properties: - workspaceRole: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + workspace_role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } projects: type: array - items: { $ref: "#/components/schemas/SelfProjectRolesItem" } + items: { $ref: "#/components/schemas/SelfProjectRoleItem" } # ---------- Audit ---------- diff --git a/tests/conftest.py b/tests/conftest.py index a79b530..fbbf416 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -60,14 +60,24 @@ def pytest_runtest_makereport(item, call): report.description = _test_description(item) -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 '—'}") +# 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: diff --git a/tests/integration/test_projects_flow.py b/tests/integration/test_projects_flow.py index c7ec969..58b0b3f 100644 --- a/tests/integration/test_projects_flow.py +++ b/tests/integration/test_projects_flow.py @@ -320,3 +320,445 @@ async def test_aoi_delete_round_trip( 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 From 414da7a69e168a936b1ce035fc3e9170da16c03d Mon Sep 17 00:00:00 2001 From: MashB Date: Tue, 2 Jun 2026 20:42:55 +0530 Subject: [PATCH 10/26] Project roles integration --- .../requirements/project-roles-integration.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/requirements/project-roles-integration.md 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 From ede4fee075e534afb94cd2d9114914dfce2f4a0e Mon Sep 17 00:00:00 2001 From: MashB Date: Thu, 4 Jun 2026 17:07:57 +0530 Subject: [PATCH 11/26] project role assignment verification --- api/core/security.py | 100 +++++++++++++++++++++ api/src/tasking/projects/repository.py | 110 ++++++++++++++++++++---- api/src/tasking/projects/routes.py | 12 ++- tests/integration/conftest.py | 33 +++++++ tests/integration/test_projects_flow.py | 42 ++++++++- tests/unit/conftest.py | 17 +++- 6 files changed, 290 insertions(+), 24 deletions(-) diff --git a/api/core/security.py b/api/core/security.py index c283af4..9919699 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -43,6 +43,106 @@ 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/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 819203e..a19089a 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -219,6 +219,63 @@ def _to_response(project: TaskingProject, task_count: int = 0) -> ProjectRespons 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 api.core.security import fetch_project_group_users + from sqlalchemy import text + + 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]: @@ -359,38 +416,55 @@ async def create( 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. Returns a 422 listing the - # missing ids instead of a generic FK violation. + # 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: - # Signed-in caller is not yet provisioned in `users`; - # distinct from a bad role_assignments entry. + # 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 has not been provisioned yet. " - "Sign in to Workspaces once to create your `users` " - "row, then retry." + "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." ), ) - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "message": ( - "One or more `role_assignments[].user_id` values " - "refer to a user that has not signed in to " - "Workspaces yet — no `users` row exists." - ), - "missing_user_ids": missing, - }, - ) + 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, diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py index 6f11a06..81f06f4 100644 --- a/api/src/tasking/projects/routes.py +++ b/api/src/tasking/projects/routes.py @@ -120,9 +120,17 @@ async def create_project( workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), project_repo: TaskingProjectRepository = Depends(get_project_repo), ): - await assert_workspace_visible(workspace_id, current_user, workspace_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) + return await project_repo.create( + workspace_id, + current_user, + body, + tdei_project_group_id=str(workspace.tdeiProjectGroupId), + ) @router.get("/{project_id}", response_model=ProjectResponse) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b7f0bf5..c56b092 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -479,6 +479,39 @@ async def _make(role: str | None): 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). # --------------------------------------------------------------------------- diff --git a/tests/integration/test_projects_flow.py b/tests/integration/test_projects_flow.py index 58b0b3f..f9a57cd 100644 --- a/tests/integration/test_projects_flow.py +++ b/tests/integration/test_projects_flow.py @@ -220,7 +220,7 @@ class TestProjectCreateErrors: async def test_role_assignment_with_unknown_user_returns_422( self, client, as_lead, seeded_workspace_id ): - """`role_assignments` with a uuid that has no `users` row → 422 + missing list, not a 409 / 500.""" + """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), @@ -236,7 +236,45 @@ async def test_role_assignment_with_unknown_user_returns_422( # 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 "users" in body["detail"]["message"].lower() + 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 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 8dc9ca8..c19f1c9 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -31,7 +31,14 @@ async def getById(self, current_user, workspace_id: int): } if workspace_id not in all_ids: raise NotFoundException(f"Workspace {workspace_id} not found") - return SimpleNamespace(id=workspace_id) + # 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) # --------------------------------------------------------------------------- @@ -77,7 +84,13 @@ def _response(self, p: dict[str, Any]): # ---- create / list / get / patch / delete -------------------------- - async def create(self, workspace_id: int, current_user, body): + 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( From d1219301a4c41240183d0293d2872754111d4088 Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 8 Jun 2026 12:34:17 +0530 Subject: [PATCH 12/26] audit api changes --- api/main.py | 2 + api/src/tasking/audit/__init__.py | 0 api/src/tasking/audit/dtos.py | 41 ++++ api/src/tasking/audit/repository.py | 280 ++++++++++++++++++++++ api/src/tasking/audit/routes.py | 134 +++++++++++ api/src/tasking/audit/schemas.py | 62 +++++ docs/tasking-mvp/tasking-mvp.openapi.json | 54 ++--- docs/tasking-mvp/tasking-mvp.openapi.yaml | 50 ++-- tests/integration/test_audit_flow.py | 248 +++++++++++++++++++ 9 files changed, 819 insertions(+), 52 deletions(-) create mode 100644 api/src/tasking/audit/__init__.py create mode 100644 api/src/tasking/audit/dtos.py create mode 100644 api/src/tasking/audit/repository.py create mode 100644 api/src/tasking/audit/routes.py create mode 100644 api/src/tasking/audit/schemas.py create mode 100644 tests/integration/test_audit_flow.py diff --git a/api/main.py b/api/main.py index cbe1007..fcff505 100644 --- a/api/main.py +++ b/api/main.py @@ -22,6 +22,7 @@ init_tdei_client, validate_token, ) +from api.src.tasking.audit.routes import router as tasking_audit_router from api.src.tasking.projects.routes import me_router as tasking_me_router from api.src.tasking.projects.routes import router as tasking_projects_router from api.src.tasking.tasks.routes import router as tasking_tasks_router @@ -97,6 +98,7 @@ async def lifespan(_app: FastAPI): app.include_router(tasking_projects_router, prefix="/api/v1") app.include_router(tasking_me_router, prefix="/api/v1") app.include_router(tasking_tasks_router, prefix="/api/v1") +app.include_router(tasking_audit_router, prefix="/api/v1") @app.get("/health") diff --git a/api/src/tasking/audit/__init__.py b/api/src/tasking/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/audit/dtos.py b/api/src/tasking/audit/dtos.py new file mode 100644 index 0000000..b40f0f3 --- /dev/null +++ b/api/src/tasking/audit/dtos.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional +from uuid import UUID + +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import Pagination, WireModel + + +class ActorRef(WireModel): + """Resolved actor for an audit event.""" + + user_id: UUID + display_name: Optional[str] = None + + +class AuditEvent(WireModel): + """One row in `tasking_audit_events`, with `actor` joined for display.""" + + id: int + event_type: AuditEventType + project_id: int + task_id: Optional[int] = None + task_number: Optional[int] = None + actor: ActorRef + occurred_at: datetime + details: dict[str, Any] + project_deleted: bool = False + + +class AuditEventListResponse(WireModel): + results: list[AuditEvent] + pagination: Pagination + + +__all__ = [ + "ActorRef", + "AuditEvent", + "AuditEventListResponse", +] diff --git a/api/src/tasking/audit/repository.py b/api/src/tasking/audit/repository.py new file mode 100644 index 0000000..e813d8e --- /dev/null +++ b/api/src/tasking/audit/repository.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional +from uuid import UUID + +from sqlalchemy import text +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import NotFoundException +from api.src.tasking.audit.dtos import ( + ActorRef, + AuditEvent, + AuditEventListResponse, +) +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import Pagination + + +_ALLOWED_ORDER_DIR = {"ASC", "DESC"} + + +def _normalise_dir(order_dir: str) -> str: + return order_dir.upper() if order_dir.upper() in _ALLOWED_ORDER_DIR else "DESC" + + +def _clamp_page(page: int, page_size: int, max_page_size: int) -> tuple[int, int]: + return max(page, 1), max(min(page_size, max_page_size), 1) + + +class TaskingAuditRepository: + """Read-side queries for `tasking_audit_events`. + + Writes are owned by the task and project repositories — see + `_audit()` in `api/src/tasking/tasks/repository.py`. This module + only reads. + """ + + def __init__(self, session: AsyncSession): + self.session = session + + # ---- internal helpers ------------------------------------------------- + + async def _assert_project_visible( + self, workspace_id: int, project_id: int, *, include_deleted: bool + ) -> None: + """Confirm the project lives in the workspace; honour soft-delete + unless the caller explicitly opted into deleted projects. + """ + clause = ( + "SELECT 1 FROM tasking_projects " + "WHERE id = :pid AND workspace_id = :wid" + ) + if not include_deleted: + clause += " AND deleted_at IS NULL" + + result = await self.session.execute( + text(clause), {"pid": project_id, "wid": workspace_id} + ) + if result.scalar() is None: + raise NotFoundException(f"Project {project_id} not found") + + async def _task_id_from_number( + self, project_id: int, task_number: int + ) -> int: + result = await self.session.execute( + text( + "SELECT id FROM tasking_tasks " + "WHERE project_id = :pid AND task_number = :tn" + ), + {"pid": project_id, "tn": task_number}, + ) + task_id = result.scalar() + if task_id is None: + raise NotFoundException( + f"Task {task_number} not found on project {project_id}" + ) + return int(task_id) + + async def _resolve_actor_names( + self, auth_uids: set[str] + ) -> dict[str, Optional[str]]: + """Batch-load `users.display_name` for every distinct actor. + + Avoids the N+1 fetch when rendering long event lists. UIDs with + no `users` row map to None (the field is optional). + """ + if not auth_uids: + return {} + rows = await self.session.execute( + text( + "SELECT auth_uid, display_name FROM users " + "WHERE auth_uid = ANY(:uids)" + ), + {"uids": list(auth_uids)}, + ) + mapping = {row[0]: row[1] for row in rows.all()} + # Unknown actors still need a None entry so callers can `.get(uid)`. + for uid in auth_uids: + mapping.setdefault(uid, None) + return mapping + + @staticmethod + def _row_to_event( + row, + names: dict[str, Optional[str]], + ) -> AuditEvent: + ( + event_id, + event_type, + project_id, + task_id, + actor_uid, + occurred_at, + details, + project_deleted, + ) = row + details = details or {} + task_number = details.get("task_number") if isinstance(details, dict) else None + return AuditEvent( + id=event_id, + event_type=AuditEventType(event_type), + project_id=project_id, + task_id=task_id, + task_number=task_number, + actor=ActorRef( + user_id=UUID(str(actor_uid)), + display_name=names.get(str(actor_uid)), + ), + occurred_at=occurred_at, + details=details, + project_deleted=bool(project_deleted), + ) + + # ---- listing queries -------------------------------------------------- + + async def list_project_events( + self, + workspace_id: int, + project_id: int, + *, + event_type: Optional[AuditEventType] = None, + task_number: Optional[int] = None, + actor_user_id: Optional[UUID] = None, + occurred_from: Optional[datetime] = None, + occurred_to: Optional[datetime] = None, + include_deleted: bool = False, + page: int = 1, + page_size: int = 50, + order_dir: str = "DESC", + ) -> AuditEventListResponse: + await self._assert_project_visible( + workspace_id, project_id, include_deleted=include_deleted + ) + + page, page_size = _clamp_page(page, page_size, max_page_size=200) + order_dir = _normalise_dir(order_dir) + + # `task_number` is stored inside `details` JSONB rather than as a + # column, so filter via the typed accessor. + where = ["project_id = :pid"] + params: dict = {"pid": project_id} + if event_type is not None: + where.append("event_type = :et") + params["et"] = event_type.value + if task_number is not None: + where.append("(details->>'task_number')::int = :tn") + params["tn"] = task_number + if actor_user_id is not None: + where.append("actor_user_auth_uid = :au") + params["au"] = str(actor_user_id) + if occurred_from is not None: + where.append("occurred_at >= :from_ts") + params["from_ts"] = occurred_from + if occurred_to is not None: + where.append("occurred_at <= :to_ts") + params["to_ts"] = occurred_to + where_sql = " AND ".join(where) + + total_q = await self.session.execute( + text(f"SELECT COUNT(*) FROM tasking_audit_events WHERE {where_sql}"), + params, + ) + total = int(total_q.scalar() or 0) + + rows = await self.session.execute( + text( + "SELECT id, event_type, project_id, task_id, " + " actor_user_auth_uid, occurred_at, details, project_deleted " + "FROM tasking_audit_events " + f"WHERE {where_sql} " + f"ORDER BY occurred_at {order_dir}, id {order_dir} " + "LIMIT :limit OFFSET :offset" + ), + {**params, "limit": page_size, "offset": (page - 1) * page_size}, + ) + raw_rows = list(rows.all()) + actor_uids = {str(r[4]) for r in raw_rows} + names = await self._resolve_actor_names(actor_uids) + + return AuditEventListResponse( + results=[self._row_to_event(r, names) for r in raw_rows], + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + async def list_task_events( + self, + workspace_id: int, + project_id: int, + task_number: int, + *, + event_type: Optional[AuditEventType] = None, + actor_user_id: Optional[UUID] = None, + occurred_from: Optional[datetime] = None, + occurred_to: Optional[datetime] = None, + page: int = 1, + page_size: int = 25, + order_dir: str = "DESC", + ) -> AuditEventListResponse: + # Task-level listings only ever surface live projects. + await self._assert_project_visible( + workspace_id, project_id, include_deleted=False + ) + task_id = await self._task_id_from_number(project_id, task_number) + + page, page_size = _clamp_page(page, page_size, max_page_size=200) + order_dir = _normalise_dir(order_dir) + + # Match either `task_id = :tid` or the `task_number` in `details` + # — early audit rows for task creation set task_id but later + # rows that reference a task (e.g. project-level lock-extensions) + # may only persist the task_number. Both forms point at the + # same task, so OR them. + where = [ + "project_id = :pid", + "(task_id = :tid OR (details->>'task_number')::int = :tn)", + ] + params: dict = {"pid": project_id, "tid": task_id, "tn": task_number} + if event_type is not None: + where.append("event_type = :et") + params["et"] = event_type.value + if actor_user_id is not None: + where.append("actor_user_auth_uid = :au") + params["au"] = str(actor_user_id) + if occurred_from is not None: + where.append("occurred_at >= :from_ts") + params["from_ts"] = occurred_from + if occurred_to is not None: + where.append("occurred_at <= :to_ts") + params["to_ts"] = occurred_to + where_sql = " AND ".join(where) + + total_q = await self.session.execute( + text(f"SELECT COUNT(*) FROM tasking_audit_events WHERE {where_sql}"), + params, + ) + total = int(total_q.scalar() or 0) + + rows = await self.session.execute( + text( + "SELECT id, event_type, project_id, task_id, " + " actor_user_auth_uid, occurred_at, details, project_deleted " + "FROM tasking_audit_events " + f"WHERE {where_sql} " + f"ORDER BY occurred_at {order_dir}, id {order_dir} " + "LIMIT :limit OFFSET :offset" + ), + {**params, "limit": page_size, "offset": (page - 1) * page_size}, + ) + raw_rows = list(rows.all()) + actor_uids = {str(r[4]) for r in raw_rows} + names = await self._resolve_actor_names(actor_uids) + + return AuditEventListResponse( + results=[self._row_to_event(r, names) for r in raw_rows], + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + +__all__ = ["TaskingAuditRepository"] diff --git a/api/src/tasking/audit/routes.py b/api/src/tasking/audit/routes.py new file mode 100644 index 0000000..992f38c --- /dev/null +++ b/api/src/tasking/audit/routes.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status # noqa: F401 +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.security import UserInfo, validate_token +from api.src.tasking.audit.dtos import AuditEventListResponse +from api.src.tasking.audit.repository import TaskingAuditRepository +from api.src.tasking.audit.schemas import AuditEventType +from api.src.workspaces.repository import WorkspaceRepository + + +router = APIRouter( + prefix="/workspaces/{workspace_id}/tasking/projects", + tags=["tasking-audit"], +) + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def get_audit_repo( + session: AsyncSession = Depends(get_osm_session), +) -> TaskingAuditRepository: + return TaskingAuditRepository(session) + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +async def assert_workspace_visible( + workspace_id: int, + current_user: UserInfo, + workspace_repo: WorkspaceRepository, +) -> None: + """Tenancy gate: 404 if the caller's project groups don't own the + workspace (matches `WorkspaceRepository.getById`'s convention). + """ + await workspace_repo.getById(current_user, workspace_id) + + +# --------------------------------------------------------------------------- +# Project-level audit +# --------------------------------------------------------------------------- + + +@router.get( + "/{project_id}/audit", + response_model=AuditEventListResponse, +) +async def list_project_audit( + workspace_id: int, + project_id: int, + event_type: Optional[AuditEventType] = Query(default=None), + task_number: Optional[int] = Query(default=None, ge=1), + actor_user_id: Optional[UUID] = Query(default=None), + occurred_from: Optional[datetime] = Query(default=None), + occurred_to: Optional[datetime] = Query(default=None), + include_deleted: bool = Query(default=False), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + order_by_type: str = Query(default="DESC"), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + audit_repo: TaskingAuditRepository = Depends(get_audit_repo), +): + """Paginated project-level audit listing, newest first.""" + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await audit_repo.list_project_events( + workspace_id, + project_id, + event_type=event_type, + task_number=task_number, + actor_user_id=actor_user_id, + occurred_from=occurred_from, + occurred_to=occurred_to, + include_deleted=include_deleted, + page=page, + page_size=page_size, + order_dir=order_by_type, + ) + + +# --------------------------------------------------------------------------- +# Task-level audit +# --------------------------------------------------------------------------- + + +@router.get( + "/{project_id}/tasks/{task_number}/audit", + response_model=AuditEventListResponse, +) +async def list_task_audit( + workspace_id: int, + project_id: int, + task_number: int, + event_type: Optional[AuditEventType] = Query(default=None), + actor_user_id: Optional[UUID] = Query(default=None), + occurred_from: Optional[datetime] = Query(default=None), + occurred_to: Optional[datetime] = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=25, ge=1, le=200), + order_by_type: str = Query(default="DESC"), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + audit_repo: TaskingAuditRepository = Depends(get_audit_repo), +): + """Paginated audit listing for a single task, newest first.""" + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await audit_repo.list_task_events( + workspace_id, + project_id, + task_number, + event_type=event_type, + actor_user_id=actor_user_id, + occurred_from=occurred_from, + occurred_to=occurred_to, + page=page, + page_size=page_size, + order_dir=order_by_type, + ) + + +__all__ = ["router"] diff --git a/api/src/tasking/audit/schemas.py b/api/src/tasking/audit/schemas.py new file mode 100644 index 0000000..b24c405 --- /dev/null +++ b/api/src/tasking/audit/schemas.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Any, Optional +from uuid import UUID + +from sqlalchemy import Column +from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import Field, SQLModel + + +class AuditEventType(StrEnum): + """Closed set of event types written to `tasking_audit_events`. + + Mirrors the spec's `AuditEventType` enum; values match the literal + strings persisted in the `event_type` column. + """ + + PROJECT_CREATED = "project_created" + PROJECT_ACTIVATED = "project_activated" + PROJECT_CLOSED = "project_closed" + PROJECT_EDITED = "project_edited" + PROJECT_DELETED = "project_deleted" + PROJECT_RESET = "project_reset" + AOI_UPLOADED = "aoi_uploaded" + AOI_DELETED = "aoi_deleted" + TASK_CREATED = "task_created" + TASK_STATE_CHANGED = "task_state_changed" + TASK_LOCKED = "task_locked" + TASK_LOCK_EXTENDED = "task_lock_extended" + TASK_LOCK_RENEWED = "task_lock_renewed" + TASK_UNLOCKED = "task_unlocked" + TASK_RESET = "task_reset" + CHANGESET_SUBMITTED = "changeset_submitted" + FEEDBACK_SUBMITTED = "feedback_submitted" + + +class TaskingAuditEvent(SQLModel, table=True): + """Read-only mirror of `tasking_audit_events`. + + Writes happen via raw SQL in the task/project repositories so the + insert path stays decoupled from this module. The table is FK-free + by design (it must survive a project soft-delete + child hard-delete). + """ + + __tablename__ = "tasking_audit_events" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + event_type: str = Field(max_length=64, nullable=False) + project_id: int = Field(nullable=False, index=True) + task_id: Optional[int] = Field(default=None, nullable=True) + actor_user_auth_uid: UUID = Field(nullable=False) + occurred_at: datetime = Field(nullable=False) + details: dict[str, Any] = Field( + default_factory=dict, + sa_column=Column(JSONB, nullable=False), + ) + project_deleted: bool = Field(default=False, nullable=False) + + +__all__ = ["AuditEventType", "TaskingAuditEvent"] diff --git a/docs/tasking-mvp/tasking-mvp.openapi.json b/docs/tasking-mvp/tasking-mvp.openapi.json index 6c003b8..6e066dc 100644 --- a/docs/tasking-mvp/tasking-mvp.openapi.json +++ b/docs/tasking-mvp/tasking-mvp.openapi.json @@ -1652,19 +1652,19 @@ "audit" ], "summary": "List project-level audit events.", - "description": "Paginated, newest first. Any workspace contributor. Soft-deleted\nprojects are visible only with `includeDeleted=true`.\n", + "description": "Paginated, newest first. Any workspace contributor. Soft-deleted\nprojects are visible only with `include_deleted=true`.\n", "operationId": "listProjectAudit", "parameters": [ { "in": "query", - "name": "eventType", + "name": "event_type", "schema": { "$ref": "#/components/schemas/AuditEventType" } }, { "in": "query", - "name": "taskNumber", + "name": "task_number", "schema": { "type": "integer", "minimum": 1 @@ -1672,7 +1672,7 @@ }, { "in": "query", - "name": "actorUserId", + "name": "actor_user_id", "schema": { "type": "string", "format": "uuid" @@ -1680,7 +1680,7 @@ }, { "in": "query", - "name": "occurredFrom", + "name": "occurred_from", "schema": { "type": "string", "format": "date-time" @@ -1688,7 +1688,7 @@ }, { "in": "query", - "name": "occurredTo", + "name": "occurred_to", "schema": { "type": "string", "format": "date-time" @@ -1696,7 +1696,7 @@ }, { "in": "query", - "name": "includeDeleted", + "name": "include_deleted", "schema": { "type": "boolean", "default": false @@ -1713,7 +1713,7 @@ }, { "in": "query", - "name": "pageSize", + "name": "page_size", "schema": { "type": "integer", "minimum": 1, @@ -1723,7 +1723,7 @@ }, { "in": "query", - "name": "orderByType", + "name": "order_by_type", "schema": { "type": "string", "enum": [ @@ -1776,14 +1776,14 @@ "parameters": [ { "in": "query", - "name": "eventType", + "name": "event_type", "schema": { "$ref": "#/components/schemas/AuditEventType" } }, { "in": "query", - "name": "actorUserId", + "name": "actor_user_id", "schema": { "type": "string", "format": "uuid" @@ -1791,7 +1791,7 @@ }, { "in": "query", - "name": "occurredFrom", + "name": "occurred_from", "schema": { "type": "string", "format": "date-time" @@ -1799,7 +1799,7 @@ }, { "in": "query", - "name": "occurredTo", + "name": "occurred_to", "schema": { "type": "string", "format": "date-time" @@ -1816,7 +1816,7 @@ }, { "in": "query", - "name": "pageSize", + "name": "page_size", "schema": { "type": "integer", "minimum": 1, @@ -1826,7 +1826,7 @@ }, { "in": "query", - "name": "orderByType", + "name": "order_by_type", "schema": { "type": "string", "enum": [ @@ -2983,14 +2983,14 @@ "ActorRef": { "type": "object", "required": [ - "userId" + "user_id" ], "properties": { - "userId": { + "user_id": { "type": "string", "format": "uuid" }, - "displayName": { + "display_name": { "type": "string", "nullable": true } @@ -3000,10 +3000,10 @@ "type": "object", "required": [ "id", - "eventType", - "projectId", + "event_type", + "project_id", "actor", - "occurredAt", + "occurred_at", "details" ], "properties": { @@ -3011,19 +3011,19 @@ "type": "integer", "format": "int64" }, - "eventType": { + "event_type": { "$ref": "#/components/schemas/AuditEventType" }, - "projectId": { + "project_id": { "type": "integer", "format": "int64" }, - "taskId": { + "task_id": { "type": "integer", "format": "int64", "nullable": true }, - "taskNumber": { + "task_number": { "type": "integer", "nullable": true, "description": "Convenience copy from `details` (so list renderers don't peek inside JSONB)." @@ -3031,7 +3031,7 @@ "actor": { "$ref": "#/components/schemas/ActorRef" }, - "occurredAt": { + "occurred_at": { "type": "string", "format": "date-time" }, @@ -3039,7 +3039,7 @@ "type": "object", "additionalProperties": true }, - "projectDeleted": { + "project_deleted": { "type": "boolean", "default": false } diff --git a/docs/tasking-mvp/tasking-mvp.openapi.yaml b/docs/tasking-mvp/tasking-mvp.openapi.yaml index 6eb502b..8076441 100644 --- a/docs/tasking-mvp/tasking-mvp.openapi.yaml +++ b/docs/tasking-mvp/tasking-mvp.openapi.yaml @@ -1114,35 +1114,35 @@ paths: summary: List project-level audit events. description: | Paginated, newest first. Any workspace contributor. Soft-deleted - projects are visible only with `includeDeleted=true`. + projects are visible only with `include_deleted=true`. operationId: listProjectAudit parameters: - in: query - name: eventType + name: event_type schema: { $ref: "#/components/schemas/AuditEventType" } - in: query - name: taskNumber + name: task_number schema: { type: integer, minimum: 1 } - in: query - name: actorUserId + name: actor_user_id schema: { type: string, format: uuid } - in: query - name: occurredFrom + name: occurred_from schema: { type: string, format: date-time } - in: query - name: occurredTo + name: occurred_to schema: { type: string, format: date-time } - in: query - name: includeDeleted + name: include_deleted schema: { type: boolean, default: false } - in: query name: page schema: { type: integer, minimum: 1, default: 1 } - in: query - name: pageSize + name: page_size schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query - name: orderByType + name: order_by_type schema: { type: string, enum: [ASC, DESC], default: DESC } responses: "200": @@ -1165,25 +1165,25 @@ paths: operationId: listTaskAudit parameters: - in: query - name: eventType + name: event_type schema: { $ref: "#/components/schemas/AuditEventType" } - in: query - name: actorUserId + name: actor_user_id schema: { type: string, format: uuid } - in: query - name: occurredFrom + name: occurred_from schema: { type: string, format: date-time } - in: query - name: occurredTo + name: occurred_to schema: { type: string, format: date-time } - in: query name: page schema: { type: integer, minimum: 1, default: 1 } - in: query - name: pageSize + name: page_size schema: { type: integer, minimum: 1, maximum: 200, default: 25 } - in: query - name: orderByType + name: order_by_type schema: { type: string, enum: [ASC, DESC], default: DESC } responses: "200": @@ -1860,32 +1860,32 @@ components: ActorRef: type: object - required: [userId] + required: [user_id] properties: - userId: { type: string, format: uuid } - displayName: { type: string, nullable: true } + user_id: { type: string, format: uuid } + display_name: { type: string, nullable: true } AuditEvent: type: object - required: [id, eventType, projectId, actor, occurredAt, details] + required: [id, event_type, project_id, actor, occurred_at, details] properties: id: { type: integer, format: int64 } - eventType: { $ref: "#/components/schemas/AuditEventType" } - projectId: { type: integer, format: int64 } - taskId: + event_type: { $ref: "#/components/schemas/AuditEventType" } + project_id: { type: integer, format: int64 } + task_id: type: integer format: int64 nullable: true - taskNumber: + task_number: type: integer nullable: true description: Convenience copy from `details` (so list renderers don't peek inside JSONB). actor: { $ref: "#/components/schemas/ActorRef" } - occurredAt: { type: string, format: date-time } + occurred_at: { type: string, format: date-time } details: type: object additionalProperties: true - projectDeleted: + project_deleted: type: boolean default: false diff --git a/tests/integration/test_audit_flow.py b/tests/integration/test_audit_flow.py new file mode 100644 index 0000000..0d8d83e --- /dev/null +++ b/tests/integration/test_audit_flow.py @@ -0,0 +1,248 @@ +"""Integration tests for the tasking audit endpoints. + +Both readers are simple paginated GETs, but they need real rows in +`tasking_audit_events` — produced by exercising the project + task +lifecycle. Each class sets up enough state to verify a specific cut: +listing, filtering, soft-delete visibility, task-scoped reads. +""" + +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 From e3d1bffb6102c36bf521ff2a75f28d92c83814ab Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 8 Jun 2026 12:34:46 +0530 Subject: [PATCH 13/26] audit test --- tests/integration/test_audit_flow.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/integration/test_audit_flow.py b/tests/integration/test_audit_flow.py index 0d8d83e..4e3c7f3 100644 --- a/tests/integration/test_audit_flow.py +++ b/tests/integration/test_audit_flow.py @@ -1,10 +1,3 @@ -"""Integration tests for the tasking audit endpoints. - -Both readers are simple paginated GETs, but they need real rows in -`tasking_audit_events` — produced by exercising the project + task -lifecycle. Each class sets up enough state to verify a specific cut: -listing, filtering, soft-delete visibility, task-scoped reads. -""" from __future__ import annotations From 0966769a919ad89191b859ab6457cb3a1b32e933 Mon Sep 17 00:00:00 2001 From: sujata-m Date: Tue, 16 Jun 2026 13:55:24 +0530 Subject: [PATCH 14/26] Fixed CI pipeline --- .github/workflows/ci.yml | 4 ++-- .gitignore | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c72a0ff..2de6a02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main ] + branches: [ main, develop ] pull_request: - branches: [ main ] + branches: [ main, develop ] jobs: lint: diff --git a/.gitignore b/.gitignore index cf7489e..4516af1 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,4 @@ docs/tasking-mvp/tasking-mvp.postman_collection.json docs/tasking-mvp/tasking-mvp.postman_environment.json docs/tasking-mvp/_enrich_postman.py docs/tasking-mvp/feature-coverage.md +.idea/ From f1e2b23111977125e5d5e832a0903d36c039874c Mon Sep 17 00:00:00 2001 From: sujata-m Date: Tue, 16 Jun 2026 13:59:46 +0530 Subject: [PATCH 15/26] Fixed isort --- .../9221408912dd_add_user_role_table.py | 5 +- .../a1b2c3d4e5f6_tasking_mvp_schema.py | 23 ++----- .../c5121cbba124_initial_task_schema.py | 6 +- api/core/security.py | 4 +- api/src/tasking/audit/repository.py | 14 +---- api/src/tasking/audit/routes.py | 1 - api/src/tasking/projects/dtos.py | 7 ++- api/src/tasking/projects/repository.py | 51 +++++---------- api/src/tasking/projects/routes.py | 37 +++-------- api/src/tasking/projects/schemas.py | 7 ++- api/src/tasking/tasks/dtos.py | 6 +- api/src/tasking/tasks/repository.py | 63 +++++-------------- api/src/tasking/tasks/routes.py | 23 ++++--- api/src/tasking/tasks/schemas.py | 5 +- tests/conftest.py | 16 ++--- tests/integration/conftest.py | 23 +++---- tests/integration/test_audit_flow.py | 46 ++++---------- tests/integration/test_projects_flow.py | 59 +++++------------ tests/integration/test_tasks_flow.py | 55 +++++----------- tests/unit/conftest.py | 6 +- tests/unit/test_aoi_normalisation.py | 3 - tests/unit/test_dtos_validation.py | 3 - tests/unit/test_project_routes.py | 38 +++-------- tests/unit/test_user_info_gates.py | 27 ++++---- 24 files changed, 152 insertions(+), 376 deletions(-) diff --git a/alembic_osm/versions/9221408912dd_add_user_role_table.py b/alembic_osm/versions/9221408912dd_add_user_role_table.py index 640453e..7d7fa57 100644 --- a/alembic_osm/versions/9221408912dd_add_user_role_table.py +++ b/alembic_osm/versions/9221408912dd_add_user_role_table.py @@ -13,7 +13,6 @@ from sqlalchemy import inspect, text from sqlalchemy.dialects import postgresql - # revision identifiers, used by Alembic. revision: str = "9221408912dd" down_revision: Union[str, None] = None @@ -37,9 +36,7 @@ def upgrade() -> None: "users", # `id` matches the Rails `users.id` numeric PK so the FK # from `team_user.user_id` in the next migration can attach. - sa.Column( - "id", sa.BigInteger(), autoincrement=True, nullable=False - ), + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), sa.Column("auth_uid", sa.String(), nullable=False), sa.Column("email", sa.String(), nullable=True), sa.Column("display_name", sa.String(), nullable=True), diff --git a/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py index 8606ce9..d792f4c 100644 --- a/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py +++ b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py @@ -1,5 +1,3 @@ - - from typing import Sequence, Union import sqlalchemy as sa @@ -244,9 +242,7 @@ def upgrade() -> None: ), sa.Column("project_id", sa.BigInteger(), nullable=False), sa.Column("task_number", sa.Integer(), nullable=False), - sa.Column( - "area_sqkm", sa.Numeric(precision=10, scale=4), nullable=False - ), + sa.Column("area_sqkm", sa.Numeric(precision=10, scale=4), nullable=False), sa.Column( "status", postgresql.ENUM( @@ -287,13 +283,8 @@ def upgrade() -> None: "ON tasking_tasks USING GIST (geometry)" ) else: - op.execute( - "ALTER TABLE tasking_tasks " - "ADD COLUMN geometry BYTEA" - ) - op.create_index( - "tasking_tasks_project_idx", "tasking_tasks", ["project_id"] - ) + op.execute("ALTER TABLE tasking_tasks " "ADD COLUMN geometry BYTEA") + op.create_index("tasking_tasks_project_idx", "tasking_tasks", ["project_id"]) # ---- tasking_locks ------------------------------------------------ @@ -439,13 +430,9 @@ def upgrade() -> None: sa.ForeignKeyConstraint( ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" ), - sa.ForeignKeyConstraint( - ["author_user_auth_uid"], ["users.auth_uid"] - ), - ) - op.create_index( - "tasking_feedback_task_idx", "tasking_feedback", ["task_id"] + sa.ForeignKeyConstraint(["author_user_auth_uid"], ["users.auth_uid"]), ) + op.create_index("tasking_feedback_task_idx", "tasking_feedback", ["task_id"]) op.create_index( "tasking_feedback_project_idx", "tasking_feedback", ["project_id"] ) diff --git a/alembic_task/versions/c5121cbba124_initial_task_schema.py b/alembic_task/versions/c5121cbba124_initial_task_schema.py index ef9befa..a06f085 100644 --- a/alembic_task/versions/c5121cbba124_initial_task_schema.py +++ b/alembic_task/versions/c5121cbba124_initial_task_schema.py @@ -1,4 +1,3 @@ - from typing import Sequence, Union import sqlalchemy as sa @@ -54,7 +53,9 @@ def upgrade() -> None: sa.Column("tdeiProjectGroupId", sa.Uuid(), nullable=False), sa.Column("tdeiRecordId", sa.Uuid(), nullable=True), sa.Column("tdeiServiceId", sa.Uuid(), nullable=True), - sa.Column("tdeiMetadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column( + "tdeiMetadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), sa.Column("createdAt", sa.DateTime(), nullable=False), sa.Column("createdBy", sa.Uuid(), nullable=False), sa.Column("createdByName", sa.String(), nullable=False), @@ -98,7 +99,6 @@ def upgrade() -> None: ) - def downgrade() -> None: bind = op.get_bind() assert bind is not None diff --git a/api/core/security.py b/api/core/security.py index 9919699..6a8f350 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -130,9 +130,7 @@ async def fetch_project_group_users( TdeiProjectGroupUser( auth_uid=str(uid), email=row.get("email"), - display_name=( - row.get("username") - ), + display_name=(row.get("username")), ) ) diff --git a/api/src/tasking/audit/repository.py b/api/src/tasking/audit/repository.py index e813d8e..4f589b4 100644 --- a/api/src/tasking/audit/repository.py +++ b/api/src/tasking/audit/repository.py @@ -8,15 +8,10 @@ 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.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"} @@ -48,8 +43,7 @@ async def _assert_project_visible( unless the caller explicitly opted into deleted projects. """ clause = ( - "SELECT 1 FROM tasking_projects " - "WHERE id = :pid AND workspace_id = :wid" + "SELECT 1 FROM tasking_projects " "WHERE id = :pid AND workspace_id = :wid" ) if not include_deleted: clause += " AND deleted_at IS NULL" @@ -60,9 +54,7 @@ async def _assert_project_visible( 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: + 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 " diff --git a/api/src/tasking/audit/routes.py b/api/src/tasking/audit/routes.py index 992f38c..e6ab06f 100644 --- a/api/src/tasking/audit/routes.py +++ b/api/src/tasking/audit/routes.py @@ -14,7 +14,6 @@ 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"], diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py index ef8848f..488e981 100644 --- a/api/src/tasking/projects/dtos.py +++ b/api/src/tasking/projects/dtos.py @@ -4,7 +4,9 @@ from typing import Any, Literal, Optional from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field as PydField, field_validator +from pydantic import BaseModel, ConfigDict +from pydantic import Field as PydField +from pydantic import field_validator from api.src.tasking.projects.schemas import ( AoiInput, @@ -14,14 +16,13 @@ _MultiPolygon, ) - # --------------------------------------------------------------------------- # Shared DTO base. # --------------------------------------------------------------------------- class WireModel(BaseModel): - + model_config = ConfigDict(extra="forbid") diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index a19089a..087a978 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -45,7 +45,6 @@ _Polygon, ) - # --------------------------------------------------------------------------- # AOI helpers # --------------------------------------------------------------------------- @@ -184,9 +183,7 @@ def __init__(self, session: AsyncSession): # ---- internal helpers -------------------------------------------- - async def _get_active( - self, workspace_id: int, project_id: int - ) -> TaskingProject: + async def _get_active(self, workspace_id: int, project_id: int) -> TaskingProject: """Fetch a non-deleted project scoped to a workspace; raise 404 otherwise.""" result = await self.session.execute( select(TaskingProject).where( @@ -233,13 +230,12 @@ async def _provision_users_from_tdei( a user who is known to TDEI but has not yet performed any action that would write them into `users` (e.g. first-time sign-in). """ - from api.core.security import fetch_project_group_users from sqlalchemy import text + from api.core.security import fetch_project_group_users + try: - members = await fetch_project_group_users( - project_group_id, bearer_token - ) + members = await fetch_project_group_users(project_group_id, bearer_token) except HTTPException: raise except Exception as e: @@ -276,9 +272,7 @@ async def _provision_users_from_tdei( return [u for u in missing_uuids if u not in resolved] - async def _missing_user_auth_uids( - self, uuids: list[UUID] - ) -> list[str]: + async def _missing_user_auth_uids(self, uuids: list[UUID]) -> list[str]: """Return the subset of `uuids` without a matching `users` row. Preflight for the `tasking_project_roles.user_auth_uid` FK so @@ -291,9 +285,7 @@ async def _missing_user_auth_uids( from sqlalchemy import text rows = await self.session.execute( - text( - "SELECT auth_uid FROM users WHERE auth_uid = ANY(:uids)" - ), + text("SELECT auth_uid FROM users WHERE auth_uid = ANY(:uids)"), {"uids": [str(u) for u in uuids]}, ) existing = {row[0] for row in rows.all()} @@ -305,9 +297,7 @@ async def _task_count(self, project_id: int) -> int: from sqlalchemy import text result = await self.session.execute( - text( - "SELECT COUNT(*) FROM tasking_tasks WHERE project_id = :pid" - ), + text("SELECT COUNT(*) FROM tasking_tasks WHERE project_id = :pid"), {"pid": project_id}, ) return int(result.scalar() or 0) @@ -626,9 +616,7 @@ async def soft_delete(self, workspace_id: int, project_id: int) -> None: # ---- lifecycle transitions --------------------------------------- - async def activate( - self, workspace_id: int, project_id: int - ) -> ProjectResponse: + async def activate(self, workspace_id: int, project_id: int) -> ProjectResponse: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.DRAFT: raise HTTPException( @@ -679,9 +667,7 @@ async def activate( await self.session.refresh(project) return self._to_response(project, task_count=tc) - async def close( - self, workspace_id: int, project_id: int - ) -> ProjectResponse: + async def close(self, workspace_id: int, project_id: int) -> ProjectResponse: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.OPEN: raise HTTPException( @@ -726,9 +712,7 @@ async def close( tc = await self._task_count(project.id) # type: ignore[arg-type] return self._to_response(project, task_count=tc) - async def reset( - self, workspace_id: int, project_id: int - ) -> ProjectResponse: + async def reset(self, workspace_id: int, project_id: int) -> ProjectResponse: """LEAD reset — see spec §projects.""" project = await self._get_active(workspace_id, project_id) if project.status == ProjectStatus.DRAFT: @@ -772,9 +756,7 @@ async def reset( # ---- AOI --------------------------------------------------------- - async def get_aoi( - self, workspace_id: int, project_id: int - ) -> AoiFeature: + async def get_aoi(self, workspace_id: int, project_id: int) -> AoiFeature: project = await self._get_active(workspace_id, project_id) if project.aoi is None: raise NotFoundException("AOI is not set on this project") @@ -823,9 +805,7 @@ async def upload_aoi( # violations on `user_auth_uid` are caught with a preflight so the # caller gets a 422 listing the missing user id. - async def _is_project_lead( - self, project_id: int, user_uuid: UUID - ) -> bool: + async def _is_project_lead(self, project_id: int, user_uuid: UUID) -> bool: """True if the user holds a `lead` role on the given project.""" from sqlalchemy import text @@ -858,8 +838,7 @@ async def assert_can_manage_roles( if await self._is_project_lead(project_id, current_user.user_uuid): return raise ForbiddenException( - "Only a workspace lead or project lead can manage roles " - "on this project." + "Only a workspace lead or project lead can manage roles " "on this project." ) async def _lead_count(self, project_id: int) -> int: @@ -1200,9 +1179,7 @@ async def remove_role( ) await self.session.commit() - async def _get_role( - self, project_id: int, user_id: UUID - ) -> ProjectRoleItem: + async def _get_role(self, project_id: int, user_id: UUID) -> ProjectRoleItem: item = await self._get_role_or_none(project_id, user_id) if item is None: # pragma: no cover — only called after insert/update raise NotFoundException( diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py index 81f06f4..e7367f1 100644 --- a/api/src/tasking/projects/routes.py +++ b/api/src/tasking/projects/routes.py @@ -1,13 +1,13 @@ from __future__ import annotations from typing import Annotated +from uuid import UUID from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session from api.core.security import UserInfo, validate_token -from api.src.tasking.projects.repository import TaskingProjectRepository from api.src.tasking.projects.dtos import ( AoiFeature, ProjectCreateRequest, @@ -20,11 +20,8 @@ ProjectUpdateRequest, SelfProjectRolesResponse, ) -from api.src.tasking.projects.schemas import ( - AoiInput, - ProjectStatus, -) -from uuid import UUID +from api.src.tasking.projects.repository import TaskingProjectRepository +from api.src.tasking.projects.schemas import AoiInput, ProjectStatus from api.src.workspaces.repository import WorkspaceRepository router = APIRouter( @@ -84,9 +81,7 @@ def assert_workspace_lead(workspace_id: int, current_user: UserInfo) -> None: @router.get("", response_model=ProjectListResponse) async def list_projects( workspace_id: int, - status_filter: Annotated[ - ProjectStatus | None, Query(alias="status") - ] = None, + status_filter: Annotated[ProjectStatus | None, Query(alias="status")] = None, text_search: str | None = Query(default=None, max_length=255), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=200), @@ -280,9 +275,7 @@ async def add_project_role( project_repo: TaskingProjectRepository = Depends(get_project_repo), ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) - await project_repo.assert_can_manage_roles( - workspace_id, project_id, current_user - ) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) return await project_repo.add_role(workspace_id, project_id, body) @@ -318,9 +311,7 @@ async def put_project_role( ): """Idempotent upsert. 201 on insert, 200 on update. Last-LEAD guarded.""" await assert_workspace_visible(workspace_id, current_user, workspace_repo) - await project_repo.assert_can_manage_roles( - workspace_id, project_id, current_user - ) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) item, created = await project_repo.upsert_role( workspace_id, project_id, user_id, body ) @@ -343,12 +334,8 @@ async def update_project_role( project_repo: TaskingProjectRepository = Depends(get_project_repo), ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) - await project_repo.assert_can_manage_roles( - workspace_id, project_id, current_user - ) - return await project_repo.update_role( - workspace_id, project_id, user_id, body - ) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) + return await project_repo.update_role(workspace_id, project_id, user_id, body) @router.delete( @@ -364,9 +351,7 @@ async def remove_project_role( project_repo: TaskingProjectRepository = Depends(get_project_repo), ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) - await project_repo.assert_can_manage_roles( - workspace_id, project_id, current_user - ) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) await project_repo.remove_role(workspace_id, project_id, user_id) @@ -409,6 +394,4 @@ async def list_self_project_roles( Single round-trip for the project-list page. """ await assert_workspace_visible(workspace_id, current_user, workspace_repo) - return await project_repo.list_self_project_roles( - workspace_id, current_user - ) + return await project_repo.list_self_project_roles(workspace_id, current_user) diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py index f0b4695..901d5f6 100644 --- a/api/src/tasking/projects/schemas.py +++ b/api/src/tasking/projects/schemas.py @@ -6,11 +6,12 @@ from uuid import UUID from geoalchemy2 import Geometry -from pydantic import BaseModel, Field as PydField -from sqlalchemy import Column, Enum as SAEnum +from pydantic import BaseModel +from pydantic import Field as PydField +from sqlalchemy import Column +from sqlalchemy import Enum as SAEnum from sqlmodel import Field, SQLModel - # --------------------------------------------------------------------------- # Enums (mirrors of postgres enums in the migration) # --------------------------------------------------------------------------- diff --git a/api/src/tasking/tasks/dtos.py b/api/src/tasking/tasks/dtos.py index edfec34..374bed2 100644 --- a/api/src/tasking/tasks/dtos.py +++ b/api/src/tasking/tasks/dtos.py @@ -7,11 +7,7 @@ from pydantic import Field as PydField from api.src.tasking.projects.dtos import Pagination, WireModel -from api.src.tasking.tasks.schemas import ( - FeedbackReason, - TaskStatus, -) - +from api.src.tasking.tasks.schemas import FeedbackReason, TaskStatus # --------------------------------------------------------------------------- # Task boundary GeoJSON (input for /tasks/validate and /tasks/save) diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index bb6cf41..0e785be 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -24,10 +24,7 @@ ) from api.core.security import UserInfo from api.src.tasking.projects.dtos import Pagination -from api.src.tasking.projects.schemas import ( - ProjectStatus, - TaskingProject, -) +from api.src.tasking.projects.schemas import ProjectStatus, TaskingProject from api.src.tasking.tasks.dtos import ( ExistingLockSummary, FeedbackInput, @@ -53,7 +50,6 @@ TaskStatus, ) - # Equirectangular approximation for area calculations on small # EPSG:4326 polygons: 1 degree latitude ≈ 111.32 km. Sufficient for # the grid-size warning threshold; precise areas need a metric @@ -149,9 +145,7 @@ def _generate_grid_over_aoi( minx, miny, maxx, maxy = aoi.bounds center_lat = (miny + maxy) / 2.0 lat_step = cell_size_m / 111_320.0 - lon_step = cell_size_m / ( - 111_320.0 * max(math.cos(math.radians(center_lat)), 0.01) - ) + lon_step = cell_size_m / (111_320.0 * max(math.cos(math.radians(center_lat)), 0.01)) cells: list[ShapelyPolygon] = [] # Safety cap for accidental large-AOI + small-cell combinations. @@ -176,15 +170,10 @@ def _generate_grid_over_aoi( # `intersection` can return a Polygon, MultiPolygon, # or GeometryCollection; retain polygon pieces only. geoms = ( - list(clipped.geoms) - if hasattr(clipped, "geoms") - else [clipped] + list(clipped.geoms) if hasattr(clipped, "geoms") else [clipped] ) for piece in geoms: - if ( - isinstance(piece, ShapelyPolygon) - and piece.area > 0 - ): + if isinstance(piece, ShapelyPolygon) and piece.area > 0: cells.append(piece) if len(cells) >= cell_cap: return cells @@ -212,9 +201,7 @@ def __init__(self, session: AsyncSession): # ---- common helpers --------------------------------------------------- - async def _get_project( - self, workspace_id: int, project_id: int - ) -> TaskingProject: + async def _get_project(self, workspace_id: int, project_id: int) -> TaskingProject: rs = await self.session.execute( select(TaskingProject).where( (TaskingProject.id == project_id) @@ -227,9 +214,7 @@ async def _get_project( raise NotFoundException(f"Project {project_id} not found") return project - async def _get_task( - self, project_id: int, task_number: int - ) -> TaskingTask: + async def _get_task(self, project_id: int, task_number: int) -> TaskingTask: rs = await self.session.execute( select(TaskingTask).where( (TaskingTask.project_id == project_id) @@ -243,13 +228,10 @@ async def _get_task( ) return task - async def _get_active_lock( - self, task_id: int - ) -> Optional[TaskingLock]: + async def _get_active_lock(self, task_id: int) -> Optional[TaskingLock]: rs = await self.session.execute( select(TaskingLock).where( - (TaskingLock.task_id == task_id) - & (TaskingLock.released_at.is_(None)) + (TaskingLock.task_id == task_id) & (TaskingLock.released_at.is_(None)) ) ) return rs.scalar_one_or_none() @@ -320,15 +302,11 @@ async def _audit( }, ) - async def _lookup_user_display( - self, user_auth_uid: Optional[str] - ) -> Optional[str]: + async def _lookup_user_display(self, user_auth_uid: Optional[str]) -> Optional[str]: if not user_auth_uid: return None rs = await self.session.execute( - text( - "SELECT display_name FROM users WHERE auth_uid = :uid" - ), + text("SELECT display_name FROM users WHERE auth_uid = :uid"), {"uid": user_auth_uid}, ) return rs.scalar_one_or_none() @@ -498,9 +476,7 @@ async def save( detail="Project AOI is required before saving tasks", ) - body_bytes = json.dumps( - body.model_dump(mode="json"), sort_keys=True - ).encode() + body_bytes = json.dumps(body.model_dump(mode="json"), sort_keys=True).encode() body_hash = hashlib.sha256(body_bytes).hexdigest() # Idempotent replay path. @@ -530,9 +506,7 @@ async def save( # Refuse if tasks already exist (re-upload AOI to wipe). existing = await self.session.execute( - text( - "SELECT 1 FROM tasking_tasks WHERE project_id = :pid LIMIT 1" - ), + text("SELECT 1 FROM tasking_tasks WHERE project_id = :pid LIMIT 1"), {"pid": project.id}, ) if existing.scalar() is not None: @@ -692,9 +666,7 @@ async def lock_task( task = await self._get_task(project_id, task_number) # Eligibility table. - role = await self._project_role( - project_id, current_user, workspace_id - ) + role = await self._project_role(project_id, current_user, workspace_id) if role is None: raise ForbiddenException("User has no access to this project") @@ -708,13 +680,10 @@ async def lock_task( raise ForbiddenException( "Role does not permit locking this task for validation" ) - if ( - task.last_mapper_id - and task.last_mapper_id == str(current_user.user_uuid) + if task.last_mapper_id and task.last_mapper_id == str( + current_user.user_uuid ): - raise ForbiddenException( - "Cannot validate a task you last mapped" - ) + raise ForbiddenException("Cannot validate a task you last mapped") else: raise ForbiddenException("Task is in a terminal state") diff --git a/api/src/tasking/tasks/routes.py b/api/src/tasking/tasks/routes.py index 1d38565..24bf14e 100644 --- a/api/src/tasking/tasks/routes.py +++ b/api/src/tasking/tasks/routes.py @@ -3,7 +3,16 @@ from typing import Annotated, Optional from uuid import UUID -from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response, status +from fastapi import ( + APIRouter, + Body, + Depends, + Header, + HTTPException, + Query, + Response, + status, +) from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session @@ -81,9 +90,7 @@ async def generate_grid( """ await assert_workspace_visible(workspace_id, current_user, workspace_repo) assert_workspace_lead(workspace_id, current_user) - return await task_repo.generate_grid( - workspace_id, project_id, cell_size_meters - ) + return await task_repo.generate_grid(workspace_id, project_id, cell_size_meters) @router.post("/tasks/validate", response_model=ValidatePreviewResponse) @@ -118,9 +125,7 @@ async def save_tasks( payload, replayed = await task_repo.save( workspace_id, project_id, current_user, body, idempotency_key ) - response.status_code = ( - status.HTTP_200_OK if replayed else status.HTTP_201_CREATED - ) + response.status_code = status.HTTP_200_OK if replayed else status.HTTP_201_CREATED return payload @@ -128,9 +133,7 @@ async def save_tasks( async def list_tasks( workspace_id: int, project_id: int, - status_filter: Annotated[ - Optional[TaskStatus], Query(alias="status") - ] = None, + status_filter: Annotated[Optional[TaskStatus], Query(alias="status")] = None, locked_by_user_id: Optional[UUID] = Query(default=None), last_mapper_id: Optional[UUID] = Query(default=None), page: int = Query(1, ge=1), diff --git a/api/src/tasking/tasks/schemas.py b/api/src/tasking/tasks/schemas.py index 40e5799..f3c114b 100644 --- a/api/src/tasking/tasks/schemas.py +++ b/api/src/tasking/tasks/schemas.py @@ -1,4 +1,3 @@ - from __future__ import annotations from datetime import datetime @@ -7,10 +6,10 @@ from typing import Any, Optional from geoalchemy2 import Geometry -from sqlalchemy import Column, Enum as SAEnum +from sqlalchemy import Column +from sqlalchemy import Enum as SAEnum from sqlmodel import Field, SQLModel - # --------------------------------------------------------------------------- # Enums (mirrors of postgres enums in the migration) # --------------------------------------------------------------------------- diff --git a/tests/conftest.py b/tests/conftest.py index fbbf416..309c430 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ - from __future__ import annotations from collections.abc import AsyncIterator, Iterator @@ -7,7 +6,6 @@ import pytest - # Constants — referenced by both unit and integration suites. SEED_WORKSPACE_ID = 1899 SEED_PROJECT_GROUP_ID = UUID("00000000-0000-0000-0000-000000001899") @@ -73,9 +71,8 @@ def pytest_html_results_table_header(cells): def pytest_html_results_table_row(report, cells): """Inject the docstring as the matching cell on each row.""" - cells.insert( - 2, f"{getattr(report, 'description', '') or '—'}" - ) + cells.insert(2, f"{getattr(report, 'description', '') or '—'}") + except ImportError: pass @@ -83,8 +80,7 @@ def pytest_html_results_table_row(report, cells): def _redact(headers) -> str: redact = {"authorization", "cookie", "set-cookie", "x-api-key"} return ", ".join( - f"{k}={'***' if k.lower() in redact else v}" - for k, v in headers.items() + f"{k}={'***' if k.lower() in redact else v}" for k, v in headers.items() ) @@ -152,11 +148,7 @@ def _make_user( is_poc: bool = False, ): """Construct a UserInfo with the minimum fields the gates inspect.""" - from api.core.security import ( - TdeiProjectGroupRole, - UserInfo, - UserInfoPGMembership, - ) + from api.core.security import TdeiProjectGroupRole, UserInfo, UserInfoPGMembership from api.src.users.schemas import WorkspaceUserRoleType u = UserInfo() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index c56b092..be2be67 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,4 +1,3 @@ - from __future__ import annotations import os @@ -10,7 +9,6 @@ from tests.conftest import SEED_PROJECT_GROUP_ID, SEED_WORKSPACE_ID - # --------------------------------------------------------------------------- # Docker availability gate — skip cleanly when the daemon is missing # and surface the actual reason in the pytest skip message. @@ -232,8 +230,8 @@ async def _seed_workspace_row(_migrated_db: tuple[str, str]) -> int: await conn.execute( text( "INSERT INTO workspaces " - "(id, type, title, \"tdeiProjectGroupId\", \"createdAt\", " - " \"createdBy\", \"createdByName\", \"externalAppAccess\") " + '(id, type, title, "tdeiProjectGroupId", "createdAt", ' + ' "createdBy", "createdByName", "externalAppAccess") ' "VALUES (:id, :type, :title, :pgid, NOW(), :uid, :uname, 0) " "ON CONFLICT (id) DO NOTHING" ), @@ -374,10 +372,11 @@ async def _get_osm(): # `validate_token`. These shadow the unit-suite counterparts in # tests/conftest.py for every test under tests/integration/. + @pytest.fixture async def as_lead(_pg_urls, seeded_workspace_id): """LEAD user persisted in users table + overridden in validate_token.""" - from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user _, osm_url = _pg_urls user = _make_user( @@ -394,7 +393,7 @@ async def as_lead(_pg_urls, seeded_workspace_id): @pytest.fixture async def as_contributor(_pg_urls, seeded_workspace_id): """CONTRIBUTOR user persisted in users table + overridden in validate_token.""" - from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user _, osm_url = _pg_urls user = _make_user( @@ -411,7 +410,7 @@ async def as_contributor(_pg_urls, seeded_workspace_id): @pytest.fixture async def as_validator(_pg_urls, seeded_workspace_id): """VALIDATOR user persisted in users table + overridden in validate_token.""" - from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user _, osm_url = _pg_urls user = _make_user( @@ -432,7 +431,7 @@ async def as_outsider(_pg_urls, seeded_workspace_id): Inserted into users so role tests don't break, but their workspace role list is empty so the tenancy gate still 404s. """ - from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user _, osm_url = _pg_urls user = _make_user( @@ -500,15 +499,11 @@ async def _fake_fetch(project_group_id: str, bearer_token: str): import api.core.security import api.src.tasking.projects.repository as proj_repo - monkeypatch.setattr( - api.core.security, "fetch_project_group_users", _fake_fetch - ) + monkeypatch.setattr(api.core.security, "fetch_project_group_users", _fake_fetch) # The repository imports the symbol locally inside the helper, but be # belt-and-braces in case that ever changes: if hasattr(proj_repo, "fetch_project_group_users"): - monkeypatch.setattr( - proj_repo, "fetch_project_group_users", _fake_fetch - ) + monkeypatch.setattr(proj_repo, "fetch_project_group_users", _fake_fetch) return members diff --git a/tests/integration/test_audit_flow.py b/tests/integration/test_audit_flow.py index 4e3c7f3..3cc37ec 100644 --- a/tests/integration/test_audit_flow.py +++ b/tests/integration/test_audit_flow.py @@ -1,4 +1,3 @@ - from __future__ import annotations import pytest @@ -61,9 +60,7 @@ async def _open_project_with_tasks(client, workspace_id): ) assert r.status_code == 201, r.text - r = await client.post( - f"{API.format(wid=workspace_id)}/{pid}/activate" - ) + r = await client.post(f"{API.format(wid=workspace_id)}/{pid}/activate") assert r.status_code == 200, r.text return pid @@ -81,9 +78,7 @@ async def test_lists_lifecycle_events_newest_first( """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" - ) + 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 @@ -96,9 +91,7 @@ async def test_lists_lifecycle_events_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 - ): + 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) @@ -110,9 +103,7 @@ async def test_filter_by_event_type( 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 - ): + 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( @@ -138,13 +129,9 @@ async def test_pagination_clamps_and_total( assert body["pagination"]["page_size"] == 1 assert body["pagination"]["total"] >= 1 - async def test_unknown_project_404( - self, client, as_lead, seeded_workspace_id - ): + 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" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/999999/audit") assert r.status_code == 404 @@ -184,14 +171,9 @@ async def test_lists_task_events( 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 - ) + 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 - ): + 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( @@ -214,22 +196,16 @@ async def test_deleted_project_hidden_by_default( 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" - ) + 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}" - ) + 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" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/audit") assert r.status_code == 404 # Explicit opt-in = visible. diff --git a/tests/integration/test_projects_flow.py b/tests/integration/test_projects_flow.py index f9a57cd..c1575de 100644 --- a/tests/integration/test_projects_flow.py +++ b/tests/integration/test_projects_flow.py @@ -1,4 +1,3 @@ - from __future__ import annotations import pytest @@ -50,9 +49,7 @@ async def test_01_create_draft(self, client, as_lead, seeded_workspace_id): async def test_02_get_round_trip(self, client, as_lead, seeded_workspace_id): """GET round-trips the project just created (same id).""" - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{self.project_id}" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{self.project_id}") assert r.status_code == 200 assert r.json()["id"] == self.project_id @@ -113,9 +110,7 @@ async def test_07_soft_delete_clears_listing( ids = {row["id"] for row in r.json()["results"]} assert self.project_id not in ids - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{self.project_id}" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{self.project_id}") assert r.status_code == 404 @@ -226,9 +221,7 @@ async def test_role_assignment_with_unknown_user_returns_422( API.format(wid=seeded_workspace_id), json={ "name": "role-fk-error", - "role_assignments": [ - {"user_id": bogus, "role": "contributor"} - ], + "role_assignments": [{"user_id": bogus, "role": "contributor"}], }, ) assert r.status_code == 422, r.text @@ -323,20 +316,13 @@ async def test_aoi_replace_resets_boundary_type( json=SQUARE_MULTI, ) assert r2.status_code == 200 - assert ( - r2.json()["geometry"]["coordinates"] - == SQUARE_MULTI["coordinates"] - ) + assert r2.json()["geometry"]["coordinates"] == SQUARE_MULTI["coordinates"] # Boundary type should have been cleared (per spec). - proj = ( - await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}") - ).json() + proj = (await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}")).json() assert proj["task_boundary_type"] is None - async def test_aoi_delete_round_trip( - self, client, as_lead, seeded_workspace_id - ): + async def test_aoi_delete_round_trip(self, client, as_lead, seeded_workspace_id): """DELETE /aoi removes the AOI; subsequent GET returns 404.""" r = await client.post( API.format(wid=seeded_workspace_id), @@ -348,15 +334,11 @@ async def test_aoi_delete_round_trip( json=SQUARE_POLY, ) - r = await client.delete( - f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" - ) + r = await client.delete(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") assert r.status_code == 204 # Subsequent GET 404s. - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") assert r.status_code == 404 @@ -380,9 +362,7 @@ async def test_list_includes_creator_auto_lead( json={"name": "roles-list-1"}, ) pid = r.json()["id"] - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/roles" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/roles") assert r.status_code == 200, r.text rows = r.json()["results"] assert len(rows) == 1 @@ -413,9 +393,7 @@ async def test_add_role_round_trip( assert body["user_id"] == str(contrib.user_uuid) assert body["role"] == "contributor" - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/roles" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/roles") ids = {row["user_id"] for row in r.json()["results"]} assert str(contrib.user_uuid) in ids @@ -527,8 +505,7 @@ async def test_remove_role_round_trip( ) r = await client.delete( - f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" - f"{contrib.user_uuid}" + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" f"{contrib.user_uuid}" ) assert r.status_code == 204 @@ -540,9 +517,7 @@ async def test_remove_role_round_trip( ) assert r.status_code == 404 - async def test_last_lead_demote_blocked( - self, client, as_lead, seeded_workspace_id - ): + async def test_last_lead_demote_blocked(self, client, as_lead, seeded_workspace_id): """Cannot demote the only LEAD — projects must always have one.""" r = await client.post( API.format(wid=seeded_workspace_id), @@ -558,9 +533,7 @@ async def test_last_lead_demote_blocked( assert r.status_code == 422, r.text assert "last lead" in r.json()["detail"].lower() - async def test_last_lead_delete_blocked( - self, client, as_lead, seeded_workspace_id - ): + async def test_last_lead_delete_blocked(self, client, as_lead, seeded_workspace_id): """Cannot delete the only LEAD — would orphan the project.""" r = await client.post( API.format(wid=seeded_workspace_id), @@ -596,8 +569,7 @@ async def test_demote_lead_works_when_two_leads_exist( # Now demote the second lead — first lead is still there. r = await client.patch( - f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" - f"{lead2.user_uuid}", + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" f"{lead2.user_uuid}", json={"role": "contributor"}, ) assert r.status_code == 200, r.text @@ -623,8 +595,7 @@ async def test_get_single_role( ) r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" - f"{contrib.user_uuid}" + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" f"{contrib.user_uuid}" ) assert r.status_code == 200, r.text body = r.json() diff --git a/tests/integration/test_tasks_flow.py b/tests/integration/test_tasks_flow.py index 1cf712a..189e791 100644 --- a/tests/integration/test_tasks_flow.py +++ b/tests/integration/test_tasks_flow.py @@ -1,5 +1,3 @@ - - from __future__ import annotations import pytest @@ -161,9 +159,7 @@ async def test_grid_blocked_without_aoi( ) pid = r.json()["id"] - r = await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid" - ) + r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid") assert r.status_code == 422, r.text assert "aoi" in r.json()["detail"].lower() @@ -187,9 +183,7 @@ async def test_grid_blocked_outside_draft( name_suffix="-grid-state", ) - r = await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid" - ) + r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid") assert r.status_code == 422, r.text assert "draft" in r.json()["detail"].lower() @@ -240,8 +234,7 @@ async def test_grid_multipolygon_straddling_cell_splits( assert len(fc["features"]) == 2, [f["geometry"] for f in fc["features"]] for feat in fc["features"]: assert feat["geometry"]["type"] == "Polygon", ( - "straddling cell was not split — got " - f"{feat['geometry']['type']}" + "straddling cell was not split — got " f"{feat['geometry']['type']}" ) # The two output polygons should align with the two lobes — @@ -290,9 +283,7 @@ class TestValidateAndSave: project_id: int | None = None - async def test_01_create_draft_with_aoi( - self, client, as_lead, seeded_workspace_id - ): + async def test_01_create_draft_with_aoi(self, client, as_lead, seeded_workspace_id): """Create a draft project and upload the project AOI.""" r = await client.post( API.format(wid=seeded_workspace_id), @@ -307,9 +298,7 @@ async def test_01_create_draft_with_aoi( ) assert r.status_code == 200, r.text - async def test_02_validate_inside_aoi( - self, client, as_lead, seeded_workspace_id - ): + async def test_02_validate_inside_aoi(self, client, as_lead, seeded_workspace_id): """Two in-AOI polygons validate cleanly with no warnings.""" r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/validate", @@ -327,9 +316,7 @@ async def test_03_validate_polygon_outside_aoi_rejected( """A polygon outside the project AOI is rejected with 422.""" outside = { "type": "Polygon", - "coordinates": [ - [[5, 5], [5.1, 5], [5.1, 5.1], [5, 5.1], [5, 5]] - ], + "coordinates": [[[5, 5], [5.1, 5], [5.1, 5.1], [5, 5.1], [5, 5]]], } r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/validate", @@ -370,9 +357,7 @@ async def test_05_save_persists_two_tasks( assert body["tasks"][0]["lock"] is None assert body["tasks"][0]["last_mapper"] is None - async def test_06_double_save_rejected( - self, client, as_lead, seeded_workspace_id - ): + async def test_06_double_save_rejected(self, client, as_lead, seeded_workspace_id): """A second save into a project that already has tasks 409s.""" r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/save", @@ -393,9 +378,7 @@ async def test_07_list_tasks_returns_geometry( assert len(body["tasks"]) == 2 assert body["tasks"][0]["geometry"]["type"] == "Polygon" - async def test_08_get_single_task( - self, client, as_lead, seeded_workspace_id - ): + async def test_08_get_single_task(self, client, as_lead, seeded_workspace_id): """GET /tasks/{n} returns one task with geometry + metadata.""" r = await client.get( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1" @@ -788,9 +771,7 @@ async def test_remap_loop( # Contributor maps → to_review. override_user(contributor) - await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" - ) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={"osm_changeset_id": 7001, "done": True}, @@ -799,9 +780,7 @@ async def test_remap_loop( # Validator validates with feedback → to_remap. override_user(validator) - await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" - ) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={ @@ -856,9 +835,7 @@ async def test_validator_cannot_validate_own_last_mapping( # Validator maps the task themselves (validators can also lock to_map). override_user(validator) - await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" - ) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={"osm_changeset_id": 9001, "done": True}, @@ -900,25 +877,21 @@ async def test_reset_releases_lock_and_resets_status( # Contributor maps → to_review. override_user(contributor) - await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" - ) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={"osm_changeset_id": 11001, "done": True}, ) # Validator picks it up. override_user(validator) - await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" - ) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") # Switch back to a LEAD token to invoke /reset. The integration # `as_lead` fixture already inserted a lead users row, so the # helper here just builds a UserInfo to bind to the override. from api.core.security import validate_token from api.main import app - from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user lead = _make_user( role="lead", diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index c19f1c9..56ab9ce 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,4 +1,3 @@ - from __future__ import annotations from datetime import datetime @@ -8,7 +7,6 @@ import pytest - # --------------------------------------------------------------------------- # Fake workspace repository — tenancy gate without a DB. # --------------------------------------------------------------------------- @@ -25,9 +23,7 @@ async def getById(self, current_user, workspace_id: int): from api.core.exceptions import NotFoundException all_ids = { - wid - for ids in current_user.accessibleWorkspaceIds.values() - for wid in ids + wid for ids in current_user.accessibleWorkspaceIds.values() for wid in ids } if workspace_id not in all_ids: raise NotFoundException(f"Workspace {workspace_id} not found") diff --git a/tests/unit/test_aoi_normalisation.py b/tests/unit/test_aoi_normalisation.py index 19d54a8..86b253b 100644 --- a/tests/unit/test_aoi_normalisation.py +++ b/tests/unit/test_aoi_normalisation.py @@ -1,5 +1,3 @@ - - from __future__ import annotations import pytest @@ -14,7 +12,6 @@ _Polygon, ) - SQUARE = [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] TWO_SQUARES = [ [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], diff --git a/tests/unit/test_dtos_validation.py b/tests/unit/test_dtos_validation.py index b7395fd..8bad12f 100644 --- a/tests/unit/test_dtos_validation.py +++ b/tests/unit/test_dtos_validation.py @@ -1,5 +1,3 @@ - - from __future__ import annotations import pytest @@ -11,7 +9,6 @@ ProjectUpdateRequest, ) - # --------------------------------------------------------------------------- # ProjectCreateRequest # --------------------------------------------------------------------------- diff --git a/tests/unit/test_project_routes.py b/tests/unit/test_project_routes.py index 86987ac..1620aba 100644 --- a/tests/unit/test_project_routes.py +++ b/tests/unit/test_project_routes.py @@ -1,7 +1,5 @@ - from __future__ import annotations - API = "/api/v1/workspaces/{wid}/tasking/projects" @@ -74,9 +72,7 @@ async def test_get_404_when_missing( self, client, as_lead, seeded_workspace_id, fake_repos ): """GET on a non-existent project id returns 404.""" - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/9999" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/9999") assert r.status_code == 404 async def test_create_then_get_round_trip( @@ -89,15 +85,11 @@ async def test_create_then_get_round_trip( ) pid = r.json()["id"] - r2 = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}" - ) + r2 = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}") assert r2.status_code == 200 assert r2.json()["id"] == pid - async def test_patch_name( - self, client, as_lead, seeded_workspace_id, fake_repos - ): + async def test_patch_name(self, client, as_lead, seeded_workspace_id, fake_repos): """PATCH updates only specified fields — name change is reflected on GET.""" pid = ( await client.post( @@ -124,14 +116,10 @@ async def test_soft_delete_204_then_404( ) ).json()["id"] - r = await client.delete( - f"{API.format(wid=seeded_workspace_id)}/{pid}" - ) + r = await client.delete(f"{API.format(wid=seeded_workspace_id)}/{pid}") assert r.status_code == 204 - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}") assert r.status_code == 404 async def test_duplicate_name_409( @@ -166,9 +154,7 @@ async def test_activate_fake_always_422( ) ).json()["id"] - r = await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/activate" - ) + r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/activate") assert r.status_code == 422 @@ -210,9 +196,7 @@ async def test_get_aoi_404_when_unset( ) ).json()["id"] - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") assert r.status_code == 404 async def test_delete_aoi_round_trip( @@ -233,12 +217,8 @@ async def test_delete_aoi_round_trip( }, ) - r = await client.delete( - f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" - ) + r = await client.delete(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") assert r.status_code == 204 - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") assert r.status_code == 404 diff --git a/tests/unit/test_user_info_gates.py b/tests/unit/test_user_info_gates.py index dcce1a4..32fcfa2 100644 --- a/tests/unit/test_user_info_gates.py +++ b/tests/unit/test_user_info_gates.py @@ -1,17 +1,10 @@ - - from __future__ import annotations from uuid import UUID -from api.core.security import ( - TdeiProjectGroupRole, - UserInfo, - UserInfoPGMembership, -) +from api.core.security import TdeiProjectGroupRole, UserInfo, UserInfoPGMembership from api.src.users.schemas import WorkspaceUserRoleType - PG = "00000000-0000-0000-0000-000000000001" @@ -21,13 +14,17 @@ def _user(*, osm_roles=None, pg_roles=None, accessible=None): u.user_uuid = UUID("11111111-1111-1111-1111-111111111111") u.user_name = "test" u.osmWorkspaceRoles = osm_roles or {} - u.projectGroups = [ - UserInfoPGMembership( - project_group_name="PG", - project_group_id=PG, - tdeiRoles=pg_roles or [TdeiProjectGroupRole.MEMBER], - ) - ] if pg_roles is not None or accessible is not None else [] + u.projectGroups = ( + [ + UserInfoPGMembership( + project_group_name="PG", + project_group_id=PG, + tdeiRoles=pg_roles or [TdeiProjectGroupRole.MEMBER], + ) + ] + if pg_roles is not None or accessible is not None + else [] + ) u.accessibleWorkspaceIds = accessible or {} return u From 90d1deff3146a827e745001987a996b529781ad2 Mon Sep 17 00:00:00 2001 From: MashB Date: Tue, 16 Jun 2026 20:31:28 +0530 Subject: [PATCH 16/26] ignore creating the extension via migration --- .../a1b2c3d4e5f6_tasking_mvp_schema.py | 46 +++++++++---------- .../c5121cbba124_initial_task_schema.py | 28 +++++------ 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py index d792f4c..068d28d 100644 --- a/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py +++ b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py @@ -47,12 +47,18 @@ def _drop_enum_if_present(bind, name: str) -> None: bind.execute(text(f'DROP TYPE IF EXISTS "{name}"')) -def _postgis_available(bind) -> bool: - return bool( +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_available_extensions WHERE name = 'postgis'") + 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: @@ -60,9 +66,7 @@ def upgrade() -> None: assert bind is not None insp = inspect(bind) - use_postgis = _postgis_available(bind) - if use_postgis: - op.execute("CREATE EXTENSION IF NOT EXISTS postgis") + _assert_postgis_installed(bind) # ---- teams / team_user ------------------------------------------- # @@ -173,12 +177,11 @@ def upgrade() -> None: sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), ) - if use_postgis: - op.execute("ALTER TABLE tasking_projects DROP COLUMN aoi") - op.execute( - "ALTER TABLE tasking_projects " - "ADD COLUMN aoi GEOMETRY(MultiPolygon, 4326)" - ) + 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( @@ -273,17 +276,14 @@ def upgrade() -> None: "project_id", "task_number", name="tasking_tasks_pn_unique" ), ) - if use_postgis: - 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)" - ) - else: - op.execute("ALTER TABLE tasking_tasks " "ADD COLUMN geometry BYTEA") + 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 ------------------------------------------------ diff --git a/alembic_task/versions/c5121cbba124_initial_task_schema.py b/alembic_task/versions/c5121cbba124_initial_task_schema.py index a06f085..a112007 100644 --- a/alembic_task/versions/c5121cbba124_initial_task_schema.py +++ b/alembic_task/versions/c5121cbba124_initial_task_schema.py @@ -12,12 +12,18 @@ depends_on: Union[str, Sequence[str], None] = None -def _postgis_available(bind) -> bool: - return bool( +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_available_extensions WHERE name = 'postgis'") + 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: @@ -25,18 +31,12 @@ def upgrade() -> None: assert bind is not None insp = inspect(bind) - use_postgis = _postgis_available(bind) - if use_postgis: - op.execute("CREATE EXTENSION IF NOT EXISTS postgis") + _assert_postgis_installed(bind) - geometry_column = ( - sa.Column( - "geometry", - Geometry(geometry_type="MULTIPOLYGON", srid=4326), - nullable=True, - ) - if use_postgis - else sa.Column("geometry", sa.Text(), nullable=True) + geometry_column = sa.Column( + "geometry", + Geometry(geometry_type="MULTIPOLYGON", srid=4326), + nullable=True, ) # The TASK tree owns `workspaces` and `workspaces_*` only. From 4be81bcdafed85f75fc60ad9fe0dbf3a4afca0bc Mon Sep 17 00:00:00 2001 From: Anuj Kumar Date: Wed, 17 Jun 2026 17:51:01 +0530 Subject: [PATCH 17/26] Tagging workflow included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## DevBoard - https://dev.azure.com/TDEI-UW/TDEI/_workitems/edit/3739 ## Changes Added a new GitHub Actions workflow (`.github/workflows/tag.yml`) that automatically updates environment tags when pull requests are merged. **Workflow Details:** - **Trigger:** Pull request closure events on `develop`, `staging`, and `production` branches (only executes when PR is merged) - **Tag Mapping:** Maps base branch to environment tag: - `develop` → `dev` - `staging` → `stage` - `production` → `prod` - **Execution:** Force-updates the git tag locally and pushes it to the remote repository with the `--force` flag - **Permissions:** Requires `contents: write` to push tags The workflow enables automated environment tagging aligned with branch-based deployments, ensuring consistent tag versions across environments. --- .github/workflows/tag.yml | 42 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/tag.yml 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 From 2c8af4856a72b824e36b8f7a29b2b5b98a0ba84d Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:27:44 +0530 Subject: [PATCH 18/26] Refactor audit event handling and update event types for tasking and project actions --- api/src/tasking/audit/dtos.py | 2 - api/src/tasking/audit/repository.py | 3 - api/src/tasking/audit/schemas.py | 2 +- api/src/tasking/projects/repository.py | 82 +++++++++++++++++++++++--- api/src/tasking/projects/routes.py | 14 ++--- api/src/tasking/tasks/repository.py | 41 +++++++------ 6 files changed, 103 insertions(+), 41 deletions(-) diff --git a/api/src/tasking/audit/dtos.py b/api/src/tasking/audit/dtos.py index b40f0f3..c8003de 100644 --- a/api/src/tasking/audit/dtos.py +++ b/api/src/tasking/audit/dtos.py @@ -21,8 +21,6 @@ class AuditEvent(WireModel): id: int event_type: AuditEventType project_id: int - task_id: Optional[int] = None - task_number: Optional[int] = None actor: ActorRef occurred_at: datetime details: dict[str, Any] diff --git a/api/src/tasking/audit/repository.py b/api/src/tasking/audit/repository.py index 4f589b4..0234f0f 100644 --- a/api/src/tasking/audit/repository.py +++ b/api/src/tasking/audit/repository.py @@ -108,13 +108,10 @@ def _row_to_event( project_deleted, ) = row details = details or {} - task_number = details.get("task_number") if isinstance(details, dict) else None return AuditEvent( id=event_id, event_type=AuditEventType(event_type), project_id=project_id, - task_id=task_id, - task_number=task_number, actor=ActorRef( user_id=UUID(str(actor_uid)), display_name=names.get(str(actor_uid)), diff --git a/api/src/tasking/audit/schemas.py b/api/src/tasking/audit/schemas.py index b24c405..9dc27be 100644 --- a/api/src/tasking/audit/schemas.py +++ b/api/src/tasking/audit/schemas.py @@ -25,7 +25,7 @@ class AuditEventType(StrEnum): PROJECT_RESET = "project_reset" AOI_UPLOADED = "aoi_uploaded" AOI_DELETED = "aoi_deleted" - TASK_CREATED = "task_created" + TASKS_CREATED = "tasks_created" TASK_STATE_CHANGED = "task_state_changed" TASK_LOCKED = "task_locked" TASK_LOCK_EXTENDED = "task_lock_extended" diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 087a978..b23ea3b 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from datetime import datetime from typing import Any from uuid import UUID @@ -9,7 +10,7 @@ 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, update +from sqlalchemy import func, text, or_, select, update from sqlalchemy.exc import IntegrityError from sqlmodel.ext.asyncio.session import AsyncSession @@ -34,6 +35,7 @@ SelfProjectRoleItem, SelfProjectRolesResponse, ) +from api.src.tasking.audit.schemas import AuditEventType from api.src.tasking.projects.schemas import ( AoiInput, ProjectRoleType, @@ -181,6 +183,28 @@ class TaskingProjectRepository: 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: @@ -511,6 +535,12 @@ async def create( }, ) + 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: @@ -529,6 +559,7 @@ async def patch( workspace_id: int, project_id: int, body: ProjectUpdateRequest, + current_user: UserInfo, ) -> ProjectResponse: project = await self._get_active(workspace_id, project_id) @@ -567,6 +598,12 @@ async def patch( .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() @@ -576,7 +613,7 @@ async def patch( 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) -> None: + 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. @@ -605,6 +642,12 @@ async def soft_delete(self, workspace_id: int, project_id: int) -> None: 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 " @@ -616,7 +659,7 @@ async def soft_delete(self, workspace_id: int, project_id: int) -> None: # ---- lifecycle transitions --------------------------------------- - async def activate(self, workspace_id: int, project_id: int) -> ProjectResponse: + async def activate(self, workspace_id: int, project_id: int, current_user: UserInfo) -> ProjectResponse: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.DRAFT: raise HTTPException( @@ -663,11 +706,16 @@ async def activate(self, workspace_id: int, project_id: int) -> ProjectResponse: .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) -> ProjectResponse: + 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( @@ -707,12 +755,17 @@ async def close(self, workspace_id: int, project_id: int) -> ProjectResponse: .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) -> ProjectResponse: + 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: @@ -749,6 +802,11 @@ async def reset(self, workspace_id: int, project_id: int) -> ProjectResponse: .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] @@ -766,7 +824,7 @@ async def get_aoi(self, workspace_id: int, project_id: int) -> AoiFeature: return _shapely_to_aoi_feature(geom) async def upload_aoi( - self, workspace_id: int, project_id: int, aoi: AoiInput + 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: @@ -793,6 +851,11 @@ async def upload_aoi( 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) @@ -1212,7 +1275,7 @@ async def _get_role_or_none( updated_at=updated, ) - async def delete_aoi(self, workspace_id: int, project_id: int) -> None: + 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( @@ -1237,6 +1300,11 @@ async def delete_aoi(self, workspace_id: int, project_id: int) -> 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() diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py index e7367f1..7389246 100644 --- a/api/src/tasking/projects/routes.py +++ b/api/src/tasking/projects/routes.py @@ -151,7 +151,7 @@ async def update_project( ): 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) + return await project_repo.patch(workspace_id, project_id, body, current_user) @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -164,7 +164,7 @@ async def delete_project( ): 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) + await project_repo.soft_delete(workspace_id, project_id, current_user) @router.post("/{project_id}/activate", response_model=ProjectResponse) @@ -177,7 +177,7 @@ async def activate_project( ): 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) + return await project_repo.activate(workspace_id, project_id, current_user) @router.post("/{project_id}/close", response_model=ProjectResponse) @@ -190,7 +190,7 @@ async def close_project( ): 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) + return await project_repo.close(workspace_id, project_id, current_user) @router.post("/{project_id}/reset", response_model=ProjectResponse) @@ -203,7 +203,7 @@ async def reset_project( ): 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) + return await project_repo.reset(workspace_id, project_id, current_user) # --------------------------------------------------------------------------- @@ -234,7 +234,7 @@ async def upload_project_aoi( ): 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) + return await project_repo.upload_aoi(workspace_id, project_id, body, current_user) # --------------------------------------------------------------------------- @@ -365,7 +365,7 @@ async def delete_project_aoi( ): 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) + await project_repo.delete_aoi(workspace_id, project_id, current_user) # --------------------------------------------------------------------------- diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index 0e785be..3c6219a 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -23,6 +23,7 @@ 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 ( @@ -281,7 +282,7 @@ async def _project_role( async def _audit( self, *, - event_type: str, + event_type: AuditEventType, project_id: int, task_id: Optional[int], actor_uuid: UUID, @@ -547,15 +548,13 @@ async def save( ) ) - # Audit one row per task. - for t in created: - await self._audit( - event_type="task_created", - project_id=project.id, # type: ignore[arg-type] - task_id=t.id, - actor_uuid=current_user.user_uuid, - details={"taskNumber": t.task_number}, - ) + 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( @@ -729,7 +728,7 @@ async def lock_task( self.session.add(lock) await self.session.flush() await self._audit( - event_type="task_locked", + event_type=AuditEventType.TASK_LOCKED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -777,7 +776,7 @@ async def unlock_task( .values(released_at=now, release_reason=release_reason) ) await self._audit( - event_type="task_unlocked", + event_type=AuditEventType.TASK_UNLOCKED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -820,7 +819,7 @@ async def extend_lock( .values(expires_at=new_expiry) ) await self._audit( - event_type="task_lock_extended", + event_type=AuditEventType.TASK_LOCK_EXTENDED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -861,7 +860,7 @@ async def reset_task( ) ) await self._audit( - event_type="task_unlocked", + event_type=AuditEventType.TASK_UNLOCKED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -883,7 +882,7 @@ async def reset_task( ) ) await self._audit( - event_type="task_state_changed", + event_type=AuditEventType.TASK_STATE_CHANGED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -902,7 +901,7 @@ async def reset_task( ) await self._audit( - event_type="task_reset", + event_type=AuditEventType.TASK_RESET, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -963,7 +962,7 @@ async def submit( await self.session.flush() await self._audit( - event_type="changeset_submitted", + event_type=AuditEventType.CHANGESET_SUBMITTED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -983,7 +982,7 @@ async def submit( .values(expires_at=new_expiry) ) await self._audit( - event_type="task_lock_renewed", + event_type=AuditEventType.TASK_LOCK_RENEWED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -1029,7 +1028,7 @@ async def submit( ) ) await self._audit( - event_type="feedback_submitted", + event_type=AuditEventType.FEEDBACK_SUBMITTED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -1052,7 +1051,7 @@ async def submit( ) ) await self._audit( - event_type="task_unlocked", + event_type=AuditEventType.TASK_UNLOCKED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -1074,7 +1073,7 @@ async def submit( ) if new_status != previous_status: await self._audit( - event_type="task_state_changed", + event_type=AuditEventType.TASK_STATE_CHANGED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, From 2c3d6a4d8536c8636d719126ffc3337e481a58bc Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:19:16 +0530 Subject: [PATCH 19/26] Refactor function signatures and import order in TaskingProjectRepository --- api/src/tasking/projects/repository.py | 28 ++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index b23ea3b..eaa81eb 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -10,7 +10,7 @@ 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, text, or_, select, update +from sqlalchemy import func, or_, select, text, update from sqlalchemy.exc import IntegrityError from sqlmodel.ext.asyncio.session import AsyncSession @@ -20,6 +20,7 @@ NotFoundException, ) from api.core.security import UserInfo +from api.src.tasking.audit.schemas import AuditEventType from api.src.tasking.projects.dtos import ( AoiFeature, Pagination, @@ -35,7 +36,6 @@ SelfProjectRoleItem, SelfProjectRolesResponse, ) -from api.src.tasking.audit.schemas import AuditEventType from api.src.tasking.projects.schemas import ( AoiInput, ProjectRoleType, @@ -602,7 +602,9 @@ async def patch( 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"]}, + details={ + "updatedFields": [k for k in updates if k != "updated_at"] + }, ) await self.session.commit() except IntegrityError as e: @@ -613,7 +615,9 @@ async def patch( 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: + 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. @@ -659,7 +663,9 @@ async def soft_delete(self, workspace_id: int, project_id: int, current_user: Us # ---- lifecycle transitions --------------------------------------- - async def activate(self, workspace_id: int, project_id: int, current_user: UserInfo) -> ProjectResponse: + 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( @@ -715,7 +721,9 @@ async def activate(self, workspace_id: int, project_id: int, current_user: UserI 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: + 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( @@ -765,7 +773,9 @@ async def close(self, workspace_id: int, project_id: int, current_user: UserInfo 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: + 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: @@ -1275,7 +1285,9 @@ async def _get_role_or_none( updated_at=updated, ) - async def delete_aoi(self, workspace_id: int, project_id: int, current_user: UserInfo) -> None: + 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( From b09eea1cc5e7c1fb67e40f4e0c091f1d59d391f3 Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:28:33 +0530 Subject: [PATCH 20/26] Add current_user parameter to patch, soft_delete, activate, close, reset, upload_aoi, and delete_aoi methods in FakeProjectRepo --- tests/unit/conftest.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 56ab9ce..20ac3f5 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -175,7 +175,7 @@ async def get(self, workspace_id: int, project_id: int): raise NotFoundException(f"Project {project_id} not found") return self._response(p) - async def patch(self, workspace_id, project_id, body): + 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: @@ -189,11 +189,11 @@ async def patch(self, workspace_id, project_id, body): p["updated_at"] = datetime.now() return self._response(p) - async def soft_delete(self, workspace_id, project_id): + 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): + async def activate(self, workspace_id, project_id, current_user): from fastapi import HTTPException, status # Mirrors the real repo's "needs tasks" rule. @@ -202,7 +202,7 @@ async def activate(self, workspace_id, project_id): detail="Project must have at least one task", ) - async def close(self, workspace_id, project_id): + async def close(self, workspace_id, project_id, current_user): from fastapi import HTTPException, status raise HTTPException( @@ -210,7 +210,7 @@ async def close(self, workspace_id, project_id): detail="Only open projects can be closed", ) - async def reset(self, workspace_id, project_id): + async def reset(self, workspace_id, project_id, current_user): await self.get(workspace_id, project_id) return self._response(self._projects[project_id]) @@ -233,7 +233,7 @@ async def get_aoi(self, workspace_id, project_id): properties={}, ) - async def upload_aoi(self, workspace_id, project_id, aoi): + 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 @@ -248,7 +248,7 @@ async def upload_aoi(self, workspace_id, project_id, aoi): properties={}, ) - async def delete_aoi(self, workspace_id, project_id): + async def delete_aoi(self, workspace_id, project_id, current_user): from api.core.exceptions import NotFoundException await self.get(workspace_id, project_id) From 14994f864170216ff06fea05ec0128b1b7cf4628 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Wed, 24 Jun 2026 15:58:41 +0530 Subject: [PATCH 21/26] Geojson file for fetching the task data Task data fetching URL --- api/src/tasking/tasks/repository.py | 24 ++++++++++++++++++++++++ api/src/tasking/tasks/routes.py | 8 ++++++++ 2 files changed, 32 insertions(+) diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index 3c6219a..28b70af 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -645,6 +645,30 @@ async def get_task( 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 ---------------------------------- diff --git a/api/src/tasking/tasks/routes.py b/api/src/tasking/tasks/routes.py index 24bf14e..f38f7ca 100644 --- a/api/src/tasking/tasks/routes.py +++ b/api/src/tasking/tasks/routes.py @@ -166,6 +166,14 @@ async def get_task( 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}.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 From de3189160c0c3ff41bb2ed419cf09ef2597e17d3 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Wed, 24 Jun 2026 20:25:26 +0530 Subject: [PATCH 22/26] Update routes.py --- api/src/tasking/tasks/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/tasking/tasks/routes.py b/api/src/tasking/tasks/routes.py index f38f7ca..20c02c7 100644 --- a/api/src/tasking/tasks/routes.py +++ b/api/src/tasking/tasks/routes.py @@ -166,7 +166,7 @@ async def get_task( 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}.geojson", response_model=TaskBoundariesFeatureCollection) +@router.get("/tasks/{task_number}/boundary.geojson", response_model=TaskBoundariesFeatureCollection) async def get_task_geojson( workspace_id: int, project_id: int, From 7abb5a0fab4043f6572dc5fc516fc39a095cf1f0 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Wed, 24 Jun 2026 20:31:21 +0530 Subject: [PATCH 23/26] Linting issues fixed --- api/src/tasking/tasks/repository.py | 3 +-- api/src/tasking/tasks/routes.py | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index 28b70af..60d8ddc 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -645,7 +645,7 @@ async def get_task( 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: @@ -668,7 +668,6 @@ async def get_task_geojson( features=[task_feature], ) return task_feature_collection - # ---- lock / unlock / extend / reset ---------------------------------- diff --git a/api/src/tasking/tasks/routes.py b/api/src/tasking/tasks/routes.py index 20c02c7..bef56ac 100644 --- a/api/src/tasking/tasks/routes.py +++ b/api/src/tasking/tasks/routes.py @@ -166,7 +166,11 @@ async def get_task( 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) + +@router.get( + "/tasks/{task_number}/boundary.geojson", + response_model=TaskBoundariesFeatureCollection, +) async def get_task_geojson( workspace_id: int, project_id: int, @@ -175,6 +179,7 @@ async def get_task_geojson( ): return await task_repo.get_task_geojson(workspace_id, project_id, task_number) + # --------------------------------------------------------------------------- # Locks — acquire / release / extend / reset # --------------------------------------------------------------------------- From ed9cf1e77090063814ad6a5a9083adfc2aa3f7da Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Fri, 26 Jun 2026 11:48:14 +0530 Subject: [PATCH 24/26] added Route to submit Changesets for task - Added route to submit changeset for a task - Removed the changeset ID from Submit request. --- api/src/tasking/tasks/dtos.py | 16 ++++- api/src/tasking/tasks/repository.py | 90 +++++++++++++++++++++------- api/src/tasking/tasks/routes.py | 31 ++++++++++ tests/integration/test_tasks_flow.py | 14 ++--- 4 files changed, 120 insertions(+), 31 deletions(-) diff --git a/api/src/tasking/tasks/dtos.py b/api/src/tasking/tasks/dtos.py index 374bed2..79a8bdb 100644 --- a/api/src/tasking/tasks/dtos.py +++ b/api/src/tasking/tasks/dtos.py @@ -110,11 +110,25 @@ class FeedbackInput(WireModel): class SubmitRequest(WireModel): - osm_changeset_id: int = PydField(ge=1) + # 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 diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index 60d8ddc..b29ff53 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -972,29 +972,29 @@ async def submit( 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, - }, - ) + # # 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. @@ -1111,5 +1111,49 @@ async def submit( 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, + }, + ) + + 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 index bef56ac..08fe0b3 100644 --- a/api/src/tasking/tasks/routes.py +++ b/api/src/tasking/tasks/routes.py @@ -21,6 +21,8 @@ SaveTasksRequest, SaveTasksResponse, SubmitRequest, + SubmitTaskChangeset, + SubmitTaskChangesetResponse, TaskBoundariesFeatureCollection, TaskListResponse, TaskResponse, @@ -273,3 +275,32 @@ async def submit_task( 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/tests/integration/test_tasks_flow.py b/tests/integration/test_tasks_flow.py index 189e791..b2e7ecc 100644 --- a/tests/integration/test_tasks_flow.py +++ b/tests/integration/test_tasks_flow.py @@ -653,7 +653,7 @@ async def test_02_contributor_lock_and_submit_done( r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/submit", - json={"osm_changeset_id": 1001, "done": True}, + json={ "done": True}, ) assert r.status_code == 200, r.text body = r.json() @@ -688,7 +688,7 @@ async def test_05_validator_submit_done_no_feedback_completes( override_user(self.validator) r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/submit", - json={"osm_changeset_id": 1002, "done": True}, + json={ "done": True}, ) assert r.status_code == 200, r.text body = r.json() @@ -732,7 +732,7 @@ async def test_submit_done_false_slides_expiry( r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", - json={"osm_changeset_id": 5001, "done": False}, + json={ "done": False}, ) assert r.status_code == 200, r.text body = r.json() @@ -774,7 +774,7 @@ async def test_remap_loop( await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", - json={"osm_changeset_id": 7001, "done": True}, + json={ "done": True}, ) assert r.json()["status"] == "to_review" @@ -784,7 +784,7 @@ async def test_remap_loop( r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={ - "osm_changeset_id": 7002, + "done": True, "feedback": { "reason_category": "incomplete_mapping", @@ -838,7 +838,7 @@ async def test_validator_cannot_validate_own_last_mapping( await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", - json={"osm_changeset_id": 9001, "done": True}, + json={ "done": True}, ) # Now they try to validate their own work → 403. @@ -880,7 +880,7 @@ async def test_reset_releases_lock_and_resets_status( await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", - json={"osm_changeset_id": 11001, "done": True}, + json={ "done": True}, ) # Validator picks it up. override_user(validator) From 24ace556457b258a67878c122821f18fd9300cac Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Fri, 26 Jun 2026 11:54:35 +0530 Subject: [PATCH 25/26] Update test_tasks_flow.py Linting issue fixed --- tests/integration/test_tasks_flow.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/integration/test_tasks_flow.py b/tests/integration/test_tasks_flow.py index b2e7ecc..5af5c50 100644 --- a/tests/integration/test_tasks_flow.py +++ b/tests/integration/test_tasks_flow.py @@ -653,7 +653,7 @@ async def test_02_contributor_lock_and_submit_done( r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/submit", - json={ "done": True}, + json={"done": True}, ) assert r.status_code == 200, r.text body = r.json() @@ -688,7 +688,7 @@ async def test_05_validator_submit_done_no_feedback_completes( override_user(self.validator) r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/submit", - json={ "done": True}, + json={"done": True}, ) assert r.status_code == 200, r.text body = r.json() @@ -732,7 +732,7 @@ async def test_submit_done_false_slides_expiry( r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", - json={ "done": False}, + json={"done": False}, ) assert r.status_code == 200, r.text body = r.json() @@ -774,7 +774,7 @@ async def test_remap_loop( 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}, + json={"done": True}, ) assert r.json()["status"] == "to_review" @@ -784,7 +784,6 @@ async def test_remap_loop( r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={ - "done": True, "feedback": { "reason_category": "incomplete_mapping", @@ -838,7 +837,7 @@ async def test_validator_cannot_validate_own_last_mapping( 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}, + json={"done": True}, ) # Now they try to validate their own work → 403. @@ -880,7 +879,7 @@ async def test_reset_releases_lock_and_resets_status( 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}, + json={"done": True}, ) # Validator picks it up. override_user(validator) From 47e272c81519c8fcee66133bde7ef723c7955a63 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Fri, 26 Jun 2026 11:57:55 +0530 Subject: [PATCH 26/26] Update repository.py Minor fix --- api/src/tasking/tasks/repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index b29ff53..ad99c12 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -1152,7 +1152,7 @@ async def submit_changeset( "osmChangesetId": changesetId, }, ) - + await self.session.commit() return cs.id # Return the ID of the newly inserted changeset row