diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..ea52c82 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Bash(grep -rln *)", + "Bash(grep -nA12 '\\\\[project.optional-dependencies\\\\]\\\\|optional-dependencies\\\\|\\\\[dependency-groups\\\\]' pyproject.toml)", + "Bash(grep -vE \"^$\")", + "Bash(git ls-tree *)", + "Bash(grep -E '\\\\.py$')", + "Bash(grep -v '\\\\.sample$')", + "Bash(git config *)", + "Bash(command -v gh)", + "Bash(gh api *)", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('protected:', d.get\\('protected'\\)\\); p=d.get\\('protection',{}\\); print\\('required_pull_request_reviews:', 'yes' if p.get\\('required_pull_request_reviews'\\) else 'no'\\); print\\('enforce_admins:', \\(p.get\\('enforce_admins'\\) or {}\\).get\\('enabled'\\)\\)\")", + "Bash(grep -nA6 \"TENANT_BYPASSES: list\" api/main.py)", + "Bash(grep -nA10 \"STRIP_REQUEST_HEADERS = \" api/main.py)" + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c72a0ff..35d921d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,81 +2,41 @@ name: CI on: push: - branches: [ main ] + branches: [ develop ] pull_request: - branches: [ main ] + branches: [ develop ] + +# Cancel any in-progress run for the same branch/PR when a newer commit lands. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - lint: + ci: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.12"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@v6 with: version: "0.5.8" - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install the project - run: uv sync --all-extras - - - name: Check imports with isort - run: uv run isort --check-only --diff . - - - name: Check code formatting with black - run: uv run black --check . + enable-cache: true - test: - needs: lint - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12"] - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v4 - with: - version: "0.5.8" - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: - python-version: "3.12" - - - name: Install the project - run: uv sync --all-extras - - - name: Run tests - run: uv run pytest tests - env: - TASK_DATABASE_URL: "postgresql+asyncpg://postgres:postgres@localhost:5432/test_db" - OSM_DATABASE_URL: "postgresql+asyncpg://postgres:postgres@localhost:5432/test_db" - JWT_SECRET: "test_secret_key" - JWT_ALGORITHM: "HS256" - - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: test_db - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 \ No newline at end of file + python-version: ${{ matrix.python-version }} + + # Runs the same checks locals get from scripts/ci.sh: uv sync, isort, + # black, pyright, and pytest. `--integration` additionally runs the + # PostGIS/testcontainers suite (`pytest -m integration`); the ubuntu-latest + # runner ships a running Docker daemon, which testcontainers needs to boot + # the database. The script runs every check and exits non-zero if any fail, + # so a single red step still lists all failures. + - name: Run CI checks + run: ./scripts/ci.sh --integration diff --git a/.github/workflows/push-docker-image.yml b/.github/workflows/push-docker-image.yml new file mode 100644 index 0000000..f8cdee6 --- /dev/null +++ b/.github/workflows/push-docker-image.yml @@ -0,0 +1,64 @@ +name: Push Docker Image to Azure Container Registry + +on: + workflow_dispatch: + inputs: + environment: + description: 'Environment to deploy to' + required: true + default: 'develop' + type: choice + options: + - develop + - staging + - production + - testing + +jobs: + set-github-environment: + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.set-env.outputs.target_env }} + steps: + - name: Set Environment + id: set-env + run: | + case "${{ inputs.environment }}" in + testing) echo "target_env=test" >> $GITHUB_OUTPUT ;; + develop) echo "target_env=dev" >> $GITHUB_OUTPUT ;; + staging) echo "target_env=stage" >> $GITHUB_OUTPUT ;; + production) echo "target_env=prod" >> $GITHUB_OUTPUT ;; + esac + build: + runs-on: ubuntu-latest + name: "Build" + needs: set-github-environment + concurrency: + group: build + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.environment }} + + - uses: docker/login-action@v3 + with: + registry: ${{ vars.DOCKERHUB_REGISTRY }} + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + platforms: linux/amd64 + tags: ${{ vars.DOCKERHUB_REGISTRY }}/workspaces-backend-v2:${{ needs.set-github-environment.outputs.environment }}, + ${{ vars.DOCKERHUB_REGISTRY }}/workspaces-backend-v2:${{github.sha}} + \ No newline at end of file diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..229dea4 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,42 @@ +# Whenever there is a pull request merged into the branches, create tag +name: Update Environment Tag +on: + pull_request: + types: [closed] + branches: + - develop + - staging + - production +jobs: + update-tag: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Get the current branch name + id: get_branch + run: | + TARGET_BRANCH="${{ github.event.pull_request.base.ref }}" + + if [ "$TARGET_BRANCH" = "develop" ]; then + echo "ENV_TAG=dev" >> $GITHUB_ENV + elif [ "$TARGET_BRANCH" = "staging" ]; then + echo "ENV_TAG=stage" >> $GITHUB_ENV + elif [ "$TARGET_BRANCH" = "production" ]; then + echo "ENV_TAG=prod" >> $GITHUB_ENV + else + echo "ENV_TAG=" >> $GITHUB_ENV + fi + - name: Force update tag + if: ${{ env.ENV_TAG != '' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + echo "Targeting tag: ${{ env.ENV_TAG }}" + git tag -f $ENV_TAG + git push origin $ENV_TAG --force diff --git a/.github/workflows/trigger-deploy-on-merge.yml b/.github/workflows/trigger-deploy-on-merge.yml new file mode 100644 index 0000000..82265c8 --- /dev/null +++ b/.github/workflows/trigger-deploy-on-merge.yml @@ -0,0 +1,36 @@ +name: Trigger Deployment on Pull Request Merge +on: + pull_request: + types: [closed] + branches: [ develop, staging, production] +permissions: + contents: read + actions: write +jobs: + on-merge: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.set-environment.outputs.environment }} + steps: + - name: Set Environment + id: set-environment + run: | + case "${{ github.base_ref }}" in + develop) echo "environment=develop" >> $GITHUB_OUTPUT ;; + staging) echo "environment=staging" >> $GITHUB_OUTPUT ;; + production) echo "environment=production" >> $GITHUB_OUTPUT ;; + testing) echo "environment=testing" >> $GITHUB_OUTPUT ;; + esac + deploy: + needs: on-merge + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Trigger Deployment Workflow + run: | + gh workflow run push-docker-image.yml \ + --repo ${{ github.repository }} \ + --ref ${{ github.base_ref }} \ + --field environment=${{ needs.on-merge.outputs.environment }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 42694c4..d929d58 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,11 @@ alembic/versions/.DS_Store /workspaces-openstreetmap-website pg_user_cache.sqlite -.env** \ No newline at end of file +.env** +integration-report.html +docs/tasking-mvp/tasking-mvp.postman_collection.json +docs/tasking-mvp/tasking-mvp.postman_environment.json +docs/tasking-mvp/_enrich_postman.py +docs/tasking-mvp/feature-coverage.md +.idea/ +data/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 47d11bc..95f6511 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,7 +12,7 @@ repos: name: isort (python) - repo: https://github.com/psf/black - rev: 24.1.1 + rev: 24.10.0 hooks: - id: black language_version: python3.12 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/.vscode/settings.json b/.vscode/settings.json index 5f626e0..f8efae9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,16 @@ "alembic" ], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + "isort.args": [ + "--profile", + "black" + ] } \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d15dea5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,266 @@ +# CLAUDE.md + +Guidance for working in this repo. Focused on the test infrastructure and +conventions established for it; see `README.md` for app setup. + +## Permission Structure + +Project Group Admin ("POC") +* Superuser for the whole project group +* Implied by "poc" role in TDEI + +Lead/Owner/Workspace Admin +* Admin-level access for a workspace +* Configures workspace settings and quest definitions +* Assigns users to workspace teams +* Ability to merge changes from other workspace +* Exports data to TDEI (with appropriate TDEI core roles) +* Granted by Workspaces setting. + +Contributor/Data Generator +* Modifies workspace data--all modifications need validation +* Implied by membership in TDEI project group + +Validator +* Modifies workspace data and approves changes from contributors +* Granted by Workspaces setting. + +Viewer/Member/Everyone Else +* Read-only access to workspace data +* With express TDEI sign-up, the need for this access level diminishes greatly +* Granted by Workspaces setting. + +## What Each Role Can Do + +Project Lead +* Edit Metadata +* Edit Longform Quests +* Toggle App-Enabled Flag +* Delete Workspace +* Define User Teams +* Define Groups or Roles +* Export to TDEI +* Validate Changeset +* Move Workspace from Project Group to Project Group +* Edit POSM Element + +Validator +* Export to TDEI +* Validate Changeset +* Edit POSM Element + +Contributor +* Edit POSM Element + +Authenticated User With PG/Workspace Association +* Edit POSM Element + +### What this backend actually enforces (vs. the matrix above) + +The matrix above is the intended product model. It is only **partially** +enforced in `api/` — this service is a proxy in front of the OSM website, +cgimap, and TDEI, so several capabilities are enforced downstream (or not yet +at all). Validated against the code: + +**Enforced here, Lead-gated (`isWorkspaceLead` → 403).** POC inherits these +(POC on the owning project group satisfies `isWorkspaceLead`): + +| Capability | Endpoint | +|---|---| +| Edit Metadata | PATCH `/workspaces/{id}` | +| Edit Longform Quests | PATCH `/workspaces/{id}/quests/long/settings` | +| Toggle App-Enabled Flag | PATCH `/workspaces/{id}` (`externalAppAccess`) | +| Delete Workspace | DELETE `/workspaces/{id}` | +| Define User Teams | `/workspaces/{id}/teams...` (create/update/delete/members) | +| Define Groups or Roles | PUT/DELETE `/workspaces/{id}/users/{user_id}...` | + +**Not enforced / not present here:** + +* **Export to TDEI** — no endpoint exists in this backend. +* **Move Workspace PG→PG** — not possible; `WorkspacePatch` has no + `tdeiProjectGroupId` field, so no route can change a workspace's project group. +* **Edit POSM Element** — goes through the OSM proxy catch-all + (`api/main.py`), which gates *every* proxied operation on + `isWorkspaceContributor` alone. There is no Validator- or Lead-level check on + proxied traffic. Raw changeset commits (proxied `PUT /api/0.6/changeset/...`) + are likewise Contributor-gated; the proxy only *tags* a contributor's new + changeset with `review_requested=yes` when the workspace has `autoFlagReview` + set — it does not enforce validation. + +**Enforced here, Validator-gated (`isWorkspaceLead || isWorkspaceValidator` → +403).** This is a native FastAPI route, not proxied traffic. Leads (and POC via +`isWorkspaceLead`) inherit it: + +| Capability | Endpoint | +|---|---| +| Resolve/Validate Changeset | PUT `/workspaces/{id}/changesets/{changeset_id}/resolve` | + +Resolving clears the `review_requested` tag and stamps `reviewed_by` with the +reviewer's UUID. The gate is enforced both in the route +(`api/src/osm/routes.py`) and, defensively, inside +`OSMRepository.resolveChangeset` (`api/src/osm/repository.py`). + +**The Validator role grants exactly one thing at this layer:** the ability to +resolve changesets via the endpoint above. Aside from that, a Validator and a +Contributor have identical permissions in this backend. `isWorkspaceValidator` +otherwise only appears in the `role` field of `WorkspaceResponse`. + +**"Contributor" and "Authenticated User With PG/Workspace Association" are the +same gate.** `isWorkspaceContributor` simply checks whether the workspace is in +one of the user's project groups (`accessibleWorkspaceIds`), i.e. PG/workspace +association — so both rows collapse to the same check. + +Changeset *resolution* is Validator/Lead-gated here (see above). But the +Validator/Lead distinction on raw changeset *commits* and TDEI export is not +enforced at this layer; if required, it must be enforced downstream +(`workspaces-openstreetmap-website/`, `workspaces-cgimap/`) — that has not been +audited here. + +## The OSM proxy layer: routing, auth, and user provisioning + +This service is a reverse proxy in front of the OSM website (`osm-rails`) and +cgimap. The non-obvious parts, learned the hard way: + +### Deployment routing (workspaces-stack) + +In `workspaces-stack`, the **api container (this backend) serves the public OSM +host** (`osm.workspaces-...`) alongside the API hosts — its traefik router +rule is `Host(new-api) || Host(api) || Host(osm)`, and the `osm-web` / +`osm-log-proxy` routers are commented out. So **every OSM call the frontend +makes routes through this backend**: `validate_token`, the `X-Workspace` gate, +and the CORS middleware all apply. The backend then proxies `/api/0.6/*` to +`WS_OSM_HOST` (config default `http://osm-web` — the internal service, *not* the +public domain). `osm-web`'s nginx splits traffic: changeset read/write subpaths +to cgimap, everything else to osm-rails. + +The frontend (`services/osm.ts`) calls the OSM host directly with +`credentials: 'include'` + a Bearer TDEI token. Because it's *credentialed* +CORS, `CORS_ORIGINS` must list the exact frontend origin (no `*`; +`allow_credentials` is on). The stack supplies `WS_API_CORS_ORIGINS` as a **JSON +array** (`["https://..."]`), while older config comma-split it — a JSON array +would then become one malformed origin. `api/main.py` therefore reads +`settings.cors_origins_list`, which parses **both** a JSON array and a +comma-separated string (and `*`); set `CORS_ORIGINS` in either form. + +### Two databases; `users` is owned by OSM Rails + +`TASK_DATABASE_URL` (`alembic_task` tree) holds workspaces / tasking-manager +tables; `OSM_DATABASE_URL` (`alembic_osm` tree) holds OSM data, `users`, and the +`tasking_*` tables. The **`users` table is owned by the OSM Rails website**, not +either alembic tree — the trees only FK to it, and integration tests stub it +(`tests/integration/conftest.py`). The backend provisions `users` rows itself +via raw SQL with `auth_provider='TDEI'` and `auth_uid = str()` (the OSM +`auth_uid` **is** the token's `sub` claim). + +### How OSM authenticates, and the TDEI token bridge + +* **osm-rails** authenticates API calls *only* via **doorkeeper OAuth2**: it + looks the bearer token up in `oauth_access_tokens` (`token` → + `resource_owner_id` → `users.id`). Doorkeeper stores tokens **plaintext** + (`SecretStoring::Plain`), so the raw JWT matches directly. It has **no** + TDEI/JWT auth path. +* **cgimap** has both: the oauth2 lookup *and* a custom TDEI path + (`get_user_id_for_tdei_token`, which verifies the JWT and matches + `users.auth_uid`). + +To make TDEI tokens work against osm-rails without forking it, the **token +bridge** (`_bridge_token_to_osm` in `api/core/security.py`) mirrors a validated +TDEI JWT into `oauth_access_tokens` — on the cache-miss path of `validate_token` +(so ~once per token, not per request). Gated by `WS_OSM_TOKEN_BRIDGE_ENABLED`. +It auto-provisions everything: a dedicated **system user** (`WS_OSM_SYSTEM_USER_*`) +to own the doorkeeper `oauth_applications` row it creates (keyed by +`WS_OSM_OAUTH_CLIENT_UID`; `oauth_applications.owner_id` is a NOT-NULL FK to +`users`, hence the system user), the caller's `users` row, and the plaintext +`oauth_access_tokens` row (`expires_in` from the JWT `exp`; +`ON CONFLICT (token) DO UPDATE` so re-presenting reactivates it). On token +rotation (new `jti`) the superseded token is revoked. Since the token then lives +in `oauth_access_tokens`, both osm-rails and cgimap authenticate it via plain +OAuth2 — cgimap's custom TDEI path becomes redundant. + +### Provisioning gotcha: `pass_crypt` must be 8..255 chars + +The OSM `User` model has `validates :pass_crypt, :length => 8..255`. When the +backend provisions a `users` row, **`pass_crypt` must be 8–255 chars** (use a +random throwaway — TDEI manages auth, so it's never used to log in). A too-short +value (the old `'none'`) makes the user *invalid*. This is silent for **cgimap** +operations — cgimap runs no Rails validations — but breaks any **osm-rails** +operation that re-validates the author via `validates :author, :associated => +true`: notably `POST /api/0.6/changeset/{id}/comment` and note comments. The +comment fails to save (no `id`), then rendering it throws *"Unable to serialize +… without an id"*. Both provisioning paths (`_ensure_osm_user`, +`_provision_users_from_tdei`) set a valid `pass_crypt`; migration +`alembic_osm/versions/f3a7b9c1d2e4` heals legacy short rows. General principle: +a backend-provisioned `users` row must satisfy OSM's `User` validations (also +`display_name` 3..255 + unique, `email` present + unique) or Rails operations +that touch it will fail even though cgimap operations succeed. + +## Testing + +Two layers, both fast and dependency-free (no Postgres, PostGIS, Docker, or +network). `tests/README.md` has the full reference; the essentials: + +* **Unit** (`tests/unit/`) — pure logic and individual classes (permission + rules, schema/DTO behavior, a repository in isolation). +* **Integration** (`tests/integration/`) — real HTTP requests driven through + the real FastAPI app: routing, auth wiring, repositories, serialization. + +### The mocking boundary is the "data fetcher", not the repository + +Integration tests run the **real** routes and repositories. Only three things +are swapped out, via `app.dependency_overrides` and a fake: + +1. `get_task_session` / `get_osm_session` → a `FakeSession` (in + `tests/support/fakes.py`) that returns pre-programmed `FakeResult`s instead + of running SQL. This is the data-fetcher boundary: everything above the + `AsyncSession` runs for real. +2. `validate_token` → a real `UserInfo` built by `tests/support/factories.py` + (skips JWT decode + the TDEI call; the permission logic is still real). +3. `api.main._osm_client` → a streamable mock transport (proxy tests only; + `tests/support/http.py`). + +Because the mock is at the session level, **queue results in the order the +repository issues queries**. Routes that touch both DBs queue on both +`task_session` and `osm_session`. Builders: `rows()`, `empty()`, `affected(n)`, +`mappings()`, `scalar(v)`, and `raises(exc)` (drives 500 paths). The +`error_client` fixture turns unhandled exceptions into 500 responses (httpx's +ASGI transport re-raises by default). + +### `@test:` comment outlines + +Modules carry `# @test:` comments describing intended coverage. They are the +spec for the test suite; when adding behavior, add matching `@test:` lines and +tests. Treat the docstring/attribute comments as authoritative when they and +the code disagree — file a fix rather than silently matching the code. + +### Known behavior discrepancy: read endpoints return 404, not 403 + +Several `@test:` comments on read endpoints (get workspace, list teams, quest +and imagery GETs) specify a **403** when the caller lacks access. The code +enforces access via `WorkspaceRepository.getById`, which raises **404 +NotFound** when the workspace is missing *or* inaccessible — so "not a member" +currently surfaces as 404 on those routes. The tests assert the actual 404 +behavior and flag this in their docstrings. If 403 is the intended contract, +that is a code change in the read routes, not a test change. + +### SQLModel + Pyright + +SQLModel declares columns as plain annotations (e.g. `id: int | None`) rather +than `Mapped[int]`, so Pyright reads `Column == value` as `bool` and flags +`where()`/`exec()`/`select()`/`selectinload` calls and `result.rowcount`. These +are framework false positives. Suppress them with **targeted, inline** +`# pyright: ignore[]` comments at the specific offending call sites (e.g. +`# pyright: ignore[reportArgumentType]` on a `.where(Column == value)` line) — +not blanket file-level `# pyright:` directives, which would hide genuine errors +of those rules elsewhere in the file. Note Black may wrap a long query line and +move a trailing comment off the flagged line; place the ignore on the line +Pyright actually reports (often the inner `== value` line) so it survives +formatting. Keep `api/` and `tests/` at zero Pyright errors. + +### Alembic enum migrations + +Postgres `ENUM` types must be created/dropped idempotently. Declare the enum +with `create_type=False` and manage it explicitly with +`enum.create(op.get_bind(), checkfirst=True)` / `enum.drop(..., checkfirst=True)` +so a migration is safe whether or not the type already exists (and never +double-creates it via implicit table DDL). + diff --git a/LICENSE b/LICENSE.template similarity index 100% rename from LICENSE rename to LICENSE.template diff --git a/README.md b/README.md index 7ac5971..b76ca45 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,126 @@ This is a combination API backend for workspaces, providing /workspaces* methods the OSM API ("openstreetmap website") plus OSM CGI-map (the C-accelerated methods) that enforces authorization and authentication based on a TDEI/Keycloak JWT token (see main.py for this proxy logic). +## What the proxy must provide for osm-rails / osm-web + +This backend is the **only entry point** to the OSM tier (osm-rails + cgimap, +behind `osm-web`): in the deployment, the public OSM host routes to this +container, which proxies `/api/0.6/*` to `WS_OSM_HOST` (default +`http://osm-web`). For the OSM services to work, the proxy must uphold the +following contract. `CLAUDE.md` has the full rationale. + +1. **Bridge TDEI auth into OSM's OAuth2.** osm-rails authenticates the API *only* + via doorkeeper OAuth2 (`oauth_access_tokens`); it has no TDEI/JWT path. So on + token validation the backend mirrors the TDEI JWT into `oauth_access_tokens` + in the OSM DB (the "token bridge" in `api/core/security.py`), and forwards the + incoming `Authorization: Bearer ` header unchanged. Then osm-rails and + cgimap authenticate the token via plain OAuth2. Controlled by + `WS_OSM_TOKEN_BRIDGE_ENABLED` (on by default) — with it off, osm-rails returns + **401** for TDEI tokens. The backend auto-creates the doorkeeper application + (and a system user to own it), so no manual OSM setup is required. + +2. **Provision valid OSM `users` rows.** The backend creates OSM `users` rows for + TDEI users (`auth_provider='TDEI'`, `auth_uid` = the JWT `sub`). These must + satisfy OSM's `User` validations — in particular `pass_crypt` length 8..255. + A too-short value is invisible to cgimap but makes osm-rails operations that + re-validate the user fail (e.g. posting a changeset comment or a note), + surfacing as *"Unable to serialize … without an id"*. The + `alembic_osm` migration `*_heal_short_tdei_pass_crypt` repairs legacy rows. + +3. **Carry workspace tenancy.** Workspace-scoped OSM requests must include an + `X-Workspace: ` header. The proxy authorizes it against the caller's + workspaces and forwards it (it is *not* stripped) so cgimap/osm-rails scope to + the `workspace-` schema. A few paths are exempt (`TENANT_BYPASSES` in + `api/main.py`): workspace create/delete (`PUT`/`DELETE /api/0.6/workspaces/{id}`) + and user provisioning during sign-in (`PUT /api/0.6/user/{uid}`). + +4. **Set the proxy headers.** The proxy rewrites `Host` to the OSM host and sets + `X-Real-IP` / `X-Forwarded-For` / `-Host` / `-Proto`, while stripping + hop-by-hop headers and any spoofed forwarding headers from the client. It does + *not* strip `Authorization` or `X-Workspace`. + +5. **Connectivity.** `WS_OSM_HOST` must reach `osm-web`, and the backend needs + **both** `OSM_DATABASE_URL` and `TASK_DATABASE_URL` — the token bridge and + user provisioning write to the OSM database. + +## Branch Index + +* ```develop``` merge your work here; keep this up to date with the "development" environment / dev tag +* ```staging``` keep this up to date with the "staging" environment / stage tag +* ```production``` keep this up to date with the "production" environment / prod tag + +## Deployment architecture + +The deployed system is defined by [`docker-compose.az.yml`](docker-compose.az.yml). It runs the +**application tier** as four containers; the **data tier** (Postgres/PostGIS) is external, managed +Azure Database for PostgreSQL, not part of this compose file. + +When deployed by `workspaces-stack` this model also holds. + +``` + client (TDEI/Keycloak JWT) + │ + ▼ :8000 + ┌──────────────────────────────────────┐ + │ workspaces-backend │ this repo — FastAPI front door. + │ authn/authz + OSM reverse proxy │ Serves /api/v1/*, proxies the rest. + └───┬──────────────────────────────┬────┘ + WS_OSM_HOST TASK_DATABASE_URL + (→ osm-rails) OSM_DATABASE_URL + │ │ + ▼ │ + ┌──────────────┐ │ + │ osm-rails │ OSM website (Rails); the single OSM entry point. + │ :3000 │ Serves the API/UI and fronts cgimap for the + └──────┬───────┘ performance-critical /api/0.6 calls. + │ (internal) │ + ▼ │ + ┌──────────────┐ │ + │ osm-cgimap │ C-accelerated /api/0.6 (map, changeset bulk) + │ :8000 │ │ + └──────────────┘ │ + │ + osm-rails-worker (rake jobs:work) │ background jobs + │ │ + │ backend, rails, cgimap, worker all connect to ▼ + ┌─────────────────────────────────────────────────────────────────┐ + │ data tier — Azure Postgres (external, PostGIS) │ + │ opensidewalks-${ENV}.postgres.database.azure.com:5432 │ + │ • workspaces-tasks-${ENV} TASK db (alembic_task; backend) │ + │ • workspaces-osm-${ENV} OSM db (alembic_osm; all four) │ + └─────────────────────────────────────────────────────────────────┘ +``` + +### Services + +| Service | Image | Role | +|---|---|---| +| `workspaces-backend` | `workspaces-backend-v2:${ENV}` | This repo. The only host-exposed service (`8000:8000`). Validates the TDEI/Keycloak JWT, enforces workspace authorization, serves `/api/v1/*`, and proxies everything else to the OSM tier. Connects to **both** databases. | +| `osm-rails` | `workspaces-osm-rails-v2:${ENV}` | The OpenStreetMap website (Rails) — the **single OSM entry point** the backend proxies to (`WS_OSM_HOST`). Serves the OSM API/UI and fronts cgimap for the heavy `/api/0.6` calls. Connects to the OSM db. | +| `osm-cgimap` | `workspaces-osm-cgimap-v2:${ENV}` | C++ reimplementation of the performance-critical OSM `0.6` calls (map queries, changeset upload/download), sitting behind `osm-rails`. Tuned here for large imports (`CGIMAP_MAX_*`). Connects to the OSM db. | +| `osm-rails-worker` | `workspaces-osm-rails-v2:${ENV}` | Background job runner (`rake jobs:work`) for the Rails app. Connects to the OSM db. | + +### Two databases + +The backend holds two connections, and the two alembic trees target them independently (see +`CLAUDE.md` and `api/utils/migrations.py`): + +* **TASK db** (`TASK_DATABASE_URL` → `workspaces-tasks-${ENV}`) — the workspaces + tasking-manager + schema, built by the `alembic_task` tree. Only the backend connects here. +* **OSM db** (`OSM_DATABASE_URL` → `workspaces-osm-${ENV}`) — OSM data plus `users` and the + `tasking_*` tables, built by the `alembic_osm` tree. The backend, cgimap, rails, and the worker + all connect here. + +On startup (outside of pytest) the backend runs `alembic -n task upgrade head` and +`alembic -n osm upgrade head`, applying each tree to its database. + +### Environment templating + +Every image tag, database name/user, and server host is parameterized by `${ENV}` +(`dev` / `stage` / `prod`), and secrets are injected from the shell environment +(`${WS_TASKS_DB_PASS}`, `${WS_OSM_DB_PASS}`, `${WS_OSM_SECRET_KEY_BASE}`). Branches map to these +environments — see the Branch Index below. + ## To start on your local machine for dev work ``` @@ -14,3 +134,85 @@ uv sync uv run uvicorn api.main:app ``` +## Running the tests + +Tests are fast and require no database, Docker, or network (see +`tests/README.md` for the design, and `CLAUDE.md` for conventions). + +``` +uv run pytest # full suite with coverage (configured in pyproject.toml) +uv run pytest --no-cov -q # quick run, no coverage +uv run pytest tests/unit # unit tests only +uv run pytest tests/integration # integration tests only +uv run pytest -k workspaces # filter by keyword +``` + +Type-check and format (matches the pre-commit hooks): + +``` +uvx pyright --pythonpath .venv/bin/python api tests +uv run black api tests && uv run isort api tests +``` + +## Development with local environment + +Use the file `docker-compose.local.yml` to build and deploy local code changes. This allows you to run the entire system at once instead of +connecting to existing Databases. + +### Initial setup for development local environment + +Step 1: Login to azure docker + +The docker compose relies on images in `opensidewalksdev` azure container registry. Make sure your docker system is logged into it before pulling the images and trying to run the containers. + +Docker login command: + +`docker login opensidewalksdev.azurecr.io -u opensidewalksdev ` + +Password needs to be obtained from Azure portal + +Step 2: Run docker compose for the first time + +Use the following command to start the containers first time + +`docker compose --file docker-compose.local.yml up --build` + +Step 3: Run the migration scripts. + +You will observe that only `osm-rails` component seems to work but the backend and other services may be down. this is because the database migrations on the base osm database are not done. To do the base migrations, do the following: + +- Connect to the `osm-rails` container. If you are using docker hub for desktop, just go to the exec section of the container. + If you want to use command line, execute the command `docker exec -it /bin/bash` where `container_name` is the name of osm-rails container +- Execute the migration script in the /bin/bash with `bundle exec rails db:migrate` +- The above command runs the migration script for databases + +Step 4: Add `workspaces-tasks-local` database in postgresql + +Workspaces backend relies on an additional database. This is needed for some older migrations code. + +- Connect to `database` container. +- Run the following set of commands one by one + +```shell +psql --username postgres +create database "workspaces-tasks-local"; +exit; +psql --username postgres --dbname "workspaces-tasks-local"; +create extension if not exists postgis; + +``` + +Step 5: Restart the docker compose again + +- `docker compose --file docker-compose.local.yml down` +- `docker compose --file docker-compose.local.yml up --build` + + + +### Commands to start and stop the docker compose + +`docker compose --file docker-compose.local.yml up --build -d` + +`docker compose --file docker-compose.local.yml down` + +Backend code will be available at `http://localhost:8000` diff --git a/alembic_osm/env.py b/alembic_osm/env.py index 9ec3f3e..bb18cbb 100644 --- a/alembic_osm/env.py +++ b/alembic_osm/env.py @@ -8,11 +8,11 @@ # Add the project root directory to the Python path sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +from alembic import context from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config -from alembic import context from api.core.config import settings from api.core.database import Base diff --git a/alembic_osm/sql/osm_augmented_diff.sql b/alembic_osm/sql/osm_augmented_diff.sql new file mode 100644 index 0000000..51733f0 --- /dev/null +++ b/alembic_osm/sql/osm_augmented_diff.sql @@ -0,0 +1,650 @@ +-- osm_augmented_diff(p_changeset_id bigint) -> table of adiff rows +-- +-- Computes a complete augmented diff for a changeset like those produced by +-- the Overpass API: +-- +-- https://wiki.openstreetmap.org/wiki/Overpass_API/Augmented_Diffs +-- +-- Each returned row represents one action. Callers convert rows to whatever +-- output format they need (JSON, XML, etc.). +-- + +CREATE OR REPLACE FUNCTION osm_augmented_diff(p_changeset_id bigint) +RETURNS TABLE ( + action_type text, -- 'create' | 'modify' | 'delete' + element_type text, -- 'node' | 'way' | 'relation' + -- new element (always populated) + new_id bigint, + new_version bigint, + new_changeset_id bigint, + new_timestamp timestamp without time zone, + new_visible boolean, + new_user text, + new_uid bigint, + new_lat double precision, -- nodes only + new_lon double precision, -- nodes only + new_tags jsonb, + new_nodes jsonb, -- ways only: [{ref,lat,lon}] + new_members jsonb, -- relations only: [{type,ref,role}] + -- old element (all NULL for create actions) + old_id bigint, + old_version bigint, + old_changeset_id bigint, + old_timestamp timestamp without time zone, + old_visible boolean, + old_user text, + old_uid bigint, + old_lat double precision, + old_lon double precision, + old_tags jsonb, + old_nodes jsonb, + old_members jsonb +) +LANGUAGE sql +STABLE +AS $$ +WITH + +-- Nodes in this changeset +cs_node_base AS ( + SELECT n.node_id AS id, + n.version, + n.changeset_id, + n.timestamp, + n.visible, + n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon, + CASE + WHEN n.version = 1 THEN 'create' + WHEN NOT n.visible THEN 'delete' + ELSE 'modify' + END AS action + FROM nodes n + WHERE n.changeset_id = p_changeset_id + AND n.redaction_id IS NULL +), + +cs_nodes AS ( + SELECT b.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM node_tags t + WHERE t.node_id = b.id + AND t.version = b.version + ), + '{}'::jsonb + ) AS tags, + usr.uid, + usr.username + FROM cs_node_base b + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = b.changeset_id + LIMIT 1 + ) usr ON true +), + +-- Previous node versions (for modify/delete) +prev_nodes AS ( + SELECT n.node_id AS id, + n.version, + n.changeset_id, + n.timestamp, + n.visible, + n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM node_tags t + WHERE t.node_id = n.node_id + AND t.version = n.version + ), + '{}'::jsonb + ) AS tags, + usr.uid, + usr.username + FROM cs_nodes csn + JOIN nodes n + ON n.node_id = csn.id + AND n.version = csn.version - 1 + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = n.changeset_id + LIMIT 1 + ) usr ON true + WHERE csn.version > 1 + AND n.redaction_id IS NULL +), + +-- Ways in this changeset +cs_way_base AS ( + SELECT w.way_id AS id, + w.version, + w.changeset_id, + w.timestamp, + w.visible, + CASE + WHEN w.version = 1 THEN 'create' + WHEN NOT w.visible THEN 'delete' + ELSE 'modify' + END AS action + FROM ways w + WHERE w.changeset_id = p_changeset_id + AND w.redaction_id IS NULL +), + +-- Resolve each node ref to its coords as of this changeset (latest version ≤ $1) +cs_way_nodes AS ( + SELECT wn.way_id, + wn.version, + jsonb_agg( + jsonb_build_object('ref', wn.node_id, 'lat', nc.lat, 'lon', nc.lon) + ORDER BY wn.sequence_id + ) AS nodes + FROM way_nodes wn + JOIN cs_way_base csw + ON csw.id = wn.way_id + AND csw.version = wn.version + LEFT JOIN LATERAL ( + SELECT n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon + FROM nodes n + WHERE n.node_id = wn.node_id + AND n.changeset_id <= p_changeset_id + AND n.redaction_id IS NULL + ORDER BY n.version DESC + LIMIT 1 + ) nc ON true + GROUP BY wn.way_id, wn.version +), + +cs_ways AS ( + SELECT b.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM way_tags t + WHERE t.way_id = b.id + AND t.version = b.version + ), + '{}'::jsonb + ) AS tags, + COALESCE(wn.nodes, '[]'::jsonb) AS nodes, + usr.uid, + usr.username + FROM cs_way_base b + LEFT JOIN cs_way_nodes wn + ON wn.way_id = b.id + AND wn.version = b.version + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = b.changeset_id + LIMIT 1 + ) usr ON true +), + +-- Previous way versions (for modify/delete) +prev_way_base AS ( + SELECT w.way_id AS id, + w.version, + w.changeset_id, + w.timestamp, + w.visible + FROM cs_ways csw + JOIN ways w + ON w.way_id = csw.id + AND w.version = csw.version - 1 + WHERE csw.version > 1 + AND w.redaction_id IS NULL +), + +-- Use changeset_id < p_changeset_id (strictly before) so coords reflect the +-- state before any node moves in this changeset +prev_way_nodes AS ( + SELECT wn.way_id, + wn.version, + jsonb_agg( + jsonb_build_object('ref', wn.node_id, 'lat', nc.lat, 'lon', nc.lon) + ORDER BY wn.sequence_id + ) AS nodes + FROM way_nodes wn + JOIN prev_way_base pw + ON pw.id = wn.way_id + AND pw.version = wn.version + LEFT JOIN LATERAL ( + SELECT n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon + FROM nodes n + WHERE n.node_id = wn.node_id + AND n.changeset_id < p_changeset_id + AND n.redaction_id IS NULL + ORDER BY n.version DESC + LIMIT 1 + ) nc ON true + GROUP BY wn.way_id, wn.version +), + +prev_ways AS ( + SELECT b.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM way_tags t + WHERE t.way_id = b.id + AND t.version = b.version + ), + '{}'::jsonb + ) AS tags, + COALESCE(wn.nodes, '[]'::jsonb) AS nodes, + usr.uid, + usr.username + FROM prev_way_base b + LEFT JOIN prev_way_nodes wn + ON wn.way_id = b.id + AND wn.version = b.version + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = b.changeset_id + LIMIT 1 + ) usr ON true +), + +-- Relations in this changeset +cs_rel_base AS ( + SELECT r.relation_id AS id, + r.version, + r.changeset_id, + r.timestamp, + r.visible, + CASE + WHEN r.version = 1 THEN 'create' + WHEN NOT r.visible THEN 'delete' + ELSE 'modify' + END AS action + FROM relations r + WHERE r.changeset_id = p_changeset_id + AND r.redaction_id IS NULL +), + +cs_rel_members AS ( + SELECT rm.relation_id AS id, + rm.version, + jsonb_agg( + jsonb_build_object( + 'type', lower(rm.member_type::text), + 'ref', rm.member_id, + 'role', rm.member_role + ) + ORDER BY rm.sequence_id + ) AS members + FROM relation_members rm + JOIN cs_rel_base cr + ON cr.id = rm.relation_id + AND cr.version = rm.version + GROUP BY rm.relation_id, rm.version +), + +cs_relations AS ( + SELECT b.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM relation_tags t + WHERE t.relation_id = b.id + AND t.version = b.version + ), + '{}'::jsonb + ) AS tags, + COALESCE(m.members, '[]'::jsonb) AS members, + usr.uid, + usr.username + FROM cs_rel_base b + LEFT JOIN cs_rel_members m + ON m.id = b.id + AND m.version = b.version + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = b.changeset_id + LIMIT 1 + ) usr ON true +), + +-- Previous relation versions (for modify/delete) +prev_rel_base AS ( + SELECT r.relation_id AS id, + r.version, + r.changeset_id, + r.timestamp, + r.visible + FROM cs_relations csr + JOIN relations r + ON r.relation_id = csr.id + AND r.version = csr.version - 1 + WHERE csr.version > 1 + AND r.redaction_id IS NULL +), + +prev_rel_members AS ( + SELECT rm.relation_id AS id, + rm.version, + jsonb_agg( + jsonb_build_object( + 'type', lower(rm.member_type::text), + 'ref', rm.member_id, + 'role', rm.member_role + ) + ORDER BY rm.sequence_id + ) AS members + FROM relation_members rm + JOIN prev_rel_base pr + ON pr.id = rm.relation_id + AND pr.version = rm.version + GROUP BY rm.relation_id, rm.version +), + +prev_relations AS ( + SELECT b.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM relation_tags t + WHERE t.relation_id = b.id + AND t.version = b.version + ), + '{}'::jsonb + ) AS tags, + COALESCE(m.members, '[]'::jsonb) AS members, + usr.uid, + usr.username + FROM prev_rel_base b + LEFT JOIN prev_rel_members m + ON m.id = b.id + AND m.version = b.version + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = b.changeset_id + LIMIT 1 + ) usr ON true +), + +-- Ways affected by node geometry changes: +-- +-- When a node moves, every way containing it has an implicit geometry change +-- even if the way record was untouched. We emit a 'modify' row for each such +-- way so the diff viewer can render the shape change. +-- Ways already explicitly in this changeset are excluded. +affected_way_ids AS ( + SELECT DISTINCT cwn.way_id AS id + FROM current_way_nodes cwn + WHERE cwn.node_id IN ( + SELECT id + FROM cs_nodes + WHERE action IN ('modify', 'delete') + ) + AND cwn.way_id NOT IN ( + SELECT id + FROM cs_way_base + ) +), + +-- Resolve each affected way to the version it was at when this changeset ran +affected_way_versions AS ( + SELECT awi.id, + w.version, + w.changeset_id, + w.timestamp, + w.visible + FROM affected_way_ids awi + JOIN LATERAL ( + SELECT w2.version, + w2.changeset_id, + w2.timestamp, + w2.visible + FROM ways w2 + WHERE w2.way_id = awi.id + AND w2.changeset_id <= p_changeset_id + AND w2.redaction_id IS NULL + ORDER BY w2.version DESC + LIMIT 1 + ) w ON true +), + +-- "new" node coords: after this changeset's node edits (<=) +affected_nodes_new AS ( + SELECT wn.way_id, + wn.version, + jsonb_agg( + jsonb_build_object('ref', wn.node_id, 'lat', nc.lat, 'lon', nc.lon) + ORDER BY wn.sequence_id + ) AS nodes + FROM way_nodes wn + JOIN affected_way_versions aw + ON aw.id = wn.way_id + AND aw.version = wn.version + LEFT JOIN LATERAL ( + SELECT n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon + FROM nodes n + WHERE n.node_id = wn.node_id + AND n.changeset_id <= p_changeset_id + AND n.redaction_id IS NULL + ORDER BY n.version DESC + LIMIT 1 + ) nc ON true + GROUP BY wn.way_id, wn.version +), + +-- "old" node coords: before this changeset's node edits (<) +affected_nodes_old AS ( + SELECT wn.way_id, + wn.version, + jsonb_agg( + jsonb_build_object('ref', wn.node_id, 'lat', nc.lat, 'lon', nc.lon) + ORDER BY wn.sequence_id + ) AS nodes + FROM way_nodes wn + JOIN affected_way_versions aw + ON aw.id = wn.way_id + AND aw.version = wn.version + LEFT JOIN LATERAL ( + SELECT n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon + FROM nodes n + WHERE n.node_id = wn.node_id + AND n.changeset_id < p_changeset_id + AND n.redaction_id IS NULL + ORDER BY n.version DESC + LIMIT 1 + ) nc ON true + GROUP BY wn.way_id, wn.version +), + +affected_ways AS ( + SELECT aw.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM way_tags t + WHERE t.way_id = aw.id + AND t.version = aw.version + ), + '{}'::jsonb + ) AS tags, + COALESCE(anew.nodes, '[]'::jsonb) AS nodes_new, + COALESCE(aold.nodes, '[]'::jsonb) AS nodes_old, + usr.uid, + usr.username + FROM affected_way_versions aw + LEFT JOIN affected_nodes_new anew + ON anew.way_id = aw.id + AND anew.version = aw.version + LEFT JOIN affected_nodes_old aold + ON aold.way_id = aw.id + AND aold.version = aw.version + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = aw.changeset_id + LIMIT 1 + ) usr ON true +) + +-- Emit one row per action +SELECT csn.action, + 'node', + csn.id, + csn.version, + csn.changeset_id, + csn.timestamp, + csn.visible, + csn.username, + csn.uid, + csn.lat, + csn.lon, + csn.tags, + NULL::jsonb, + NULL::jsonb, + pn.id, + pn.version, + pn.changeset_id, + pn.timestamp, + pn.visible, + pn.username, + pn.uid, + pn.lat, + pn.lon, + pn.tags, + NULL::jsonb, + NULL::jsonb + FROM cs_nodes csn + LEFT JOIN prev_nodes pn + ON pn.id = csn.id + AND pn.version = csn.version - 1 + +UNION ALL + +SELECT csw.action, + 'way', + csw.id, + csw.version, + csw.changeset_id, + csw.timestamp, + csw.visible, + csw.username, + csw.uid, + NULL::double precision, + NULL::double precision, + csw.tags, + csw.nodes, + NULL::jsonb, + pw.id, + pw.version, + pw.changeset_id, + pw.timestamp, + pw.visible, + pw.username, + pw.uid, + NULL::double precision, + NULL::double precision, + pw.tags, + pw.nodes, + NULL::jsonb + FROM cs_ways csw + LEFT JOIN prev_ways pw + ON pw.id = csw.id + AND pw.version = csw.version - 1 + +UNION ALL + +SELECT csr.action, + 'relation', + csr.id, + csr.version, + csr.changeset_id, + csr.timestamp, + csr.visible, + csr.username, + csr.uid, + NULL::double precision, + NULL::double precision, + csr.tags, + NULL::jsonb, + csr.members, + pr.id, + pr.version, + pr.changeset_id, + pr.timestamp, + pr.visible, + pr.username, + pr.uid, + NULL::double precision, + NULL::double precision, + pr.tags, + NULL::jsonb, + pr.members + FROM cs_relations csr + LEFT JOIN prev_relations pr + ON pr.id = csr.id + AND pr.version = csr.version - 1 + +UNION ALL + +-- Implicit way modifications from node moves: new and old share way metadata; +-- only the node coordinate arrays differ. +SELECT 'modify', + 'way', + aw.id, + aw.version, + aw.changeset_id, + aw.timestamp, + aw.visible, + aw.username, + aw.uid, + NULL::double precision, + NULL::double precision, + aw.tags, + aw.nodes_new, + NULL::jsonb, + aw.id, + aw.version, + aw.changeset_id, + aw.timestamp, + aw.visible, + aw.username, + aw.uid, + NULL::double precision, + NULL::double precision, + aw.tags, + aw.nodes_old, + NULL::jsonb + FROM affected_ways aw; +$$; diff --git a/alembic_osm/versions/5303f61d7b3a_add_osm_augmented_diff_function.py b/alembic_osm/versions/5303f61d7b3a_add_osm_augmented_diff_function.py new file mode 100644 index 0000000..1fa4002 --- /dev/null +++ b/alembic_osm/versions/5303f61d7b3a_add_osm_augmented_diff_function.py @@ -0,0 +1,37 @@ +"""add osm_augmented_diff function + +Revision ID: 5303f61d7b3a +Revises: a1b2c3d4e5f6 +Create Date: 2026-06-17 00:00:00.000000 + +""" + +import pathlib +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import text + +# revision identifiers, used by Alembic. +revision: str = "5303f61d7b3a" +down_revision: Union[str, None] = "a1b2c3d4e5f6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_SQL_DIR = pathlib.Path(__file__).parent.parent / "sql" + + +def upgrade() -> None: + # This SQL function references OSM core tables (nodes, ways, way_nodes) + # owned by the Rails website. Disable body validation for this transaction + # so the function can be created even when those tables aren't present yet + # (a fresh DB where alembic runs before the Rails schema load, or the + # integration-test container). pg_dump does the same when restoring + # SQL-language functions. The function is only invoked at runtime, by + # which point the OSM tables exist. + op.execute(text("SET LOCAL check_function_bodies = off")) + op.execute(text((_SQL_DIR / "osm_augmented_diff.sql").read_text())) + + +def downgrade() -> None: + op.execute(text("DROP FUNCTION IF EXISTS osm_augmented_diff(bigint)")) diff --git a/alembic_osm/versions/9221408912dd_add_user_role_table.py b/alembic_osm/versions/9221408912dd_add_user_role_table.py index aa938a9..402cf9a 100644 --- a/alembic_osm/versions/9221408912dd_add_user_role_table.py +++ b/alembic_osm/versions/9221408912dd_add_user_role_table.py @@ -5,68 +5,37 @@ Create Date: 2026-01-29 14:54:10.669000 """ + from typing import Sequence, Union -from alembic import op -from sqlalchemy import inspect, text import sqlalchemy as sa - +from alembic import op +from sqlalchemy import text # revision identifiers, used by Alembic. -revision: str = '9221408912dd' +revision: str = "9221408912dd" down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - bind = op.get_bind() - insp = inspect(bind) - - # 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'") - ).scalar() - if not constraint_exists: - op.create_unique_constraint('auth_uid_unique', 'users', ['auth_uid']) - - # Create the workspace_role enum type (if not already present) - result = bind.execute( - text("SELECT 1 FROM pg_type WHERE typname = 'workspace_role'") + op.create_unique_constraint("auth_uid_unique", "users", ["auth_uid"]) + op.create_table( + "user_workspace_roles", + sa.Column("user_auth_uid", sa.String(), nullable=False), + sa.Column("workspace_id", sa.BigInteger(), nullable=False), + sa.Column( + "role", + sa.Enum("lead", "validator", "contributor", name="workspace_role"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + sa.PrimaryKeyConstraint("user_auth_uid", "workspace_id"), ) - if not result.scalar(): - workspace_role = sa.Enum('lead', 'validator', 'contributor', name='workspace_role') - workspace_role.create(bind) - - # Create the user_workspace_roles table (if not already present) - 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('workspace_id', sa.BigInteger(), nullable=False), - sa.Column('role', sa.Enum('lead', 'validator', 'contributor', name='workspace_role', create_type=False), nullable=False), - sa.ForeignKeyConstraint(['user_auth_uid'], ['users.auth_uid']), - sa.PrimaryKeyConstraint('user_auth_uid', 'workspace_id') - ) def downgrade() -> None: - bind = op.get_bind() - insp = inspect(bind) - - if insp.has_table('user_workspace_roles'): - op.drop_table('user_workspace_roles') - - # Drop the enum type - result = bind.execute( - text("SELECT 1 FROM pg_type WHERE typname = 'workspace_role'") - ) - if result.scalar(): - workspace_role = sa.Enum('lead', 'validator', 'contributor', name='workspace_role') - workspace_role.drop(bind) - - constraint_exists = bind.execute( - text("SELECT 1 FROM pg_constraint WHERE conname = 'auth_uid_unique'") - ).scalar() - if constraint_exists: - op.drop_constraint('auth_uid_unique', 'users', type_='unique') + op.drop_table("user_workspace_roles") + op.execute(text("DROP TYPE workspace_role")) + op.drop_constraint("auth_uid_unique", "users", type_="unique") diff --git a/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py new file mode 100644 index 0000000..068d28d --- /dev/null +++ b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py @@ -0,0 +1,551 @@ +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect, text +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, None] = "9221408912dd" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# Enum names + values, declared once and reused on up/down. +TASKING_PROJECT_STATUS = ("draft", "open", "done") +TASKING_TASK_STATUS = ("to_map", "to_review", "to_remap", "completed") +TASKING_TASK_BOUNDARY_TYPE = ("grid", "import") +TASKING_LOCK_RELEASE_REASON = ( + "auto_unlock", + "manual", + "lead_release", + "stale_timeout", + "reset", +) +TASKING_FEEDBACK_REASON = ( + "incomplete_mapping", + "data_quality_issue", + "wrong_area", + "other", +) + + +def _create_enum_if_absent(bind, name: str, values: tuple[str, ...]) -> None: + exists = bind.execute( + text("SELECT 1 FROM pg_type WHERE typname = :n"), {"n": name} + ).scalar() + if not exists: + sa.Enum(*values, name=name).create(bind) + + +def _drop_enum_if_present(bind, name: str) -> None: + exists = bind.execute( + text("SELECT 1 FROM pg_type WHERE typname = :n"), {"n": name} + ).scalar() + if exists: + bind.execute(text(f'DROP TYPE IF EXISTS "{name}"')) + + +def _assert_postgis_installed(bind) -> None: + """Require the postgis extension to be installed in this database.""" + installed = bool( + bind.execute( + text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'") + ).scalar() + ) + if not installed: + raise RuntimeError( + "postgis extension is not installed in this database. " + "Run `CREATE EXTENSION IF NOT EXISTS postgis;` before migrations." + ) + + +def upgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + _assert_postgis_installed(bind) + + # ---- teams / team_user ------------------------------------------- + # + # Created here so the OSM tree owns every table that references + # `users.id`. The `has_table` guards keep this idempotent in both + # production (shared TASK/OSM database) and fresh test installs. + + if not insp.has_table("teams"): + op.create_table( + "teams", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("workspace_id", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_teams_workspace_id", "teams", ["workspace_id"]) + + if not insp.has_table("team_user"): + op.create_table( + "team_user", + sa.Column("team_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["team_id"], ["teams.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("team_id", "user_id"), + ) + + # ---- Enums -------------------------------------------------------- + + _create_enum_if_absent(bind, "tasking_project_status", TASKING_PROJECT_STATUS) + _create_enum_if_absent(bind, "tasking_task_status", TASKING_TASK_STATUS) + _create_enum_if_absent( + bind, "tasking_task_boundary_type", TASKING_TASK_BOUNDARY_TYPE + ) + _create_enum_if_absent( + bind, "tasking_lock_release_reason", TASKING_LOCK_RELEASE_REASON + ) + _create_enum_if_absent(bind, "tasking_feedback_reason", TASKING_FEEDBACK_REASON) + + # ---- tasking_projects -------------------------------------------- + + if not insp.has_table("tasking_projects"): + op.create_table( + "tasking_projects", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + # Cross-DB ref to workspaces.id — no FK by design (matches + # user_workspace_roles convention). + sa.Column("workspace_id", sa.BigInteger(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("instructions", sa.Text(), nullable=True), + sa.Column( + "status", + postgresql.ENUM( + *TASKING_PROJECT_STATUS, + name="tasking_project_status", + create_type=False, + ), + nullable=False, + server_default="draft", + ), + sa.Column( + "review_required", + sa.Boolean(), + nullable=False, + server_default=sa.true(), + ), + sa.Column( + "lock_timeout_hours", + sa.Integer(), + nullable=False, + server_default="8", + ), + sa.Column( + "task_boundary_type", + postgresql.ENUM( + *TASKING_TASK_BOUNDARY_TYPE, + name="tasking_task_boundary_type", + create_type=False, + ), + nullable=True, + ), + # AOI is MultiPolygon in EPSG:4326. Polygon inputs are + # upcast to single-member MultiPolygons in the app layer. + sa.Column( + "aoi", + sa.dialects.postgresql.BYTEA(), # placeholder; replaced below + nullable=True, + ), + sa.Column("created_by", sa.Uuid(), nullable=False), + sa.Column("created_by_name", sa.String(length=255), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + + op.execute("ALTER TABLE tasking_projects DROP COLUMN aoi") + op.execute( + "ALTER TABLE tasking_projects " + "ADD COLUMN aoi GEOMETRY(MultiPolygon, 4326)" + ) + + # Unique project name per workspace among non-deleted rows. + op.execute( + "CREATE UNIQUE INDEX tasking_projects_workspace_name_unique " + "ON tasking_projects (workspace_id, lower(name)) " + "WHERE deleted_at IS NULL" + ) + + op.create_index( + "tasking_projects_workspace_idx", + "tasking_projects", + ["workspace_id"], + ) + op.create_index( + "tasking_projects_status_idx", + "tasking_projects", + ["status"], + ) + + # ---- tasking_project_roles --------------------------------------- + + if not insp.has_table("tasking_project_roles"): + op.create_table( + "tasking_project_roles", + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("user_auth_uid", sa.String(), nullable=False), + sa.Column( + "role", + postgresql.ENUM( + "lead", + "validator", + "contributor", + name="workspace_role", + create_type=False, + ), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + sa.PrimaryKeyConstraint("project_id", "user_auth_uid"), + ) + + # ---- tasking_tasks ------------------------------------------------ + + if not insp.has_table("tasking_tasks"): + op.create_table( + "tasking_tasks", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("task_number", sa.Integer(), nullable=False), + sa.Column("area_sqkm", sa.Numeric(precision=10, scale=4), nullable=False), + sa.Column( + "status", + postgresql.ENUM( + *TASKING_TASK_STATUS, + name="tasking_task_status", + create_type=False, + ), + nullable=False, + server_default="to_map", + ), + sa.Column("last_mapper_id", sa.String(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.UniqueConstraint( + "project_id", "task_number", name="tasking_tasks_pn_unique" + ), + ) + op.execute( + "ALTER TABLE tasking_tasks " + "ADD COLUMN geometry GEOMETRY(Polygon, 4326) NOT NULL" + ) + op.execute( + "CREATE INDEX tasking_tasks_geometry_idx " + "ON tasking_tasks USING GIST (geometry)" + ) + op.create_index("tasking_tasks_project_idx", "tasking_tasks", ["project_id"]) + + # ---- tasking_locks ------------------------------------------------ + + if not insp.has_table("tasking_locks"): + op.create_table( + "tasking_locks", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("task_id", sa.BigInteger(), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("user_auth_uid", sa.String(), nullable=False), + sa.Column( + "task_status_at_lock", + postgresql.ENUM( + *TASKING_TASK_STATUS, + name="tasking_task_status", + create_type=False, + ), + nullable=False, + ), + sa.Column( + "locked_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("released_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "release_reason", + postgresql.ENUM( + *TASKING_LOCK_RELEASE_REASON, + name="tasking_lock_release_reason", + create_type=False, + ), + nullable=True, + ), + sa.ForeignKeyConstraint( + ["task_id"], ["tasking_tasks.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + ) + # One active lock per task; one active lock per (project, user). + op.execute( + "CREATE UNIQUE INDEX tasking_locks_one_active_per_task " + "ON tasking_locks (task_id) WHERE released_at IS NULL" + ) + op.execute( + "CREATE UNIQUE INDEX tasking_locks_one_active_per_user_project " + "ON tasking_locks (project_id, user_auth_uid) " + "WHERE released_at IS NULL" + ) + op.execute( + "CREATE INDEX tasking_locks_expiry_idx " + "ON tasking_locks (expires_at) WHERE released_at IS NULL" + ) + + # ---- tasking_changesets ------------------------------------------ + + if not insp.has_table("tasking_changesets"): + op.create_table( + "tasking_changesets", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("task_id", sa.BigInteger(), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("lock_id", sa.BigInteger(), nullable=False), + sa.Column("user_auth_uid", sa.String(), nullable=False), + sa.Column("osm_changeset_id", sa.BigInteger(), nullable=False), + sa.Column( + "submitted_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["task_id"], ["tasking_tasks.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["lock_id"], ["tasking_locks.id"]), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + ) + op.create_index( + "tasking_changesets_task_idx", "tasking_changesets", ["task_id"] + ) + + # ---- tasking_feedback -------------------------------------------- + # + # Generic per-task feedback table. Covers validator remap rejections + # (originally the only use case) plus any other free-form notes a + # contributor / validator / lead may attach to a task — approval + # comments, follow-up reminders, etc. + # + # `reason_category` is nullable: required only when the feedback is + # used to drive a `to_review → to_remap` transition; left NULL for + # generic notes. `notes` is required so a row always has at least + # one of (category, free text) — usually both. + + if not insp.has_table("tasking_feedback"): + op.create_table( + "tasking_feedback", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("task_id", sa.BigInteger(), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("author_user_auth_uid", sa.String(), nullable=False), + sa.Column( + "reason_category", + postgresql.ENUM( + *TASKING_FEEDBACK_REASON, + name="tasking_feedback_reason", + create_type=False, + ), + nullable=True, + ), + sa.Column("notes", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["task_id"], ["tasking_tasks.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["author_user_auth_uid"], ["users.auth_uid"]), + ) + op.create_index("tasking_feedback_task_idx", "tasking_feedback", ["task_id"]) + op.create_index( + "tasking_feedback_project_idx", "tasking_feedback", ["project_id"] + ) + + # ---- tasking_audit_events ---------------------------------------- + + if not insp.has_table("tasking_audit_events"): + op.create_table( + "tasking_audit_events", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("event_type", sa.String(length=64), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("task_id", sa.BigInteger(), nullable=True), + sa.Column("actor_user_auth_uid", sa.Uuid(), nullable=False), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "details", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column( + "project_deleted", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + # No FK to tasking_projects so audit survives the + # project's hard-delete of children + soft-delete itself. + ) + op.create_index( + "tasking_audit_project_idx", "tasking_audit_events", ["project_id"] + ) + op.create_index( + "tasking_audit_project_task_idx", + "tasking_audit_events", + ["project_id", "task_id"], + ) + op.create_index( + "tasking_audit_occurred_idx", + "tasking_audit_events", + ["occurred_at"], + ) + + # ---- tasking_task_save_idempotency ------------------------------- + + if not insp.has_table("tasking_task_save_idempotency"): + op.create_table( + "tasking_task_save_idempotency", + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("key", sa.String(length=128), nullable=False), + sa.Column("body_hash", sa.String(length=128), nullable=False), + sa.Column( + "response_json", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("project_id", "key"), + ) + op.create_index( + "tasking_task_save_idempotency_created_idx", + "tasking_task_save_idempotency", + ["created_at"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + # Drop tables in reverse FK order. + for table in ( + "tasking_task_save_idempotency", + "tasking_audit_events", + "tasking_feedback", + "tasking_changesets", + "tasking_locks", + "tasking_tasks", + "tasking_project_roles", + "tasking_projects", + "team_user", + "teams", + ): + if insp.has_table(table): + op.drop_table(table) + + # Drop tasking-specific enums (workspace_role is owned by an earlier + # revision and stays). + for enum_name in ( + "tasking_feedback_reason", + "tasking_lock_release_reason", + "tasking_task_boundary_type", + "tasking_task_status", + "tasking_project_status", + ): + _drop_enum_if_present(bind, enum_name) diff --git a/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py b/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py new file mode 100644 index 0000000..6f1bc64 --- /dev/null +++ b/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py @@ -0,0 +1,35 @@ +"""custom_imagery_and_description + +Revision ID: a92361f527ef +Revises: f3a7b9c1d2e4 +Create Date: 2026-07-14 15:08:42.142553 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +# revision identifiers, used by Alembic. +revision: str = "a92361f527ef" +down_revision: Union[str, None] = "f3a7b9c1d2e4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tasking_projects", + sa.Column("custom_imagery", JSONB(astext_type=sa.Text()), nullable=True), + ) + op.add_column( + "tasking_projects", + sa.Column("description", sa.String(length=10000), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("tasking_projects", "custom_imagery") + op.drop_column("tasking_projects", "description") diff --git a/alembic_osm/versions/f3a7b9c1d2e4_heal_short_tdei_pass_crypt.py b/alembic_osm/versions/f3a7b9c1d2e4_heal_short_tdei_pass_crypt.py new file mode 100644 index 0000000..5ff6bca --- /dev/null +++ b/alembic_osm/versions/f3a7b9c1d2e4_heal_short_tdei_pass_crypt.py @@ -0,0 +1,47 @@ +"""heal short TDEI-provisioned users.pass_crypt + +TDEI users are provisioned by the backend via raw SQL with a placeholder +``pass_crypt``. Older rows used a 4-char value (``"none"``), which violates OSM's +``pass_crypt`` length 8..255 ``User`` validation. That makes the user invalid, +and Rails operations that re-validate the author via ``validates :author, +:associated => true`` (posting a changeset comment or a note comment) then fail +to save. Replace any too-short value with a random throwaway -- TDEI manages +auth, so ``pass_crypt`` is never used to authenticate. + +Revision ID: f3a7b9c1d2e4 +Revises: 5303f61d7b3a +Create Date: 2026-07-11 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "f3a7b9c1d2e4" +down_revision: Union[str, None] = "5303f61d7b3a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + # `users` is owned by the OSM Rails website, not this tree; guard so the + # migration is a no-op if it hasn't been created yet. + if not inspect(bind).has_table("users"): + return + + # Idempotent: re-running only touches rows that are still too short. + op.execute( + text( + "UPDATE users SET pass_crypt = md5(random()::text) " + "WHERE auth_provider = 'TDEI' AND length(pass_crypt) < 8" + ) + ) + + +def downgrade() -> None: + # The original short values are unrecoverable (and were invalid anyway). + pass diff --git a/alembic_task/env.py b/alembic_task/env.py index 3d1a2b3..ba3c5da 100644 --- a/alembic_task/env.py +++ b/alembic_task/env.py @@ -8,11 +8,11 @@ # Add the project root directory to the Python path sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +from alembic import context from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config -from alembic import context from api.core.config import settings from api.core.database import Base diff --git a/alembic_task/versions/add6266277c7_.py b/alembic_task/versions/add6266277c7_.py index 517ee34..cd27884 100644 --- a/alembic_task/versions/add6266277c7_.py +++ b/alembic_task/versions/add6266277c7_.py @@ -7,14 +7,13 @@ """ import sqlalchemy as sa - from alembic import op # revision identifiers, used by Alembic. revision = "add6266277c7" branch_labels = None depends_on = None -down_revision = None +down_revision = "c5121cbba124" def upgrade(): diff --git a/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py b/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py new file mode 100644 index 0000000..f0b39ba --- /dev/null +++ b/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py @@ -0,0 +1,50 @@ +"""Add autoFlagReview column to workspaces table + +Revision ID: b3f8a2c91e04 +Revises: add6266277c7 +Create Date: 2026-03-05 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision: str = "b3f8a2c91e04" +down_revision: Union[str, None] = "add6266277c7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_column(name: str) -> bool: + """True if `workspaces.` already exists. + + Guards the add/drop so the migration is safe whether or not the column + was created out-of-band (e.g. by a parallel branch) — a plain + ``ADD COLUMN`` would raise ``DuplicateColumnError`` otherwise. + """ + insp = inspect(op.get_bind()) + return name in {c["name"] for c in insp.get_columns("workspaces")} + + +def upgrade() -> None: + if _has_column("autoFlagReview"): + return + with op.batch_alter_table("workspaces", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "autoFlagReview", + sa.Boolean(), + nullable=False, + server_default="false", + ) + ) + + +def downgrade() -> None: + if not _has_column("autoFlagReview"): + return + with op.batch_alter_table("workspaces", schema=None) as batch_op: + batch_op.drop_column("autoFlagReview") diff --git a/alembic_task/versions/c5121cbba124_initial_task_schema.py b/alembic_task/versions/c5121cbba124_initial_task_schema.py new file mode 100644 index 0000000..a112007 --- /dev/null +++ b/alembic_task/versions/c5121cbba124_initial_task_schema.py @@ -0,0 +1,112 @@ +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from geoalchemy2 import Geometry +from sqlalchemy import inspect, text +from sqlalchemy.dialects import postgresql + +revision: str = "c5121cbba124" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _assert_postgis_installed(bind) -> None: + """Require the postgis extension to be installed in this database.""" + installed = bool( + bind.execute( + text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'") + ).scalar() + ) + if not installed: + raise RuntimeError( + "postgis extension is not installed in this database. " + "Run `CREATE EXTENSION IF NOT EXISTS postgis;` before migrations." + ) + + +def upgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + _assert_postgis_installed(bind) + + geometry_column = sa.Column( + "geometry", + Geometry(geometry_type="MULTIPOLYGON", srid=4326), + nullable=True, + ) + + # The TASK tree owns `workspaces` and `workspaces_*` only. + # `users`, `teams`, `team_user`, `user_workspace_roles`, and the + # `tasking_*` tables are owned by the OSM tree. + + if not insp.has_table("workspaces"): + op.create_table( + "workspaces", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("type", sa.String(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("tdeiProjectGroupId", sa.Uuid(), nullable=False), + sa.Column("tdeiRecordId", sa.Uuid(), nullable=True), + sa.Column("tdeiServiceId", sa.Uuid(), nullable=True), + sa.Column( + "tdeiMetadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + sa.Column("createdAt", sa.DateTime(), nullable=False), + sa.Column("createdBy", sa.Uuid(), nullable=False), + sa.Column("createdByName", sa.String(), nullable=False), + geometry_column, + sa.Column( + "externalAppAccess", + sa.SmallInteger(), + nullable=False, + server_default="0", + ), + sa.Column("kartaViewToken", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + if not insp.has_table("workspaces_long_quests"): + op.create_table( + "workspaces_long_quests", + sa.Column("workspace_id", sa.Integer(), nullable=False), + sa.Column("definition", sa.String(), nullable=True), + sa.Column("modifiedAt", sa.DateTime(), nullable=False), + sa.Column("modifiedBy", sa.Uuid(), nullable=False), + sa.Column("modifiedByName", sa.String(), nullable=False), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("workspace_id"), + ) + + if not insp.has_table("workspaces_imagery"): + op.create_table( + "workspaces_imagery", + sa.Column("workspace_id", sa.Integer(), nullable=False), + sa.Column( + "definition", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + ), + sa.Column("modifiedAt", sa.DateTime(), nullable=False), + sa.Column("modifiedBy", sa.Uuid(), nullable=False), + sa.Column("modifiedByName", sa.String(), nullable=False), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("workspace_id"), + ) + + +def downgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + if insp.has_table("workspaces_imagery"): + op.drop_table("workspaces_imagery") + if insp.has_table("workspaces_long_quests"): + op.drop_table("workspaces_long_quests") + if insp.has_table("workspaces"): + op.drop_table("workspaces") diff --git a/api/core/config.py b/api/core/config.py index 94edba6..c6554c7 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -1,11 +1,31 @@ +import json + from pydantic_settings import BaseSettings, SettingsConfigDict + + +# Test outline: +# @test: Test that environment variables of the same name as the members of this class are correctly loaded into the members of this class. +# @test: Test that any environment variables that are not set in the environment are correctly loaded into the members of this class with their default values. +# @test: Test that strings and number values, empty strings and URLs are all correctly loaded as exemplified by the default values class Settings(BaseSettings): """Application settings.""" PROJECT_NAME: str = "Workspaces API" - TASK_DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" - OSM_DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" + # Allowed CORS origins. Accepts either a comma-separated list OR a JSON + # array (the deployment stack historically supplies a JSON array), plus "*" + # for all origins. Read via `cors_origins_list`, not this raw string. + # Examples: + # CORS_ORIGINS="https://workspaces.example.com,https://leaderboard.example.com" + # CORS_ORIGINS='["https://workspaces.example.com"]' + CORS_ORIGINS: str = "" + + TASK_DATABASE_URL: str = ( + "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" + ) + OSM_DATABASE_URL: str = ( + "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" + ) TDEI_BACKEND_URL: str = "https://portal-api-dev.tdei.us/api/v1/" TDEI_OIDC_URL: str = "https://account-dev.tdei.us/" @@ -14,19 +34,70 @@ class Settings(BaseSettings): DEBUG: bool = False # used for validation - WS_LONGFORM_SCHEMA_URL: str = ( + LONGFORM_SCHEMA_URL: str = ( "https://raw.githubusercontent.com/TaskarCenterAtUW/asr-quests/refs/heads/main/schema/schema.json" ) + IMAGERY_SCHEMA_URL: str = ( + "https://raw.githubusercontent.com/TaskarCenterAtUW/asr-imagery-list/refs/heads/main/schema/schema.json" + ) + + # proxy destination--"osm-web" is a virtual docker network endpoint + WS_OSM_HOST: str = "http://osm-web" - # proxy destination--"osm-rails" is a virtual docker network endpoint - WS_OSM_HOST: str = "http://osm-rails:3000" - #WS_OSM_HOST: str = "https://osm.workspaces-dev.sidewalks.washington.edu" + # OSM token bridge: when enabled, a validated TDEI token is mirrored into the + # OSM database's `oauth_access_tokens` table so osm-rails (doorkeeper) and + # cgimap authenticate it via their standard OAuth2 path -- no custom JWT + # handling needed in those services. The backend also auto-creates the + # doorkeeper `oauth_applications` row (keyed by WS_OSM_OAUTH_CLIENT_UID and + # owned by the dedicated system user below) that these tokens belong to, so + # no manual OSM setup is required. + WS_OSM_TOKEN_BRIDGE_ENABLED: bool = True + + # Stable client id (uid) for the auto-created doorkeeper application. Point + # this at an existing application's uid to reuse it instead of creating one. + WS_OSM_OAUTH_CLIENT_UID: str = "workspaces-backend" + + # Scopes granted to the application and mirrored tokens. Must cover the OSM + # API operations the frontend performs; see `lib/oauth.rb` in the OSM website. + WS_OSM_OAUTH_SCOPES: str = ( + "read_prefs write_prefs write_api write_changeset_comments " + "read_gpx write_gpx write_notes" + ) + + # Dedicated OSM `users` row that owns the auto-created doorkeeper application + # (satisfies oauth_applications.owner_id, a NOT-NULL FK to users). It never + # signs in; these values just need to be stable and unique among users. + WS_OSM_SYSTEM_USER_AUTH_UID: str = "workspaces-backend-system" + WS_OSM_SYSTEM_USER_DISPLAY_NAME: str = "Workspaces Backend (system)" + WS_OSM_SYSTEM_USER_EMAIL: str = "workspaces-backend-system@tdei.us" SENTRY_DSN: str = "" + @property + def cors_origins_list(self) -> list[str]: + """Allowed CORS origins as a list. + + Tolerates both formats the deployment has used: a JSON array + (``["https://a","https://b"]``) and a comma-separated string + (``https://a,https://b``). ``"*"`` is passed through as-is. + """ + raw = self.CORS_ORIGINS.strip() + if not raw: + return [] + if raw.startswith("["): + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, list): + return [str(o).strip() for o in parsed if str(o).strip()] + return [o.strip() for o in raw.split(",") if o.strip()] + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", + extra="ignore", # ignore unknown environment variables ) + settings = Settings() diff --git a/api/core/exceptions.py b/api/core/exceptions.py index 9bbbdba..fd1a7d4 100644 --- a/api/core/exceptions.py +++ b/api/core/exceptions.py @@ -15,6 +15,13 @@ def __init__(self, detail: str = "Resource already exists"): super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail) +class ConflictException(HTTPException): + """Base exception for conflict errors.""" + + def __init__(self, detail: str = "Conflict"): + super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail) + + class UnauthorizedException(HTTPException): """Base exception for unauthorized access errors.""" diff --git a/api/core/json_schema.py b/api/core/json_schema.py new file mode 100644 index 0000000..4048de5 --- /dev/null +++ b/api/core/json_schema.py @@ -0,0 +1,146 @@ +import asyncio +import json +from typing import Any, NoReturn + +import httpx +import jsonschema +from fastapi import HTTPException, status + +from api.core.config import settings + +# Shared HTTP client. Initialized by main.py lifespan. +_http_client: httpx.AsyncClient | None = None + +_longform_schema: dict | None = None +_longform_schema_lock = asyncio.Lock() + +_imagery_schema: dict | None = None +_imagery_schema_lock = asyncio.Lock() + +# Test outline: +# @test: Test that this class validates JSON payloads properly against the JSON schema fetched +# @test: Test that malformed JSON or JSON that doesn't validate +# @test: Test that any failed network requests are handled gracefully and return a proper error (not just "Exception") or worse a false positive validation + + +def init_json_schema_client() -> None: + global _http_client + _http_client = httpx.AsyncClient( + timeout=httpx.Timeout(connect=10, read=30, write=30, pool=10), + ) + + +def get_http_client() -> httpx.AsyncClient | None: + return _http_client + + +def _require_http_client() -> httpx.AsyncClient: + if _http_client is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Schema HTTP client is not initialized", + ) + return _http_client + + +async def close_json_schema_client() -> None: + global _http_client + if _http_client is not None: + await _http_client.aclose() + _http_client = None + + +async def _fetch_longform_schema() -> dict: + global _longform_schema + cached = _longform_schema + if cached is not None: + return cached + async with _longform_schema_lock: + if _longform_schema is None: + response = await _require_http_client().get(settings.LONGFORM_SCHEMA_URL) + response.raise_for_status() + schema: dict = response.json() + _longform_schema = schema + return schema + return _longform_schema + + +async def _fetch_imagery_schema() -> dict: + global _imagery_schema + cached = _imagery_schema + if cached is not None: + return cached + async with _imagery_schema_lock: + if _imagery_schema is None: + response = await _require_http_client().get(settings.IMAGERY_SCHEMA_URL) + response.raise_for_status() + schema: dict = response.json() + _imagery_schema = schema + return schema + return _imagery_schema + + +def _raise_for_fetch_error(e: Exception, label: str) -> NoReturn: + if isinstance(e, httpx.TimeoutException): + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail=f"Timed out fetching {label} schema", + ) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to fetch {label} schema: {e}", + ) + + +async def validate_quest_definition_schema(definition: str) -> None: + """ + Parse, type-check, and validate a quest definition string against the long- + form quest JSON schema. + """ + try: + parsed = json.loads(definition) + except json.JSONDecodeError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"'definition' must be valid JSON: {e}", + ) + if not parsed or not isinstance(parsed, dict): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'definition' must be a JSON object.", + ) + + try: + schema = await _fetch_longform_schema() + except HTTPException: + raise + except Exception as e: + _raise_for_fetch_error(e, "quest") + + try: + jsonschema.validate(instance=parsed, schema=schema) + except jsonschema.ValidationError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{e.message} at {list(e.path)}", + ) + + +async def validate_imagery_definition_schema(definition: list[Any]) -> None: + """ + Validate the provided definition against the imagery list schema. + """ + try: + schema = await _fetch_imagery_schema() + except HTTPException: + raise + except Exception as e: + _raise_for_fetch_error(e, "imagery") + + try: + jsonschema.validate(instance=definition, schema=schema) + except jsonschema.ValidationError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{e.message} at {list(e.path)}", + ) diff --git a/api/core/jwt.py b/api/core/jwt.py new file mode 100644 index 0000000..efb536b --- /dev/null +++ b/api/core/jwt.py @@ -0,0 +1,38 @@ +import jwt + +from api.core.config import settings + +# Singleton JWKS client reused to take advantage of internal cert/key caching: +_jwks_client: jwt.PyJWKClient | None = None + +# Test outline: +# @test: Test that this class validates JSON payloads properly against the expected JWT/token format +# @test: Test that malformed JSON or JSON that doesn't validate doesn't succeed +# @test: Test that any failed network requests are handled gracefully and return a proper error (not just "Exception") or worse a false positive validation + + +def _get_jwks_client() -> jwt.PyJWKClient: + global _jwks_client + + if _jwks_client is None: + _jwks_client = jwt.PyJWKClient( + f"{settings.TDEI_OIDC_URL.rstrip("/")}/realms/" + f"{settings.TDEI_OIDC_REALM}/protocol/openid-connect/certs" + ) + + return _jwks_client + + +def validate_and_decode_token(token: str) -> dict: + # TODO: use an async client like pyjwt-key-fetcher + signing_key = _get_jwks_client().get_signing_key_from_jwt(token) + + decoded = jwt.decode_complete( + token, + key=signing_key.key, + algorithms=["RS256"], + # OIDC server does not currently differentiate tokens by audience + options={"verify_aud": False}, + ) + + return decoded.get("payload", {}) diff --git a/api/core/security.py b/api/core/security.py index 7531677..020abf8 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -1,10 +1,10 @@ -import json +import secrets +import time from enum import StrEnum from uuid import UUID import cachetools -import jwt -import requests +import httpx from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import text @@ -12,20 +12,160 @@ from api.core.config import settings from api.core.database import get_osm_session, get_task_session +from api.core.jwt import validate_and_decode_token from api.core.logging import get_logger -from api.src.workspaces.schemas import WorkspaceUserRoleType +from api.src.users.schemas import WorkspaceUserRoleType + +# Test outline: +# @test: Test that the permissions structure here matches what is described in CLAUDE.md +# @test: Test that the methods on the UserInfo class return the correct values for a given set of project groups and workspace roles +# @test: Test that any failed network requests are handled gracefully +# @test: Test that the caching mechanism works correctly and evicts entries when roles change +# @test: Test that when WS_OSM_TOKEN_BRIDGE_ENABLED, a validated token is mirrored into oauth_access_tokens with the doorkeeper application + system-owner user + caller user auto-provisioned and expires_in from the JWT exp; and is a no-op when disabled +# @test: Test that re-presenting a token reactivates its OSM row (revoked_at cleared, expiry refreshed) and that a rotated (superseded) token is revoked, both gated on WS_OSM_TOKEN_BRIDGE_ENABLED # Set up logger for this module logger = get_logger(__name__) -# TTL cache for token validation (1 hour TTL, max 1000 entries) -_token_cache: cachetools.TTLCache[str, "UserInfo"] = cachetools.TTLCache( +# TTL cache keyed by a user's OIDC subject. Evict entries when roles change. We +# still validate the JWT signature and expiry on every request before reading a +# cached record. +_user_info_cache: cachetools.TTLCache[UUID, "UserInfo"] = cachetools.TTLCache( maxsize=1000, ttl=60 * 60 -) +) # type: ignore[assignment] # cachetools ctor can't infer key/value types + +# Shared HTTP client for TDEI backend calls. Initialized by main.py lifespan. +_tdei_client: httpx.AsyncClient | None = None + + +def init_tdei_client() -> None: + global _tdei_client + _tdei_client = httpx.AsyncClient( + base_url=settings.TDEI_BACKEND_URL, + timeout=httpx.Timeout(connect=10, read=30, write=30, pool=10), + ) + + +async def close_tdei_client() -> None: + global _tdei_client + if _tdei_client is not None: + await _tdei_client.aclose() + _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 + permissions. + + Call this after modifying a user's roles in the OSM DB to ensure the change + takes effect on their next request rather than after the cache TTL expires. + """ + _user_info_cache.pop(auth_uid, None) + security = HTTPBearer() +# @test: Test that this matches what is described in CLAUDE.md and that the methods on the UserInfo class return the correct values for a given set of project groups and workspace roles class TdeiProjectGroupRole(StrEnum): MEMBER = "member" POINT_OF_CONTACT = "poc" @@ -50,10 +190,14 @@ def __init__( self.tdeiRoles = tdeiRoles +# @test: Test that the values populated in this class match the expected values from the JWT and TDEI API responses, and that the methods return the correct roles based on the user's project group memberships and workspace roles +# @test: Test that the osmWorkspaceRoles, accessibleWorkspaceIds, and projectGroups attributes are correctly populated based on the user's roles in the OSM DB and TDEI API responses +# @test: Test that the comments in this doc that describe what is supposed to be in the attributes are accurate and match the actual data being stored in those attributes and what is returned. The comments should be considered authoratative class UserInfo: credentials: str user_uuid: UUID user_name: str + token_jti: str # JWT ID used to detect token rotation on cache hits # workspaceId, role from OSM DB osmWorkspaceRoles: dict[int, list[WorkspaceUserRoleType]] @@ -84,7 +228,9 @@ def isWorkspaceLead(self, workspaceId: int) -> bool: for pg in self.projectGroups: if TdeiProjectGroupRole.POINT_OF_CONTACT in pg.tdeiRoles: - if workspaceId in self.accessibleWorkspaceIds[pg.project_group_id]: + if workspaceId in self.accessibleWorkspaceIds.get( + pg.project_group_id, [] + ): return True return False @@ -105,6 +251,13 @@ def isWorkspaceContributor(self, workspaceId: int) -> bool: return True return False + def effective_role(self, workspaceId: int) -> WorkspaceUserRoleType: + if self.isWorkspaceLead(workspaceId): + return WorkspaceUserRoleType.LEAD + if self.isWorkspaceValidator(workspaceId): + return WorkspaceUserRoleType.VALIDATOR + return WorkspaceUserRoleType.CONTRIBUTOR + # can't use the ORM here since the ORM uses us! (circular dependency) def get_osm_db_session( @@ -118,30 +271,261 @@ def get_task_db_session( ) -> AsyncSession: return session + +# @test: + + async def validate_token( credentials: HTTPAuthorizationCredentials = Depends(security), osm_db_session: AsyncSession = Depends(get_osm_db_session), task_db_session: AsyncSession = Depends(get_task_db_session), ) -> UserInfo: - """Dependency to get current authenticated user from TDEI/KeyCloak token and APIs. + """ + Dependency that gets the current authenticated user from the TDEI/KeyCloak + access token and fetches permissions from TDEI APIs. - Results are cached by token for 1 hour to avoid repeated validation calls. + We validate the JWT's signature and expiry on every request. The expensive + TDEI API and DB lookups are cached for 1 hour and should be evicted when a + user's role changes via evict_user_from_cache(). """ token = credentials.credentials - # Check cache first - if token in _token_cache: - logger.info("Token validation cache hit") - return _token_cache[token] + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = validate_and_decode_token(token) + except Exception: + raise credentials_exception + + user_id_str: str | None = payload.get("sub") + if user_id_str is None: + raise credentials_exception + + try: + user_uuid = UUID(user_id_str) + except ValueError: + raise credentials_exception from None + + # Cache keyed by user UUID. If the token rotated (new "jti") since we + # created the cache entry, evict it so we fetch fresh claims: + # + if user_uuid in _user_info_cache: + cached = _user_info_cache[user_uuid] + current_jti = payload.get("jti", "") + if cached.token_jti == current_jti: + logger.info("Token validation cache hit") + return cached + logger.info("Token validation cache miss: token rotated") + # The old token is superseded; revoke its OSM row so it stops + # authenticating against osm-rails/cgimap before its own exp. + # TODO: this assumes a single active token per user. Truly concurrent + # sessions (multiple valid jtis for one user) will flap -- each request + # revokes the other session's OSM token and reactivates its own via the + # DO UPDATE upsert. To support multi-session, track multiple active jtis + # per user (cache + revoke set) instead of a single cached token. + await _revoke_osm_token(osm_db_session, cached.credentials) + del _user_info_cache[user_uuid] + + # Cache miss: fetch TDEI roles and DB data: + user_info = await _validate_token_uncached( + token, user_uuid, payload, osm_db_session, task_db_session + ) + _user_info_cache[user_uuid] = user_info - # Cache miss - perform full validation - user_info = await _validate_token_uncached(token, osm_db_session, task_db_session) - _token_cache[token] = user_info return user_info +async def _ensure_osm_user( + session: AsyncSession, + *, + auth_uid: str, + email: str | None, + display_name: str, +) -> int | None: + """Idempotently provision an OSM ``users`` row (auth_provider ``TDEI``) and + return its id. ``email`` is synthesised when absent -- the column is UNIQUE + and NOT NULL.""" + await session.execute( + text( + "INSERT INTO users (auth_uid, email, display_name, auth_provider, " + "status, pass_crypt, data_public, email_valid, terms_seen, " + "creation_time, terms_agreed, tou_agreed) " + "VALUES (:auth_uid, :email, :name, 'TDEI', 'active', :pass_crypt, " + "true, true, true, (now() at time zone 'utc'), " + "(now() at time zone 'utc'), (now() at time zone 'utc')) " + "ON CONFLICT (auth_uid) DO NOTHING" + ), + { + "auth_uid": auth_uid, + "email": email or f"{auth_uid}@tdei.invalid", + "name": display_name, + # OSM's User model validates pass_crypt length 8..255. TDEI manages + # auth, so this is a throwaway that just satisfies that rule (a too- + # short value makes the user invalid and breaks Rails operations + # that re-validate it via `validates :author, :associated => true`, + # e.g. posting a changeset comment). + "pass_crypt": secrets.token_hex(16), + }, + ) + row = ( + await session.execute( + text( + "SELECT id FROM users " + "WHERE auth_provider = 'TDEI' AND auth_uid = :auth_uid" + ), + {"auth_uid": auth_uid}, + ) + ).first() + return row[0] if row else None + + +async def _ensure_osm_oauth_application(session: AsyncSession) -> int | None: + """Ensure the doorkeeper application the mirrored tokens belong to exists, + creating it (owned by a dedicated system user) if needed. Idempotent by the + configured client uid; returns the application id.""" + owner_id = await _ensure_osm_user( + session, + auth_uid=settings.WS_OSM_SYSTEM_USER_AUTH_UID, + email=settings.WS_OSM_SYSTEM_USER_EMAIL, + display_name=settings.WS_OSM_SYSTEM_USER_DISPLAY_NAME, + ) + if owner_id is None: + return None + await session.execute( + text( + "INSERT INTO oauth_applications (owner_type, owner_id, name, uid, " + "secret, redirect_uri, scopes, confidential, created_at, updated_at) " + "VALUES ('User', :owner_id, :name, :uid, :secret, " + "'urn:ietf:wg:oauth:2.0:oob', :scopes, true, " + "(now() at time zone 'utc'), (now() at time zone 'utc')) " + "ON CONFLICT (uid) DO NOTHING" + ), + { + "owner_id": owner_id, + "name": "Workspaces Backend", + "uid": settings.WS_OSM_OAUTH_CLIENT_UID, + # Never used (tokens are inserted directly rather than issued via the + # OAuth flow), but the column is NOT NULL. + "secret": secrets.token_hex(32), + "scopes": settings.WS_OSM_OAUTH_SCOPES, + }, + ) + row = ( + await session.execute( + text("SELECT id FROM oauth_applications WHERE uid = :uid"), + {"uid": settings.WS_OSM_OAUTH_CLIENT_UID}, + ) + ).first() + return row[0] if row else None + + +async def _bridge_token_to_osm( + session: AsyncSession, + *, + user_uuid: UUID, + user_name: str, + email: str | None, + token: str, + exp: int | None, +) -> None: + """Mirror a validated TDEI token into the OSM database so osm-rails + (doorkeeper) and cgimap authenticate it through their standard OAuth2 path, + with no custom JWT handling required in those services. + + Auto-provisions everything it needs: the doorkeeper ``oauth_applications`` + row (owned by a dedicated system user), the caller's ``users`` row, and a + plaintext ``oauth_access_tokens`` row -- no manual OSM setup. Doorkeeper + stores tokens plaintext (``SecretStoring::Plain``), so the raw JWT matches on + lookup for both osm-rails and cgimap; ``expires_in`` tracks the JWT's ``exp``. + + No-op unless ``WS_OSM_TOKEN_BRIDGE_ENABLED``. Best-effort: a failure here + must not break token validation -- the ``/api/v1`` routes keep working; only + the proxied OSM calls would 401 until the row exists. + """ + if not settings.WS_OSM_TOKEN_BRIDGE_ENABLED: + return + + try: + app_id = await _ensure_osm_oauth_application(session) + if app_id is None: + return + user_id = await _ensure_osm_user( + session, auth_uid=str(user_uuid), email=email, display_name=user_name + ) + if user_id is None: + return + + expires_in = max(0, exp - int(time.time())) if exp else None + await session.execute( + text( + "INSERT INTO oauth_access_tokens (application_id, " + "resource_owner_id, token, scopes, created_at, expires_in) " + "VALUES (:app_id, :user_id, :token, :scopes, " + "(now() at time zone 'utc'), :expires_in) " + # Re-presenting a token reactivates it: refresh the expiry to + # track this validation and clear any prior revocation, so a + # token revoked on rotation heals if it is used again. + "ON CONFLICT (token) DO UPDATE SET " + "resource_owner_id = EXCLUDED.resource_owner_id, " + "scopes = EXCLUDED.scopes, " + "created_at = EXCLUDED.created_at, " + "expires_in = EXCLUDED.expires_in, " + "revoked_at = NULL" + ), + { + "app_id": app_id, + "user_id": user_id, + "token": token, + "scopes": settings.WS_OSM_OAUTH_SCOPES, + "expires_in": expires_in, + }, + ) + await session.commit() + except Exception as e: + # Never fail auth on a bridge error; the OSM row just won't exist yet. + logger.warning( + "Failed to bridge TDEI token into OSM oauth_access_tokens: %s", e + ) + await session.rollback() + + +async def _revoke_osm_token(session: AsyncSession, token: str) -> None: + """Revoke a superseded token's OSM ``oauth_access_tokens`` row (best-effort). + + Called when a user's token rotates (new ``jti``) so the previous JWT stops + authenticating against osm-rails/cgimap before its own ``exp`` rather than + lingering until it expires. No-op unless the bridge is enabled. + + Note: OSM/cgimap are reachable only *through* this proxy, and every request + first passes ``validate_token`` -- so a TDEI-revoked token is already + rejected upstream. This revocation is defense-in-depth for the superseded + token and keeps the OSM token store tidy. + """ + if not settings.WS_OSM_TOKEN_BRIDGE_ENABLED: + return + try: + await session.execute( + text( + "UPDATE oauth_access_tokens " + "SET revoked_at = (now() at time zone 'utc') " + "WHERE token = :token AND revoked_at IS NULL" + ), + {"token": token}, + ) + await session.commit() + except Exception as e: + logger.warning("Failed to revoke superseded OSM token: %s", e) + await session.rollback() + + async def _validate_token_uncached( token: str, + user_uuid: UUID, + payload: dict, osm_db_session: AsyncSession, task_db_session: AsyncSession, ) -> UserInfo: @@ -153,59 +537,49 @@ async def _validate_token_uncached( headers={"WWW-Authenticate": "Bearer"}, ) - jwks_client = jwt.PyJWKClient( - f"{settings.TDEI_OIDC_URL}realms/{settings.TDEI_OIDC_REALM}/protocol/openid-connect/certs" - ) - - signing_key = jwks_client.get_signing_key_from_jwt(token) - - jwtDecoded = jwt.decode_complete( - token, - key=signing_key.key, - algorithms=["RS256"], - # OIDC server does not currently differentiate tokens by audience - options={"verify_aud": False} - ) - payload = jwtDecoded.get("payload", {}) - - user_id: str | None = payload.get("sub") - if user_id is None: - raise credentials_exception - headers = { "Authorization": "Bearer " + token, "Content-Type": "application/json", } + r = UserInfo() + r.user_uuid = user_uuid + r.credentials = token + r.token_jti = payload.get("jti", "") + r.user_name = payload.get("preferred_username", "unknown") + # get user's project groups and roles from TDEI - # TODO: fix if user has > 50 PGs - authorizationUrl = ( - settings.TDEI_BACKEND_URL - + "/project-group-roles/" - + user_id - + "?page_no=1&page_size=50" - ) + pgs = [] - response = requests.get(authorizationUrl, headers=headers) + client = _tdei_client + if client is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="TDEI client is not initialized", + ) + + try: + response = await client.get( + f"project-group-roles/{user_uuid}", + headers=headers, + params={"page_no": 1, "page_size": 1000}, + ) + except httpx.RequestError: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Could not reach TDEI backend", + ) from None # token is not valid or server unavailable if response.status_code != 200: raise credentials_exception try: - content = response.text - j = json.loads(content) - except json.JSONDecodeError: + pg_data = response.json() + except Exception: raise credentials_exception - r = UserInfo() - r.credentials = token - r.user_uuid = UUID(payload.get("sub", "unknown")) - r.user_name = payload.get("preferred_username", "unknown") - - # project groups and roles from TDEI KeyCloak - pgs = [] - for i in j: + for i in pg_data: pgs.append( UserInfoPGMembership( project_group_id=i["tdei_project_group_id"], @@ -213,6 +587,7 @@ async def _validate_token_uncached( tdeiRoles=i["roles"], ) ) + r.projectGroups = pgs # workspaces within our set of PGs from tasking manager DB @@ -234,10 +609,8 @@ async def _validate_token_uncached( # workspace roles from OSM DB result = await osm_db_session.execute( - text( - "SELECT workspace_id, role FROM user_workspace_roles \ - WHERE user_auth_uid = :auth_uid" - ), + text("SELECT workspace_id, role FROM user_workspace_roles \ + WHERE user_auth_uid = :auth_uid"), {"auth_uid": str(r.user_uuid)}, ) workspaceRoles = list(result.mappings().all()) @@ -249,6 +622,17 @@ async def _validate_token_uncached( osmRoles[i["workspace_id"]].append(i["role"]) r.osmWorkspaceRoles = osmRoles + # Mirror this validated token into the OSM DB so proxied OSM/cgimap calls + # authenticate via the standard OAuth2 path. No-op unless configured. + await _bridge_token_to_osm( + osm_db_session, + user_uuid=user_uuid, + user_name=r.user_name, + email=payload.get("email"), + token=token, + exp=payload.get("exp"), + ) + logger.info("Finished validation of token") return r diff --git a/api/main.py b/api/main.py index af66673..c3708b3 100644 --- a/api/main.py +++ b/api/main.py @@ -1,9 +1,13 @@ import os import re +import sys +from contextlib import asynccontextmanager +from xml.etree import ElementTree as ET import httpx import sentry_sdk from fastapi import Depends, FastAPI, HTTPException, Request, status +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse, StreamingResponse from sqlmodel.ext.asyncio.session import AsyncSession from starlette.background import BackgroundTask @@ -11,9 +15,21 @@ from api.core import config from api.core.config import settings from api.core.database import get_task_session +from api.core.json_schema import close_json_schema_client, init_json_schema_client from api.core.logging import get_logger, setup_logging -from api.core.security import UserInfo, validate_token +from api.core.security import ( + UserInfo, + close_tdei_client, + init_tdei_client, + validate_token, +) +from api.src.osm.routes import router as osm_router +from api.src.tasking.audit.routes import router as tasking_audit_router +from api.src.tasking.projects.routes import me_router as tasking_me_router +from api.src.tasking.projects.routes import router as tasking_projects_router +from api.src.tasking.tasks.routes import router as tasking_tasks_router from api.src.teams.routes import router as teams_router +from api.src.users.routes import router as users_router from api.src.workspaces.repository import WorkspaceRepository from api.src.workspaces.routes import router as workspaces_router from api.utils.migrations import run_migrations @@ -21,29 +37,83 @@ sentry_sdk.init( dsn=config.settings.SENTRY_DSN, environment=os.getenv("ENV", "unknown"), + release=os.getenv("CODE_VERSION", "unknown"), debug=settings.DEBUG, ) +# Kept alongside `release` for any dashboards that query the `version` tag. sentry_sdk.set_tag("version", os.getenv("CODE_VERSION", "unknown")) # Set up logging configuration setup_logging() -# Optional: Run migrations on startup -run_migrations() - # Set up logger for this module logger = get_logger(__name__) +# Shared HTTP client for OSM proxy. Reuses connection pool across requests: +_osm_client: httpx.AsyncClient | None = None + + +def _require_osm_client() -> httpx.AsyncClient: + if _osm_client is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="OSM proxy client is not initialized", + ) + return _osm_client + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + # only run migrations when not under test + if "pytest" not in sys.modules: + run_migrations() + + # Run before app bootstrap: + global _osm_client + _osm_client = httpx.AsyncClient( + base_url=settings.WS_OSM_HOST, + # 2 hour timeout for long-running OSM imports: + timeout=httpx.Timeout(connect=10, read=7200, write=7200, pool=10), + ) + init_tdei_client() + init_json_schema_client() + + yield # App runs + + # Run after app cleanup: + await _osm_client.aclose() + _osm_client = None + await close_tdei_client() + await close_json_schema_client() + + app = FastAPI( title=settings.PROJECT_NAME, debug=settings.DEBUG, swagger_ui_parameters={"syntaxHighlight": False}, + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + max_age=100, ) # Include routers +app.include_router(osm_router, prefix="/api/v1") app.include_router(teams_router, prefix="/api/v1") +app.include_router(users_router, prefix="/api/v1") app.include_router(workspaces_router, prefix="/api/v1") +app.include_router(tasking_projects_router, prefix="/api/v1") +app.include_router(tasking_me_router, prefix="/api/v1") +app.include_router(tasking_tasks_router, prefix="/api/v1") +app.include_router(tasking_audit_router, prefix="/api/v1") + @app.get("/health") async def health_check(): @@ -63,21 +133,113 @@ def get_workspace_repository( return WorkspaceRepository(session) +# @test: Any headers defined in STRIP_REQUEST_HEADERS are not forwarded to the OSM service +# @test: Any headers defined in HOP_BY_HOP_HEADERS are not forwarded to the client +# @test: /api/capabilities.json is proxied to the OSM service without requiring authentication +# @test: Any request to the OSM service that returns a 4xx or 5xx status code is logged to Sentry with the correct message and the correct status code is returned to the client +# @test: Any request that matches the RegEx in TENANT_BYPASSES is allowed to proceed without an X-Workspace header, +# and any request that does not match the Regex in TENANT_BYPASSES and does not have an X-Workspace header returns a 400 Bad Request error +# @test: Only the methods defined in the @app.api_route decorator are allowed to be proxied to the OSM service, and any other methods return a 405 Method Not Allowed error +# @test: Any request with an X-Workspace header that does not match the user's accessible workspaces returns a 403 Forbidden error +# @test: Any request with a missing X-Workspace header that does not match the TENANT_BYPASSES returns a 400 Bad Request error +# @test: All the values for Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto headers are correctly set when proxied to the OSM service +# @test: The response from the OSM service is correctly streamed back to the client with the correct status code and headers, and the response body is not modified in any way + # This API route catches anything not otherwise defined above--MUST be last in this file # # h/t: https://stackoverflow.com/questions/70610266/proxy-an-external-website-using-python-fast-api-not-supporting-query-params # -# Define paths that do not require X-Workspace header -AUTH_WHITELIST_PATHS = [ - "/api/0.6/user/*", # used during authentication - "/api/0.6/workspaces/[0-9]*/bbox.json", # used to get workspace bbox without workspace header, to be removed +# According to HTTP/1.1, a proxy must not forward these "hop-by-hop" headers. +# We also exclude Content-Length because httpx recomputes the length from the +# actual content bytes, and this value may differ from the original after the +# proxy rewrites body content (i.e. after changset tag injection). +HOP_BY_HOP_HEADERS = frozenset( + [ + "connection", + "content-length", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + ] +) + +# Do not forward spoofed reverse-proxy informational headers: +STRIP_REQUEST_HEADERS = HOP_BY_HOP_HEADERS | { + "host", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-real-ip", + "forwarded", +} + +# Paths that do not require X-Workspace header, scoped by HTTP method. Each +# entry is a tuple of: (compiled regex, set of allowed methods). +TENANT_BYPASSES: list[tuple[re.Pattern[str], set[str]]] = [ + # Creating/deleting a workspace (no tenant context applies): + (re.compile(r"^/api/0\.6/workspaces/\d+$"), {"PUT", "DELETE"}), + # Provisioning users during authentication: + (re.compile(r"^/api/0\.6/user/[^/]+$"), {"PUT"}), ] +# Changeset create path — buffered for potential tag injection +_CHANGESET_CREATE_RE = re.compile(r"^/api/0\.6/changeset/create$") + + +@app.get("/api/capabilities.json") +async def capabilities(request: Request): + """Proxy OSM capabilities manifest without requiring authentication.""" + + client = _require_osm_client() + client_host = request.client.host if request.client else "unknown" + req_headers = [ + (k.encode(), v.encode()) + for k, v in request.headers.items() + if k.lower() not in STRIP_REQUEST_HEADERS + ] + [ + (b"Host", client.base_url.host.encode()), + (b"X-Real-IP", client_host.encode()), + (b"X-Forwarded-For", client_host.encode()), + (b"X-Forwarded-Host", (request.url.hostname or "").encode()), + (b"X-Forwarded-Proto", request.url.scheme.encode()), + ] + + url = httpx.URL(path="/api/capabilities.json") + rp_req = client.build_request("GET", url, headers=req_headers) + + try: + rp_resp = await client.send(rp_req, stream=True) + except httpx.TimeoutException: + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail="Upstream OSM service timed out", + ) + except httpx.ConnectError: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Could not connect to upstream OSM service", + ) + + forwarded_headers = { + k: v for k, v in rp_resp.headers.items() if k.lower() not in HOP_BY_HOP_HEADERS + } + + return StreamingResponse( + rp_resp.aiter_raw(), + status_code=rp_resp.status_code, + headers=forwarded_headers, + background=BackgroundTask(rp_resp.aclose), + ) + @app.api_route( "/{full_path:path}", - methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"], + methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"], ) async def catch_all( request: Request, @@ -87,8 +249,22 @@ async def catch_all( """ Catch-all route to proxy requests to the OSM service. """ - authorizedWorkspace = None + # `/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." + ), + ) + + workspace_id: int | None = None if request.headers.get("X-Workspace") is not None: try: workspace_id = int(request.headers.get("X-Workspace") or "-1") @@ -100,55 +276,93 @@ async def catch_all( if not current_user.isWorkspaceContributor(workspace_id): raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", - headers={"WWW-Authenticate": "Bearer"}, + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to this workspace", ) - return else: if not any( - re.search(pattern, request.url.path) for pattern in AUTH_WHITELIST_PATHS + p.fullmatch(request.url.path) and request.method in methods + for p, methods in TENANT_BYPASSES ): raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="You must set your workspace in the X-Workspace header to access OSM methods.", + status_code=status.HTTP_400_BAD_REQUEST, + detail="No X-Workspace header supplied", ) - return url = httpx.URL( path=request.url.path.strip(), query=request.url.query.encode("utf-8") ) - client = httpx.AsyncClient(base_url=settings.WS_OSM_HOST) - new_headers = list() - new_headers.append( - (bytes("Authorization", "utf-8"), request.headers.get("Authorization")) - ) + client = _require_osm_client() + client_host = request.client.host if request.client else "unknown" + req_headers = [ + (k.encode(), v.encode()) + for k, v in request.headers.items() + if k.lower() not in STRIP_REQUEST_HEADERS + ] + [ + (b"Host", client.base_url.host.encode()), + (b"X-Real-IP", client_host.encode()), + (b"X-Forwarded-For", client_host.encode()), + (b"X-Forwarded-Host", (request.url.hostname or "").encode()), + (b"X-Forwarded-Proto", request.url.scheme.encode()), + ] - if authorizedWorkspace is not None: - new_headers.append( - (bytes("X-Workspace", "utf-8"), bytes(str(authorizedWorkspace.id), "utf-8")) - ) - new_headers.append((bytes("Host", "utf-8"), bytes(client.base_url.host, "utf-8"))) + # For changeset creation, inject review_requested tag for contributors: + request_content: object = request.stream() + if ( + workspace_id is not None + and request.method == "PUT" + and _CHANGESET_CREATE_RE.fullmatch(request.url.path) + ): + workspace = await repository.getById(current_user, workspace_id) + + if ( + workspace.autoFlagReview + and current_user.effective_role(workspace_id) == "contributor" + ): + logger.info("Injecting review request tag") + body = await request.body() + root = ET.fromstring(body) + changeset_el = root.find("changeset") + if changeset_el is not None: + ET.SubElement(changeset_el, "tag", k="review_requested", v="yes") + request_content = ET.tostring(root, encoding="unicode").encode("utf-8") + else: + # Body was not consumed; fall back to buffered bytes to avoid + # double-read issues after the workspace fetch above. + request_content = await request.body() rp_req = client.build_request( - request.method, url, headers=new_headers, content=await request.body() + request.method, url, headers=req_headers, content=request_content ) - - rp_resp = await client.send(rp_req, stream=True) + try: + rp_resp = await client.send(rp_req, stream=True) + except httpx.TimeoutException: + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail="Upstream OSM service timed out", + ) + except httpx.ConnectError: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Could not connect to upstream OSM service", + ) if rp_resp.status_code >= 400 and rp_resp.status_code < 600: - sentry_sdk.capture_message( - f"Upstream request to {rp_req.url} returned status code {rp_resp.status_code}" + msg = ( + f"Upstream request to {rp_req.url} returned " + f"status code {rp_resp.status_code}" ) + sentry_sdk.capture_message(msg) + logger.warning(msg) - logger.warning( - f"Upstream request to {rp_req.url} returned status code {rp_resp.status_code}" - ) + forwarded_headers = { + k: v for k, v in rp_resp.headers.items() if k.lower() not in HOP_BY_HOP_HEADERS + } return StreamingResponse( rp_resp.aiter_raw(), status_code=rp_resp.status_code, - headers=rp_resp.headers, + headers=forwarded_headers, background=BackgroundTask(rp_resp.aclose), ) diff --git a/api/src/osm/__init__.py b/api/src/osm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/osm/repository.py b/api/src/osm/repository.py new file mode 100644 index 0000000..5a29b7e --- /dev/null +++ b/api/src/osm/repository.py @@ -0,0 +1,91 @@ +from sqlalchemy import text +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import ForbiddenException, NotFoundException +from api.core.security import UserInfo + + +class OSMRepository: + + def __init__(self, session: AsyncSession): + self.session = session + + async def getWorkspaceBBox( + self, + workspace_id: int, + ): + # Postgres does not support parameter binding for `SET search_path`, so + # workspace_id is interpolated directly. The explicit int() cast guards + # against SQL injection if this method is ever called from outside of a + # FastAPI path handler (where the type annotation acts as a safeguard). + # + await self.session.execute( + text(f"SET search_path TO 'workspace-{int(workspace_id)}', public") + ) + + # OSM stores node latitude/longitude as integers scaled by 1e7 + # (100-nanodegree units), so divide by 1e7 to return decimal degrees. + sql_query = text( + "select MAX(latitude) / 1e7 AS max_lat, MAX(longitude) / 1e7 AS max_lon, \ + MIN(latitude) / 1e7 AS min_lat, MIN(longitude) / 1e7 AS min_lon from nodes" + ) + + result = await self.session.execute(sql_query) + retVal = result.mappings().first() + + if retVal is None: + raise NotFoundException(f"Workspace with id {workspace_id} not found") + + return retVal + + async def getChangesetAdiff(self, workspace_id: int, changeset_id: int) -> list: + await self.session.execute( + text(f"SET search_path TO 'workspace-{int(workspace_id)}', public") + ) + result = await self.session.execute( + text("SELECT * FROM osm_augmented_diff(:changeset_id)"), + {"changeset_id": changeset_id}, + ) + + return list(result.mappings().all()) + + async def resolveChangeset( + self, + current_user: UserInfo, + workspace_id: int, + changeset_id: int, + ) -> None: + # Defense in depth: resolving a changeset is a validator/lead + # capability. The route also enforces this, but gate here too so the + # repository cannot be misused from another call site. + if not current_user.isWorkspaceLead( + workspace_id + ) and not current_user.isWorkspaceValidator(workspace_id): + raise ForbiddenException( + "Only workspace leads and validators can resolve changesets" + ) + + reviewer_uuid = str(current_user.user_uuid) + + await self.session.execute( + text(f"SET search_path TO 'workspace-{int(workspace_id)}', public") + ) + + await self.session.execute( + text( + "DELETE FROM changeset_tags" + " WHERE changeset_id = :cs_id AND k = 'review_requested'" + ), + {"cs_id": changeset_id}, + ) + + await self.session.execute( + text( + "INSERT INTO changeset_tags (changeset_id, k, v)" + " VALUES (:cs_id, 'reviewed_by', :uid)" + " ON CONFLICT (changeset_id, k) DO UPDATE SET v = :uid" + ), + {"cs_id": changeset_id, "uid": reviewer_uuid}, + ) + + await self.session.commit() diff --git a/api/src/osm/routes.py b/api/src/osm/routes.py new file mode 100644 index 0000000..cadee1e --- /dev/null +++ b/api/src/osm/routes.py @@ -0,0 +1,80 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.logging import get_logger +from api.core.security import UserInfo, validate_token +from api.src.osm.repository import OSMRepository +from api.src.osm.schemas import AugmentedDiffResponse +from api.src.workspaces.repository import WorkspaceRepository + +logger = get_logger(__name__) + +router = APIRouter(prefix="/workspaces", tags=["osm"]) + + +def get_osm_repo( + session: AsyncSession = Depends(get_osm_session), +) -> OSMRepository: + return OSMRepository(session) + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +@router.get( + "/{workspace_id}/changesets/{changeset_id}/adiff", + response_model=AugmentedDiffResponse, +) +async def get_changeset_adiff( + workspace_id: int, + changeset_id: int, + repository_ws: WorkspaceRepository = Depends(get_workspace_repo), + repository_osm: OSMRepository = Depends(get_osm_repo), + current_user: UserInfo = Depends(validate_token), +) -> AugmentedDiffResponse: + try: + await repository_ws.getById(current_user, workspace_id) + rows = await repository_osm.getChangesetAdiff(workspace_id, changeset_id) + + return AugmentedDiffResponse.from_rows(rows) + except Exception as e: + logger.error( + f"Failed to fetch adiff for changeset {changeset_id} " + f"in workspace {workspace_id}: {str(e)}" + ) + raise + + +@router.put( + "/{workspace_id}/changesets/{changeset_id}/resolve", + status_code=status.HTTP_204_NO_CONTENT, +) +async def resolve_changeset( + workspace_id: int, + changeset_id: int, + repository_ws: WorkspaceRepository = Depends(get_workspace_repo), + repository_osm: OSMRepository = Depends(get_osm_repo), + current_user: UserInfo = Depends(validate_token), +) -> None: + if not current_user.isWorkspaceLead( + workspace_id + ) and not current_user.isWorkspaceValidator(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads and validators can resolve changesets", + ) + + await repository_ws.getById(current_user, workspace_id) + + try: + await repository_osm.resolveChangeset(current_user, workspace_id, changeset_id) + except Exception as e: + logger.error( + f"Failed to resolve changeset {changeset_id}" + f" in workspace {workspace_id}: {str(e)}" + ) + raise diff --git a/api/src/osm/schemas.py b/api/src/osm/schemas.py new file mode 100644 index 0000000..04e46c7 --- /dev/null +++ b/api/src/osm/schemas.py @@ -0,0 +1,74 @@ +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel + + +class AdiffElement(BaseModel): + """A particular OSM element version in an augmented diff action.""" + + type: str # 'node' | 'way' | 'relation' + id: int + version: int + changeset: int + timestamp: datetime + user: Optional[str] = None + uid: Optional[int] = None + visible: bool + tags: dict[str, str] + # node-only: + lat: Optional[float] = None + lon: Optional[float] = None + # way-only: [{ref, lat, lon}] + nodes: Optional[list[dict[str, Any]]] = None + # relation-only: [{type, ref, role}] + members: Optional[list[dict[str, Any]]] = None + + +class AdiffAction(BaseModel): + type: str # 'create' | 'modify' | 'delete' + new: AdiffElement + old: Optional[AdiffElement] = None + + +class AugmentedDiffResponse(BaseModel): + actions: list[AdiffAction] + + @classmethod + def from_rows(cls, rows: list) -> "AugmentedDiffResponse": + def make_element(row: Any, prefix: str) -> Optional[AdiffElement]: + if row[f"{prefix}_id"] is None: + return None + + return AdiffElement( + type=row["element_type"], + id=row[f"{prefix}_id"], + version=row[f"{prefix}_version"], + changeset=row[f"{prefix}_changeset_id"], + timestamp=row[f"{prefix}_timestamp"], + user=row[f"{prefix}_user"], + uid=row[f"{prefix}_uid"], + visible=row[f"{prefix}_visible"], + tags=row[f"{prefix}_tags"] or {}, + lat=row[f"{prefix}_lat"], + lon=row[f"{prefix}_lon"], + nodes=row[f"{prefix}_nodes"], + members=row[f"{prefix}_members"], + ) + + def require_new(row: Any) -> AdiffElement: + element = make_element(row, "new") + if element is None: + raise ValueError(f"adiff row missing new_id: {dict(row)}") + return element + + actions = [ + AdiffAction( + type=row["action_type"], + new=require_new(row), + old=make_element(row, "old"), + ) + for row in rows + ] + + return cls(actions=actions) diff --git a/api/src/tasking/__init__.py b/api/src/tasking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/audit/__init__.py b/api/src/tasking/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/audit/dtos.py b/api/src/tasking/audit/dtos.py new file mode 100644 index 0000000..c8003de --- /dev/null +++ b/api/src/tasking/audit/dtos.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional +from uuid import UUID + +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import Pagination, WireModel + + +class ActorRef(WireModel): + """Resolved actor for an audit event.""" + + user_id: UUID + display_name: Optional[str] = None + + +class AuditEvent(WireModel): + """One row in `tasking_audit_events`, with `actor` joined for display.""" + + id: int + event_type: AuditEventType + project_id: int + actor: ActorRef + occurred_at: datetime + details: dict[str, Any] + project_deleted: bool = False + + +class AuditEventListResponse(WireModel): + results: list[AuditEvent] + pagination: Pagination + + +__all__ = [ + "ActorRef", + "AuditEvent", + "AuditEventListResponse", +] diff --git a/api/src/tasking/audit/repository.py b/api/src/tasking/audit/repository.py new file mode 100644 index 0000000..f5985b8 --- /dev/null +++ b/api/src/tasking/audit/repository.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional +from uuid import UUID + +from sqlalchemy import text +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import NotFoundException +from api.src.tasking.audit.dtos import ActorRef, AuditEvent, AuditEventListResponse +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import Pagination + +_ALLOWED_ORDER_DIR = {"ASC", "DESC"} + + +def _normalise_dir(order_dir: str) -> str: + return order_dir.upper() if order_dir.upper() in _ALLOWED_ORDER_DIR else "DESC" + + +def _clamp_page(page: int, page_size: int, max_page_size: int) -> tuple[int, int]: + return max(page, 1), max(min(page_size, max_page_size), 1) + + +class TaskingAuditRepository: + """Read-side queries for `tasking_audit_events`. + + Writes are owned by the task and project repositories — see + `_audit()` in `api/src/tasking/tasks/repository.py`. This module + only reads. + """ + + def __init__(self, session: AsyncSession): + self.session = session + + # ---- internal helpers ------------------------------------------------- + + async def _assert_project_visible( + self, workspace_id: int, project_id: int, *, include_deleted: bool + ) -> None: + """Confirm the project lives in the workspace; honour soft-delete + unless the caller explicitly opted into deleted projects. + """ + clause = ( + "SELECT 1 FROM tasking_projects " "WHERE id = :pid AND workspace_id = :wid" + ) + if not include_deleted: + clause += " AND deleted_at IS NULL" + + result = await self.session.execute( + text(clause), {"pid": project_id, "wid": workspace_id} + ) + if result.scalar() is None: + raise NotFoundException(f"Project {project_id} not found") + + async def _task_id_from_number(self, project_id: int, task_number: int) -> int: + result = await self.session.execute( + text( + "SELECT id FROM tasking_tasks " + "WHERE project_id = :pid AND task_number = :tn" + ), + {"pid": project_id, "tn": task_number}, + ) + task_id = result.scalar() + if task_id is None: + raise NotFoundException( + f"Task {task_number} not found on project {project_id}" + ) + return int(task_id) + + async def _resolve_actor_names( + self, auth_uids: set[str] + ) -> dict[str, Optional[str]]: + """Batch-load `users.display_name` for every distinct actor. + + Avoids the N+1 fetch when rendering long event lists. UIDs with + no `users` row map to None (the field is optional). + """ + if not auth_uids: + return {} + rows = await self.session.execute( + text( + "SELECT auth_uid, display_name FROM users " + "WHERE auth_uid = ANY(:uids)" + ), + {"uids": list(auth_uids)}, + ) + mapping = {row[0]: row[1] for row in rows.all()} + # Unknown actors still need a None entry so callers can `.get(uid)`. + for uid in auth_uids: + mapping.setdefault(uid, None) + return mapping + + @staticmethod + def _row_to_event( + row, + names: dict[str, Optional[str]], + ) -> AuditEvent: + ( + event_id, + event_type, + project_id, + task_id, + actor_uid, + occurred_at, + details, + project_deleted, + ) = row + details = details or {} + return AuditEvent( + id=event_id, + event_type=AuditEventType(event_type), + project_id=project_id, + actor=ActorRef( + user_id=UUID(str(actor_uid)), + display_name=names.get(str(actor_uid)), + ), + occurred_at=occurred_at, + details=details, + project_deleted=bool(project_deleted), + ) + + # ---- listing queries -------------------------------------------------- + + async def list_project_events( + self, + workspace_id: int, + project_id: int, + *, + event_type: Optional[AuditEventType] = None, + task_number: Optional[int] = None, + actor_user_id: Optional[UUID] = None, + occurred_from: Optional[datetime] = None, + occurred_to: Optional[datetime] = None, + include_deleted: bool = False, + page: int = 1, + page_size: int = 50, + order_dir: str = "DESC", + ) -> AuditEventListResponse: + await self._assert_project_visible( + workspace_id, project_id, include_deleted=include_deleted + ) + + page, page_size = _clamp_page(page, page_size, max_page_size=200) + order_dir = _normalise_dir(order_dir) + + # The task number is stored inside `details` JSONB rather than as a + # column, under the camelCase key `taskNumber` that the task + # repository emits, so filter via the typed accessor on that key. + 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->>'taskNumber')::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 `taskNumber` 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 taskNumber. Both forms point at the + # same task, so OR them. + where = [ + "project_id = :pid", + "(task_id = :tid OR (details->>'taskNumber')::int = :tn)", + ] + params: dict = {"pid": project_id, "tid": task_id, "tn": task_number} + if event_type is not None: + where.append("event_type = :et") + params["et"] = event_type.value + if actor_user_id is not None: + where.append("actor_user_auth_uid = :au") + params["au"] = str(actor_user_id) + if occurred_from is not None: + where.append("occurred_at >= :from_ts") + params["from_ts"] = occurred_from + if occurred_to is not None: + where.append("occurred_at <= :to_ts") + params["to_ts"] = occurred_to + where_sql = " AND ".join(where) + + total_q = await self.session.execute( + text(f"SELECT COUNT(*) FROM tasking_audit_events WHERE {where_sql}"), + params, + ) + total = int(total_q.scalar() or 0) + + rows = await self.session.execute( + text( + "SELECT id, event_type, project_id, task_id, " + " actor_user_auth_uid, occurred_at, details, project_deleted " + "FROM tasking_audit_events " + f"WHERE {where_sql} " + f"ORDER BY occurred_at {order_dir}, id {order_dir} " + "LIMIT :limit OFFSET :offset" + ), + {**params, "limit": page_size, "offset": (page - 1) * page_size}, + ) + raw_rows = list(rows.all()) + actor_uids = {str(r[4]) for r in raw_rows} + names = await self._resolve_actor_names(actor_uids) + + return AuditEventListResponse( + results=[self._row_to_event(r, names) for r in raw_rows], + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + +__all__ = ["TaskingAuditRepository"] diff --git a/api/src/tasking/audit/routes.py b/api/src/tasking/audit/routes.py new file mode 100644 index 0000000..e6ab06f --- /dev/null +++ b/api/src/tasking/audit/routes.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status # noqa: F401 +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.security import UserInfo, validate_token +from api.src.tasking.audit.dtos import AuditEventListResponse +from api.src.tasking.audit.repository import TaskingAuditRepository +from api.src.tasking.audit.schemas import AuditEventType +from api.src.workspaces.repository import WorkspaceRepository + +router = APIRouter( + prefix="/workspaces/{workspace_id}/tasking/projects", + tags=["tasking-audit"], +) + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def get_audit_repo( + session: AsyncSession = Depends(get_osm_session), +) -> TaskingAuditRepository: + return TaskingAuditRepository(session) + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +async def assert_workspace_visible( + workspace_id: int, + current_user: UserInfo, + workspace_repo: WorkspaceRepository, +) -> None: + """Tenancy gate: 404 if the caller's project groups don't own the + workspace (matches `WorkspaceRepository.getById`'s convention). + """ + await workspace_repo.getById(current_user, workspace_id) + + +# --------------------------------------------------------------------------- +# Project-level audit +# --------------------------------------------------------------------------- + + +@router.get( + "/{project_id}/audit", + response_model=AuditEventListResponse, +) +async def list_project_audit( + workspace_id: int, + project_id: int, + event_type: Optional[AuditEventType] = Query(default=None), + task_number: Optional[int] = Query(default=None, ge=1), + actor_user_id: Optional[UUID] = Query(default=None), + occurred_from: Optional[datetime] = Query(default=None), + occurred_to: Optional[datetime] = Query(default=None), + include_deleted: bool = Query(default=False), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + order_by_type: str = Query(default="DESC"), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + audit_repo: TaskingAuditRepository = Depends(get_audit_repo), +): + """Paginated project-level audit listing, newest first.""" + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await audit_repo.list_project_events( + workspace_id, + project_id, + event_type=event_type, + task_number=task_number, + actor_user_id=actor_user_id, + occurred_from=occurred_from, + occurred_to=occurred_to, + include_deleted=include_deleted, + page=page, + page_size=page_size, + order_dir=order_by_type, + ) + + +# --------------------------------------------------------------------------- +# Task-level audit +# --------------------------------------------------------------------------- + + +@router.get( + "/{project_id}/tasks/{task_number}/audit", + response_model=AuditEventListResponse, +) +async def list_task_audit( + workspace_id: int, + project_id: int, + task_number: int, + event_type: Optional[AuditEventType] = Query(default=None), + actor_user_id: Optional[UUID] = Query(default=None), + occurred_from: Optional[datetime] = Query(default=None), + occurred_to: Optional[datetime] = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=25, ge=1, le=200), + order_by_type: str = Query(default="DESC"), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + audit_repo: TaskingAuditRepository = Depends(get_audit_repo), +): + """Paginated audit listing for a single task, newest first.""" + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await audit_repo.list_task_events( + workspace_id, + project_id, + task_number, + event_type=event_type, + actor_user_id=actor_user_id, + occurred_from=occurred_from, + occurred_to=occurred_to, + page=page, + page_size=page_size, + order_dir=order_by_type, + ) + + +__all__ = ["router"] diff --git a/api/src/tasking/audit/schemas.py b/api/src/tasking/audit/schemas.py new file mode 100644 index 0000000..9dc27be --- /dev/null +++ b/api/src/tasking/audit/schemas.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Any, Optional +from uuid import UUID + +from sqlalchemy import Column +from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import Field, SQLModel + + +class AuditEventType(StrEnum): + """Closed set of event types written to `tasking_audit_events`. + + Mirrors the spec's `AuditEventType` enum; values match the literal + strings persisted in the `event_type` column. + """ + + PROJECT_CREATED = "project_created" + PROJECT_ACTIVATED = "project_activated" + PROJECT_CLOSED = "project_closed" + PROJECT_EDITED = "project_edited" + PROJECT_DELETED = "project_deleted" + PROJECT_RESET = "project_reset" + AOI_UPLOADED = "aoi_uploaded" + AOI_DELETED = "aoi_deleted" + TASKS_CREATED = "tasks_created" + TASK_STATE_CHANGED = "task_state_changed" + TASK_LOCKED = "task_locked" + TASK_LOCK_EXTENDED = "task_lock_extended" + TASK_LOCK_RENEWED = "task_lock_renewed" + TASK_UNLOCKED = "task_unlocked" + TASK_RESET = "task_reset" + CHANGESET_SUBMITTED = "changeset_submitted" + FEEDBACK_SUBMITTED = "feedback_submitted" + + +class TaskingAuditEvent(SQLModel, table=True): + """Read-only mirror of `tasking_audit_events`. + + Writes happen via raw SQL in the task/project repositories so the + insert path stays decoupled from this module. The table is FK-free + by design (it must survive a project soft-delete + child hard-delete). + """ + + __tablename__ = "tasking_audit_events" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + event_type: str = Field(max_length=64, nullable=False) + project_id: int = Field(nullable=False, index=True) + task_id: Optional[int] = Field(default=None, nullable=True) + actor_user_auth_uid: UUID = Field(nullable=False) + occurred_at: datetime = Field(nullable=False) + details: dict[str, Any] = Field( + default_factory=dict, + sa_column=Column(JSONB, nullable=False), + ) + project_deleted: bool = Field(default=False, nullable=False) + + +__all__ = ["AuditEventType", "TaskingAuditEvent"] diff --git a/api/src/tasking/projects/__init__.py b/api/src/tasking/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py new file mode 100644 index 0000000..01057a8 --- /dev/null +++ b/api/src/tasking/projects/dtos.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal, Optional +from uuid import UUID + +from pydantic import BaseModel, ConfigDict +from pydantic import Field as PydField +from pydantic import field_validator + +from api.src.tasking.projects.schemas import ( + AoiInput, + ProjectRoleType, + ProjectStatus, + TaskBoundaryType, + _MultiPolygon, +) + +# --------------------------------------------------------------------------- +# Shared DTO base. +# --------------------------------------------------------------------------- + + +class WireModel(BaseModel): + + model_config = ConfigDict(extra="forbid") + + +# --------------------------------------------------------------------------- +# Project DTOs +# --------------------------------------------------------------------------- + + +class ProjectRoleAssignment(WireModel): + """Seed entry for `tasking_project_roles` at create time.""" + + user_id: UUID + role: Literal["lead", "validator", "contributor"] + + +class ProjectCreateRequest(WireModel): + """Body for `POST /workspaces/{wid}/tasking/projects`.""" + + name: str = PydField(min_length=1, max_length=255) + instructions: Optional[str] = PydField(default=None, max_length=10_000) + review_required: bool = True + lock_timeout_hours: int = PydField(default=8, ge=1, le=720) + aoi: Optional[AoiInput] = None + role_assignments: list[ProjectRoleAssignment] = PydField(default_factory=list) + custom_imagery: Optional[dict[str, Any]] = PydField(default=None) + description: Optional[str] = PydField(default=None, max_length=10_000) + + @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 + custom_imagery: Optional[dict[str, Any]] = PydField(default=None) + description: Optional[str] = PydField(default=None, max_length=10_000) + + +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 + custom_imagery: Optional[Any] = None + description: Optional[str] = None + + +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 + + +class ProjectNameValidationResponse(WireModel): + exists: bool + + +# --------------------------------------------------------------------------- +# AOI response shape (canonical Feature wrapping a MultiPolygon) +# --------------------------------------------------------------------------- + + +class AoiFeature(WireModel): + type: Literal["Feature"] = "Feature" + geometry: _MultiPolygon + properties: dict[str, Any] = PydField(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Project-role management DTOs +# --------------------------------------------------------------------------- + + +class ProjectRoleItem(WireModel): + """One row of `GET /projects/{pid}/roles`.""" + + user_id: UUID + user_name: Optional[str] = None + role: ProjectRoleType + updated_at: datetime + + +class ProjectRoleListResponse(WireModel): + """Paginated-but-flat list of role assignments for a project.""" + + results: list[ProjectRoleItem] + + +class ProjectRoleAddRequest(WireModel): + """Body for `POST /projects/{pid}/roles`.""" + + user_id: UUID + role: ProjectRoleType + + +class ProjectRoleUpdateRequest(WireModel): + """Body for `PATCH /projects/{pid}/roles/{user_id}`.""" + + role: ProjectRoleType + + +# --------------------------------------------------------------------------- +# Self project-roles — `GET /me/workspaces/{wid}/tasking/projects/roles`. +# One row per project in the workspace with the caller's effective role on +# that project: explicit `tasking_project_roles` row if present, else the +# workspace-level role (which is the implicit fallback). +# --------------------------------------------------------------------------- + + +class SelfProjectRoleItem(WireModel): + project_id: int + project_name: str + project_status: ProjectStatus + role: ProjectRoleType + + +class SelfProjectRolesResponse(WireModel): + workspace_role: ProjectRoleType + projects: list[SelfProjectRoleItem] + + +__all__ = [ + "AoiFeature", + "Pagination", + "ProjectCreateRequest", + "ProjectListItem", + "ProjectListResponse", + "ProjectNameValidationResponse", + "ProjectResponse", + "ProjectRoleAddRequest", + "ProjectRoleAssignment", + "ProjectRoleItem", + "ProjectRoleListResponse", + "ProjectRoleUpdateRequest", + "ProjectUpdateRequest", + "SelfProjectRoleItem", + "SelfProjectRolesResponse", + "WireModel", +] diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py new file mode 100644 index 0000000..95875c1 --- /dev/null +++ b/api/src/tasking/projects/repository.py @@ -0,0 +1,1417 @@ +from __future__ import annotations + +import json +import secrets +from datetime import datetime +from typing import Any +from uuid import UUID + +from fastapi import HTTPException, status +from geoalchemy2.shape import from_shape, to_shape +from shapely.geometry import MultiPolygon as ShapelyMultiPolygon +from shapely.geometry import Polygon as ShapelyPolygon +from shapely.geometry import shape as shapely_shape +from sqlalchemy import func, or_, select, text, update +from sqlalchemy.exc import IntegrityError +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import ( + AlreadyExistsException, + ForbiddenException, + NotFoundException, +) +from api.core.security import UserInfo +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import ( + AoiFeature, + Pagination, + ProjectCreateRequest, + ProjectListItem, + ProjectListResponse, + ProjectResponse, + ProjectRoleAddRequest, + ProjectRoleItem, + ProjectRoleListResponse, + ProjectRoleUpdateRequest, + ProjectUpdateRequest, + SelfProjectRoleItem, + SelfProjectRolesResponse, +) +from api.src.tasking.projects.schemas import ( + AoiInput, + ProjectRoleType, + ProjectStatus, + TaskingProject, + _Feature, + _FeatureCollection, + _MultiPolygon, + _Polygon, +) + +# --------------------------------------------------------------------------- +# AOI helpers +# --------------------------------------------------------------------------- + + +def _aoi_to_shapely(aoi: AoiInput) -> ShapelyMultiPolygon: + """Normalise any of the accepted GeoJSON shapes to a Shapely + MultiPolygon. Bare Polygons are upcast to a single-member + MultiPolygon — storage column is always MULTIPOLYGON(4326). + """ + if isinstance(aoi, _FeatureCollection): + geom_dict = aoi.features[0].geometry.model_dump() + elif isinstance(aoi, _Feature): + geom_dict = aoi.geometry.model_dump() + elif isinstance(aoi, (_Polygon, _MultiPolygon)): + geom_dict = aoi.model_dump() + else: # pragma: no cover — Pydantic guards against this + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Unsupported AOI shape", + ) + + try: + geom = shapely_shape(geom_dict) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid AOI geometry: {e}", + ) from None + + if isinstance(geom, ShapelyPolygon): + geom = ShapelyMultiPolygon([geom]) + elif not isinstance(geom, ShapelyMultiPolygon): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="AOI must be a Polygon or MultiPolygon", + ) + + if not geom.is_valid: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"AOI is not a valid polygon: {geom.is_valid_reason if hasattr(geom, 'is_valid_reason') else 'self-intersection or invalid ring'}", # pyright: ignore[reportAttributeAccessIssue] + ) + + return geom + + +def _shapely_to_aoi_feature(geom: ShapelyMultiPolygon) -> AoiFeature: + """Build the GeoJSON Feature wrapper returned by the AOI GET endpoint.""" + raw = geom.__geo_interface__ + return AoiFeature( + type="Feature", + geometry=_MultiPolygon( + type="MultiPolygon", + coordinates=raw["coordinates"], + ), + properties={}, + ) + + +# --------------------------------------------------------------------------- +# Constraint translation — map Postgres `IntegrityError` to a precise +# HTTPException keyed by constraint name. Avoids the generic +# "everything is 409: name already exists" message. +# --------------------------------------------------------------------------- + + +def _constraint_name(e: IntegrityError) -> str | None: + """Return the PG constraint name from an `IntegrityError`, or None.""" + orig = getattr(e, "orig", None) + name = getattr(orig, "constraint_name", None) + if name: + return name + inner = getattr(orig, "__cause__", None) + return getattr(inner, "constraint_name", None) + + +def _translate_integrity_error(e: IntegrityError) -> HTTPException: + """Convert a Postgres constraint violation into an HTTPException.""" + name = _constraint_name(e) or "" + + if name == "tasking_projects_workspace_name_unique": + return AlreadyExistsException( + "A project with this name already exists in the workspace" + ) + + if name == "tasking_project_roles_user_auth_uid_fkey": + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "One or more `role_assignments[].user_id` values refer " + "to a user that has not signed in to Workspaces yet." + ), + ) + + if name == "tasking_tasks_project_id_fkey": + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Cannot insert tasks: parent project does not exist.", + ) + + # NOT NULL violations surface with no constraint_name on asyncpg. + orig_class = type(getattr(e, "orig", None)).__name__ + if orig_class == "NotNullViolationError": + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Required field is missing.", + ) + + if name: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Database constraint violated: {name}", + ) + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Database constraint violated.", + ) + + +# --------------------------------------------------------------------------- +# Repository +# --------------------------------------------------------------------------- + + +class TaskingProjectRepository: + """CRUD and lifecycle for tasking projects. + + Methods assume the caller has already passed the workspace tenancy + gate (`WorkspaceRepository.getById`); that check is performed at + the route layer. + """ + + def __init__(self, session: AsyncSession): + self.session = session + + async def _audit( + self, + *, + event_type: AuditEventType, + project_id: int, + actor_uuid: UUID, + details: dict[str, Any] | None = None, + ) -> None: + await self.session.execute( + text( + "INSERT INTO tasking_audit_events " + "(event_type, project_id, task_id, actor_user_auth_uid, details) " + "VALUES (:et, :pid, NULL, :uid, CAST(:dt AS jsonb))" + ), + { + "et": event_type, + "pid": project_id, + "uid": str(actor_uuid), + "dt": json.dumps(details or {}), + }, + ) + + # ---- internal helpers -------------------------------------------- + + async def _get_active(self, workspace_id: int, project_id: int) -> TaskingProject: + """Fetch a non-deleted project scoped to a workspace; raise 404 otherwise.""" + result = await self.session.execute( + select(TaskingProject).where( + (TaskingProject.id == project_id) + & (TaskingProject.workspace_id == workspace_id) + & ( + TaskingProject.deleted_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + 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, + custom_imagery=project.custom_imagery, # type: ignore[arg-type] + description=project.description, # type: ignore[arg-type] + ) + + async def _provision_users_from_tdei( + self, + missing_uuids: list[str], + project_group_id: str, + bearer_token: str, + ) -> list[str]: + """Look up `missing_uuids` against TDEI's project-group members + and insert matching rows into `users`. Return the UUIDs that are + still unknown (not members of the project group at all). + + This is the auto-provisioning fallback for `role_assignments[]`: + a user who is known to TDEI but has not yet performed any action + that would write them into `users` (e.g. first-time sign-in). + """ + from sqlalchemy import text + + from api.core.security import fetch_project_group_users + + try: + members = await fetch_project_group_users(project_group_id, bearer_token) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to fetch project group users from TDEI: {e}", + ) from e + + by_uid = {m.auth_uid: m for m in members} + + """ + INSERT INTO users ( + email, + display_name, + auth_uid, + auth_provider, + status, + pass_crypt, + data_public, + email_valid, + terms_seen, + creation_time, + terms_agreed, + tou_agreed) + VALUES ( + $1, + $2, + $3, + 'TDEI', + 'active', + 'none', + true, + true, + true, + (now() at time zone 'utc'), + (now() at time zone 'utc'), + (now() at time zone 'utc')) + RETURNING id + ) + """ + + 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, auth_provider, status, pass_crypt, data_public, email_valid, terms_seen, creation_time, terms_agreed, tou_agreed) " + "VALUES (:uid, :email, :name, 'TDEI', 'active', :pass_crypt, true, true, true, (now() at time zone 'utc'), (now() at time zone 'utc'), (now() at time zone 'utc')) " + "ON CONFLICT (auth_uid) DO NOTHING" + ), + params={ + "uid": uid, + "email": member.email, + "name": member.display_name, + # OSM validates pass_crypt length 8..255; a too-short value + # makes the user invalid and breaks Rails ops that re-validate + # the author (changeset/note comments). TDEI manages auth, so + # this is a throwaway. + "pass_crypt": secrets.token_hex(16), + }, + ) + resolved.add(uid) + + if resolved: + # Commit the new `users` rows in their own transaction so the + # subsequent FK insert into `tasking_project_roles` resolves. + await self.session.commit() + + return [u for u in missing_uuids if u not in resolved] + + async def _missing_user_auth_uids(self, uuids: list[UUID]) -> list[str]: + """Return the subset of `uuids` without a matching `users` row. + + Preflight for the `tasking_project_roles.user_auth_uid` FK so + downstream inserts produce a clean 422 with the offending ids + instead of a 23503 foreign-key-violation. + """ + if not uuids: + return [] + + from sqlalchemy import text + + rows = await self.session.execute( + text("SELECT auth_uid FROM users WHERE auth_uid = ANY(:uids)"), + {"uids": [str(u) for u in uuids]}, + ) + existing = {row[0] for row in rows.all()} + return [str(u) for u in uuids if str(u) not in existing] + + async def _task_count(self, project_id: int) -> int: + """Read-only task count for a project; raw SQL to keep this + module independent of the tasks sub-module's ORM.""" + from sqlalchemy import text + + result = await self.session.execute( + text("SELECT COUNT(*) FROM tasking_tasks WHERE project_id = :pid"), + {"pid": project_id}, + ) + return int(result.scalar() or 0) + + # ---- create / list / get / patch / delete ------------------------ + + async def list_projects( + self, + workspace_id: int, + *, + status_filter: ProjectStatus | None = None, + text_search: str | None = None, + page: int = 1, + page_size: int = 20, + order_by: str = "created_at", + order_dir: str = "DESC", + ) -> ProjectListResponse: + valid_order = { + "created_at": TaskingProject.created_at, + "updated_at": TaskingProject.updated_at, + "name": TaskingProject.name, + } + col = valid_order.get(order_by, TaskingProject.created_at) + col = col.desc() if order_dir.upper() == "DESC" else col.asc() + + where = (TaskingProject.workspace_id == workspace_id) & ( + TaskingProject.deleted_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + 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 project_name_exists(self, workspace_id: int, name: str) -> bool: + result = await self.session.execute( + select(func.count()) + .select_from(TaskingProject) + .where( + (TaskingProject.workspace_id == workspace_id) + & (TaskingProject.name == name) + & ( + TaskingProject.deleted_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + None + ) + ) + ) + ) + return int(result.scalar() or 0) > 0 + + async def create( + self, + workspace_id: int, + current_user: UserInfo, + body: ProjectCreateRequest, + tdei_project_group_id: str, + ) -> ProjectResponse: + # Preflight every user_auth_uid that will be inserted into + # `tasking_project_roles` — the creator's auto-LEAD seed plus + # any explicit role_assignments. Any UUID missing from `users` + # is looked up against TDEI's project-group member list and + # auto-provisioned; only UUIDs that are not members at all + # surface as a 422 to the caller. + candidate_uuids: list[UUID] = [current_user.user_uuid] + candidate_uuids.extend(ra.user_id for ra in body.role_assignments or []) + missing = await self._missing_user_auth_uids(candidate_uuids) + + if missing: + creator_uid = str(current_user.user_uuid) + if creator_uid in missing: + # The signed-in caller is not in `users`. Pull them + # from TDEI just like any other role-assignment user; + # if even TDEI doesn't know them, surface 409 because + # the request can never succeed. + pass + + # Auto-provision via TDEI for the members of this project group. + still_missing = await self._provision_users_from_tdei( + missing, + project_group_id=tdei_project_group_id, + bearer_token=current_user.credentials, + ) + + if creator_uid in still_missing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "Your user record could not be provisioned: TDEI " + "does not list you as a member of this project " + "group. Sign in to Workspaces once, then retry." + ), + ) + if still_missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "One or more `role_assignments[].user_id` " + "values are not members of this workspace's " + "project group in TDEI." + ), + "missing_user_ids": still_missing, + }, + ) + + project = TaskingProject( + workspace_id=workspace_id, + name=body.name, + instructions=body.instructions, + review_required=body.review_required, + lock_timeout_hours=body.lock_timeout_hours, + created_by=current_user.user_uuid, + created_by_name=current_user.user_name, + custom_imagery=body.custom_imagery, # type: ignore[arg-type] + description=body.description, # type: ignore[arg-type] + ) + if body.aoi is not None: + geom = _aoi_to_shapely(body.aoi) + project.aoi = from_shape(geom, srid=4326) + + try: + self.session.add(project) + await self.session.flush() # need project.id for role rows + + # Seed project-level role overrides. + if body.role_assignments: + from sqlalchemy import text + + for ra in body.role_assignments: + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role) " + "VALUES (:pid, :uid, :role) " + "ON CONFLICT (project_id, user_auth_uid) " + "DO UPDATE SET role = EXCLUDED.role, " + " updated_at = NOW()" + ), + { + "pid": project.id, + "uid": str(ra.user_id), + "role": ra.role, + }, + ) + + # Creator is auto-assigned the LEAD role on the project, + # mirroring the workspace-creator auto-LEAD convention. + from sqlalchemy import text + + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role) " + "VALUES (:pid, :uid, 'lead') " + "ON CONFLICT DO NOTHING" + ), + { + "pid": project.id, + "uid": str(current_user.user_uuid), + }, + ) + + await self._audit( + event_type=AuditEventType.PROJECT_CREATED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + details={"name": project.name}, + ) + await self.session.commit() + await self.session.refresh(project) + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + + return self._to_response(project) + + async def get(self, workspace_id: int, project_id: int) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + tc = await self._task_count(project.id) # type: ignore[arg-type] + return self._to_response(project, task_count=tc) + + async def patch( + self, + workspace_id: int, + project_id: int, + body: ProjectUpdateRequest, + current_user: UserInfo, + ) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + + if project.status == ProjectStatus.DONE: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="A closed project cannot be edited", + ) + + # `review_required` immutable after activation + if ( + body.review_required is not None + and project.status != ProjectStatus.DRAFT + and body.review_required != project.review_required + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="review_required is immutable after activation", + ) + + updates: dict[str, Any] = {} + if body.name is not None: + updates["name"] = body.name.strip() + if body.instructions is not None: + updates["instructions"] = body.instructions + if body.lock_timeout_hours is not None: + updates["lock_timeout_hours"] = body.lock_timeout_hours + if body.review_required is not None: + updates["review_required"] = body.review_required + if body.custom_imagery is not None: + updates["custom_imagery"] = body.custom_imagery # type: ignore[arg-type] + if body.description is not None: + updates["description"] = body.description + + if updates: + updates["updated_at"] = datetime.now() + try: + await self.session.execute( + update(TaskingProject) + .where( + TaskingProject.id + == project.id # pyright: ignore[reportArgumentType] + ) + .values(**updates) + ) + await self._audit( + event_type=AuditEventType.PROJECT_EDITED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + details={ + "updatedFields": [k for k in updates if k != "updated_at"] + }, + ) + await self.session.commit() + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + await self.session.refresh(project) + + tc = await self._task_count(project.id) # type: ignore[arg-type] + return self._to_response(project, task_count=tc) + + async def soft_delete( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> None: + project = await self._get_active(workspace_id, project_id) + + # Refuse if any active task locks remain. + from sqlalchemy import text + + active = await self.session.execute( + text( + "SELECT 1 FROM tasking_locks " + "WHERE project_id = :pid AND released_at IS NULL LIMIT 1" + ), + {"pid": project.id}, + ) + if active.scalar() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project has active task locks; force-release first", + ) + + # Soft-delete the project, hard-delete its tasks, flag audit rows. + await self.session.execute( + update(TaskingProject) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) + .values(deleted_at=datetime.now()) + ) + await self.session.execute( + text("DELETE FROM tasking_tasks WHERE project_id = :pid"), + {"pid": project.id}, + ) + # Insert the deletion event before flagging so this row is caught too. + await self._audit( + event_type=AuditEventType.PROJECT_DELETED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.execute( + text( + "UPDATE tasking_audit_events SET project_deleted = TRUE " + "WHERE project_id = :pid" + ), + {"pid": project.id}, + ) + await self.session.commit() + + # ---- lifecycle transitions --------------------------------------- + + async def activate( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Only draft projects can be activated", + ) + if not project.name.strip(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project name is required", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required", + ) + tc = await self._task_count(project.id) # type: ignore[arg-type] + if tc == 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project must have at least one task", + ) + + # Activation requires at least one explicit contributor or + # validator allocation (creator's auto-LEAD does not count). + from sqlalchemy import text + + worker_q = await self.session.execute( + text( + "SELECT 1 FROM tasking_project_roles " + "WHERE project_id = :pid AND role IN ('contributor', 'validator') " + "LIMIT 1" + ), + {"pid": project.id}, + ) + if worker_q.scalar() is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="At least one contributor or validator must be allocated to the project", + ) + + await self.session.execute( + update(TaskingProject) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) + .values(status=ProjectStatus.OPEN, updated_at=datetime.now()) + ) + await self._audit( + event_type=AuditEventType.PROJECT_ACTIVATED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.commit() + await self.session.refresh(project) + return self._to_response(project, task_count=tc) + + async def close( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.OPEN: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Only open projects can be closed", + ) + + from sqlalchemy import text + + not_done = await self.session.execute( + text( + "SELECT 1 FROM tasking_tasks " + "WHERE project_id = :pid AND status <> 'completed' LIMIT 1" + ), + {"pid": project.id}, + ) + if not_done.scalar() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project has tasks that are not yet completed", + ) + active_lock = await self.session.execute( + text( + "SELECT 1 FROM tasking_locks " + "WHERE project_id = :pid AND released_at IS NULL LIMIT 1" + ), + {"pid": project.id}, + ) + if active_lock.scalar() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project has active task locks", + ) + + await self.session.execute( + update(TaskingProject) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) + .values(status=ProjectStatus.DONE, updated_at=datetime.now()) + ) + await self._audit( + event_type=AuditEventType.PROJECT_CLOSED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.commit() + await self.session.refresh(project) + tc = await self._task_count(project.id) # type: ignore[arg-type] + return self._to_response(project, task_count=tc) + + async def reset( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> ProjectResponse: + """LEAD reset — see spec §projects.""" + project = await self._get_active(workspace_id, project_id) + if project.status == ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Cannot reset a draft project (nothing to reset)", + ) + + from sqlalchemy import text + + # Release every active lock with release_reason='reset'. + await self.session.execute( + text( + "UPDATE tasking_locks " + "SET released_at = NOW(), release_reason = 'reset' " + "WHERE project_id = :pid AND released_at IS NULL" + ), + {"pid": project.id}, + ) + # Wind tasks back to to_map; clear last_mapper_id. + await self.session.execute( + text( + "UPDATE tasking_tasks " + "SET status = 'to_map', last_mapper_id = NULL, updated_at = NOW() " + "WHERE project_id = :pid AND status <> 'to_map'" + ), + {"pid": project.id}, + ) + # Project reopens if it was done. + if project.status == ProjectStatus.DONE: + await self.session.execute( + update(TaskingProject) + .where( + TaskingProject.id + == project.id # pyright: ignore[reportArgumentType] + ) + .values(status=ProjectStatus.OPEN, updated_at=datetime.now()) + ) + + await self._audit( + event_type=AuditEventType.PROJECT_RESET, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.commit() + await self.session.refresh(project) + tc = await self._task_count(project.id) # type: ignore[arg-type] + return self._to_response(project, task_count=tc) + + # ---- AOI --------------------------------------------------------- + + async def get_aoi(self, workspace_id: int, project_id: int) -> AoiFeature: + project = await self._get_active(workspace_id, project_id) + if project.aoi is None: + raise NotFoundException("AOI is not set on this project") + geom = to_shape(project.aoi) + if isinstance(geom, ShapelyPolygon): # defensive + geom = ShapelyMultiPolygon([geom]) + return _shapely_to_aoi_feature(geom) # pyright: ignore[reportArgumentType] + + async def upload_aoi( + self, workspace_id: int, project_id: int, aoi: AoiInput, current_user: UserInfo + ) -> AoiFeature: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="AOI can only be set or replaced while the project is in draft", + ) + + geom = _aoi_to_shapely(aoi) + from sqlalchemy import text + + # Replacing AOI hard-deletes any saved tasks and clears the + # boundary type (per spec). + await self.session.execute( + text("DELETE FROM tasking_tasks WHERE project_id = :pid"), + {"pid": project.id}, + ) + await self.session.execute( + update(TaskingProject) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) + .values( + aoi=from_shape(geom, srid=4326), + task_boundary_type=None, + updated_at=datetime.now(), + ) + ) + await self._audit( + event_type=AuditEventType.AOI_UPLOADED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.commit() + return _shapely_to_aoi_feature(geom) + + # ---- Project roles ------------------------------------------------ + # + # Schema lives in `tasking_project_roles (project_id, user_auth_uid, + # role, updated_at)` with PK on (project_id, user_auth_uid) and a FK + # to `users.auth_uid`. The `add` path inserts via raw SQL so duplicate + # rows raise the PK uniqueness violation translated into a 409; FK + # violations on `user_auth_uid` are caught with a preflight so the + # caller gets a 422 listing the missing user id. + + async def _is_project_lead(self, project_id: int, user_uuid: UUID) -> bool: + """True if the user holds a `lead` role on the given project.""" + from sqlalchemy import text + + result = await self.session.execute( + text( + "SELECT 1 FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid " + "AND role = 'lead' LIMIT 1" + ), + {"pid": project_id, "uid": str(user_uuid)}, + ) + return result.scalar() is not None + + async def assert_can_manage_roles( + self, + workspace_id: int, + project_id: int, + current_user: UserInfo, + ) -> None: + """Permission gate for role-management endpoints. + + Allows either a workspace-level LEAD (per `UserInfo.isWorkspaceLead`) + or a project-level LEAD recorded in `tasking_project_roles`. The + project must already exist; otherwise the underlying `_get_active` + call raises 404. + """ + await self._get_active(workspace_id, project_id) + if current_user.isWorkspaceLead(workspace_id): + return + if await self._is_project_lead(project_id, current_user.user_uuid): + return + raise ForbiddenException( + "Only a workspace lead or project lead can manage roles " "on this project." + ) + + async def _lead_count(self, project_id: int) -> int: + from sqlalchemy import text + + result = await self.session.execute( + text( + "SELECT COUNT(*) FROM tasking_project_roles " + "WHERE project_id = :pid AND role = 'lead'" + ), + {"pid": project_id}, + ) + return int(result.scalar() or 0) + + async def list_roles( + self, workspace_id: int, project_id: int + ) -> ProjectRoleListResponse: + """Return every role assignment on the project, newest first.""" + await self._get_active(workspace_id, project_id) + from sqlalchemy import text + + rows = await self.session.execute( + text( + "SELECT r.user_auth_uid, u.display_name, r.role, r.updated_at " + "FROM tasking_project_roles r " + "LEFT JOIN users u ON u.auth_uid = r.user_auth_uid " + "WHERE r.project_id = :pid " + "ORDER BY r.updated_at DESC" + ), + {"pid": project_id}, + ) + items = [ + ProjectRoleItem( + user_id=UUID(uid), + user_name=name, + role=ProjectRoleType(role), + updated_at=updated, + ) + for uid, name, role, updated in rows.all() + ] + return ProjectRoleListResponse(results=items) + + async def add_role( + self, + workspace_id: int, + project_id: int, + body: ProjectRoleAddRequest, + user_token: str, + project_group_id: str, + ) -> 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: # User never logged in, so we try to provision them from TDEI + still_missing = await self._provision_users_from_tdei( + missing, + project_group_id=project_group_id, + bearer_token=user_token, + ) + # If TDEI doesn't list the user either, surface the same structured + # 422 the create path returns rather than falling through to a raw + # foreign-key violation with a generic string detail. + if still_missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "The `user_id` is not a member of this " + "workspace's project group in TDEI." + ), + "missing_user_ids": still_missing, + }, + ) + + from sqlalchemy import text + + existing = await self.session.execute( + text( + "SELECT 1 FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(body.user_id)}, + ) + if existing.scalar() is not None: + raise AlreadyExistsException( + "This user is already assigned a role on the project. " + "Use PATCH to change their role." + ) + + try: + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role, updated_at) " + "VALUES (:pid, :uid, :role, NOW())" + ), + { + "pid": project_id, + "uid": str(body.user_id), + "role": body.role.value, + }, + ) + await self.session.commit() + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + + # Re-read so the response carries server-set `updated_at` and the + # `display_name` join with `users`. + return await self._get_role(project_id, body.user_id) + + async def update_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + ) -> ProjectRoleItem: + """Change a user's role. 404 if not assigned. 422 if it would + remove the last LEAD (per spec — projects must always have one).""" + await self._get_active(workspace_id, project_id) + + current = await self._get_role_or_none(project_id, user_id) + if current is None: + raise NotFoundException( + f"User {user_id} has no role on project {project_id}" + ) + + # Guard: demoting the only LEAD would orphan the project. + if ( + current.role == ProjectRoleType.LEAD + and body.role != ProjectRoleType.LEAD + and await self._lead_count(project_id) <= 1 + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Cannot demote the last LEAD on this project. Assign " + "another LEAD first." + ), + ) + + from sqlalchemy import text + + await self.session.execute( + text( + "UPDATE tasking_project_roles " + "SET role = :role, updated_at = NOW() " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + { + "pid": project_id, + "uid": str(user_id), + "role": body.role.value, + }, + ) + await self.session.commit() + return await self._get_role(project_id, user_id) + + async def list_self_project_roles( + self, + workspace_id: int, + current_user: UserInfo, + ) -> SelfProjectRolesResponse: + """One row per non-deleted project in the workspace with the + caller's effective role on that project. + + Resolution rule: + 1. If `tasking_project_roles` has an explicit row for this + user on the project, that wins. + 2. Otherwise the caller's workspace-level role applies (the + implicit fallback that grants `contributor` access to + every project in the workspace). + + Workspace-level role is mapped to the same `ProjectRoleType` + enum the project rows use. + """ + # Workspace-level role first — used as the fallback for projects + # without an explicit override. + from api.src.users.schemas import WorkspaceUserRoleType + + ws_role_raw = current_user.effective_role(workspace_id) + ws_role = ProjectRoleType(ws_role_raw.value) + + from sqlalchemy import text + + rows = await self.session.execute( + text( + "SELECT p.id, p.name, p.status, r.role " + "FROM tasking_projects p " + "LEFT JOIN tasking_project_roles r " + " ON r.project_id = p.id " + " AND r.user_auth_uid = :uid " + "WHERE p.workspace_id = :wid " + " AND p.deleted_at IS NULL " + "ORDER BY p.created_at DESC" + ), + { + "wid": workspace_id, + "uid": str(current_user.user_uuid), + }, + ) + + items = [ + SelfProjectRoleItem( + project_id=pid, + project_name=name, + project_status=ProjectStatus(status_value), + role=( + ProjectRoleType(explicit_role) + if explicit_role is not None + else ws_role + ), + ) + for pid, name, status_value, explicit_role in rows.all() + ] + return SelfProjectRolesResponse( + workspace_role=ws_role, + projects=items, + ) + + async def get_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + ) -> ProjectRoleItem: + """Single-user role read. 404 if no assignment exists.""" + await self._get_active(workspace_id, project_id) + item = await self._get_role_or_none(project_id, user_id) + if item is None: + raise NotFoundException( + f"User {user_id} has no role on project {project_id}" + ) + return item + + async def upsert_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + ) -> tuple[ProjectRoleItem, bool]: + """PUT semantics: idempotent insert-or-update on a role row. + + Returns ``(item, created)`` where ``created`` is True iff the + row did not exist before this call. The route layer uses that + flag to pick 201 vs 200. + + Honours the last-LEAD guard: a PUT that would demote the only + remaining LEAD returns 422 — assign another LEAD first. + """ + await self._get_active(workspace_id, project_id) + + current = await self._get_role_or_none(project_id, user_id) + + if current is not None: + # Update path — guard against orphaning the project. + if ( + current.role == ProjectRoleType.LEAD + and body.role != ProjectRoleType.LEAD + and await self._lead_count(project_id) <= 1 + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Cannot demote the last LEAD on this project. " + "Assign another LEAD first." + ), + ) + else: + # Insert path — `user_id` must exist in `users`. Preflight so + # the caller gets a 422 with `missing_user_ids` rather than + # a generic 23503 FK violation. + missing = await self._missing_user_auth_uids([user_id]) + if missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "`user_id` refers to a user that has not signed " + "in to Workspaces yet — no `users` row exists." + ), + "missing_user_ids": missing, + }, + ) + + from sqlalchemy import text + + try: + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role, updated_at) " + "VALUES (:pid, :uid, :role, NOW()) " + "ON CONFLICT (project_id, user_auth_uid) DO UPDATE " + "SET role = EXCLUDED.role, updated_at = NOW()" + ), + { + "pid": project_id, + "uid": str(user_id), + "role": body.role.value, + }, + ) + await self.session.commit() + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + + item = await self._get_role(project_id, user_id) + return item, current is None + + async def remove_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + ) -> None: + """Drop a role assignment. 404 if absent. 422 on last-LEAD removal.""" + await self._get_active(workspace_id, project_id) + + current = await self._get_role_or_none(project_id, user_id) + if current is None: + raise NotFoundException( + f"User {user_id} has no role on project {project_id}" + ) + + if ( + current.role == ProjectRoleType.LEAD + and await self._lead_count(project_id) <= 1 + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Cannot remove the last LEAD on this project. Assign " + "another LEAD first." + ), + ) + + from sqlalchemy import text + + await self.session.execute( + text( + "DELETE FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(user_id)}, + ) + await self.session.commit() + + async def _get_role(self, project_id: int, user_id: UUID) -> ProjectRoleItem: + item = await self._get_role_or_none(project_id, user_id) + if item is None: # pragma: no cover — only called after insert/update + raise NotFoundException( + f"User {user_id} has no role on project {project_id}" + ) + return item + + async def _get_role_or_none( + self, project_id: int, user_id: UUID + ) -> ProjectRoleItem | None: + from sqlalchemy import text + + row = await self.session.execute( + text( + "SELECT r.user_auth_uid, u.display_name, r.role, r.updated_at " + "FROM tasking_project_roles r " + "LEFT JOIN users u ON u.auth_uid = r.user_auth_uid " + "WHERE r.project_id = :pid AND r.user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(user_id)}, + ) + result = row.first() + if result is None: + return None + uid, name, role, updated = result + return ProjectRoleItem( + user_id=UUID(uid), + user_name=name, + role=ProjectRoleType(role), + updated_at=updated, + ) + + async def delete_aoi( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> None: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="AOI can only be deleted while the project is in draft", + ) + if project.aoi is None: + raise NotFoundException("AOI is not set on this project") + + from sqlalchemy import text + + await self.session.execute( + text("DELETE FROM tasking_tasks WHERE project_id = :pid"), + {"pid": project.id}, + ) + await self.session.execute( + update(TaskingProject) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) + .values( + aoi=None, + task_boundary_type=None, + updated_at=datetime.now(), + ) + ) + await self._audit( + event_type=AuditEventType.AOI_DELETED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) + await self.session.commit() + + +__all__ = ["TaskingProjectRepository"] diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py new file mode 100644 index 0000000..4d531d0 --- /dev/null +++ b/api/src/tasking/projects/routes.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.security import UserInfo, validate_token +from api.src.tasking.projects.dtos import ( + AoiFeature, + ProjectCreateRequest, + ProjectListResponse, + ProjectNameValidationResponse, + ProjectResponse, + ProjectRoleAddRequest, + ProjectRoleItem, + ProjectRoleListResponse, + ProjectRoleUpdateRequest, + ProjectUpdateRequest, + SelfProjectRolesResponse, +) +from api.src.tasking.projects.repository import TaskingProjectRepository +from api.src.tasking.projects.schemas import AoiInput, ProjectStatus +from api.src.workspaces.repository import WorkspaceRepository + +router = APIRouter( + prefix="/workspaces/{workspace_id}/tasking/projects", + tags=["tasking-projects"], +) + +# Sibling router for caller-scoped (`/me/...`) tasking endpoints. Kept on +# its own prefix so the project-scoped router above can stay focused. +me_router = APIRouter( + prefix="/me/workspaces/{workspace_id}/tasking/projects", + tags=["tasking-projects"], +) + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def get_project_repo( + session: AsyncSession = Depends(get_osm_session), +) -> TaskingProjectRepository: + return TaskingProjectRepository(session) + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +async def assert_workspace_visible( + workspace_id: int, + current_user: UserInfo, + workspace_repo: WorkspaceRepository, +) -> None: + """Tenancy gate: 404 if the caller's project groups don't own the + workspace (matches `WorkspaceRepository.getById`'s convention). + """ + await workspace_repo.getById(current_user, workspace_id) + + +def assert_workspace_lead(workspace_id: int, current_user: UserInfo) -> None: + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User does not have permission to edit this workspace", + ) + + +# --------------------------------------------------------------------------- +# Projects — CRUD + lifecycle +# --------------------------------------------------------------------------- + + +@router.get("", response_model=ProjectListResponse) +async def list_projects( + workspace_id: int, + status_filter: Annotated[ProjectStatus | None, Query(alias="status")] = None, + text_search: str | None = Query(default=None, max_length=255), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=200), + order_by: str = Query("created_at"), + order_by_type: str = Query("DESC"), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.list_projects( + workspace_id, + status_filter=status_filter, + text_search=text_search, + page=page, + page_size=page_size, + order_by=order_by, + order_dir=order_by_type, + ) + + +@router.post( + "", + response_model=ProjectResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_project( + workspace_id: int, + body: ProjectCreateRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + # `getById` enforces the tenancy gate AND returns the workspace + # row, whose `tdeiProjectGroupId` we hand to the repository so it + # can auto-provision `role_assignments[]` users from TDEI. + workspace = await workspace_repo.getById(current_user, workspace_id) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.create( + workspace_id, + current_user, + body, + tdei_project_group_id=str(workspace.tdeiProjectGroupId), + ) + + +@router.get("/validate-name", response_model=ProjectNameValidationResponse) +async def validate_project_name( + workspace_id: int, + name: str = Query(..., min_length=1, max_length=255), + 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) + normalized_name = name.strip() + if not normalized_name: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="name cannot be blank", + ) + return ProjectNameValidationResponse( + exists=await project_repo.project_name_exists(workspace_id, normalized_name) + ) + + +@router.get("/{project_id}", response_model=ProjectResponse) +async def get_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.get(workspace_id, project_id) + + +@router.patch("/{project_id}", response_model=ProjectResponse) +async def update_project( + workspace_id: int, + project_id: int, + body: ProjectUpdateRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.patch(workspace_id, project_id, body, current_user) + + +@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + await project_repo.soft_delete(workspace_id, project_id, current_user) + + +@router.post("/{project_id}/activate", response_model=ProjectResponse) +async def activate_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.activate(workspace_id, project_id, current_user) + + +@router.post("/{project_id}/close", response_model=ProjectResponse) +async def close_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.close(workspace_id, project_id, current_user) + + +@router.post("/{project_id}/reset", response_model=ProjectResponse) +async def reset_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.reset(workspace_id, project_id, current_user) + + +# --------------------------------------------------------------------------- +# AOI +# --------------------------------------------------------------------------- + + +@router.get("/{project_id}/aoi", response_model=AoiFeature) +async def get_project_aoi( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.get_aoi(workspace_id, project_id) + + +@router.post("/{project_id}/aoi", response_model=AoiFeature) +async def upload_project_aoi( + workspace_id: int, + project_id: int, + body: AoiInput = Body(...), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.upload_aoi(workspace_id, project_id, body, current_user) + + +# --------------------------------------------------------------------------- +# Project role management +# +# Writes require either workspace LEAD or project LEAD (delegated to the +# repository so the project-LEAD check can hit `tasking_project_roles`). +# Reads are open to any caller who passes the workspace tenancy gate. +# --------------------------------------------------------------------------- + + +@router.get( + "/{project_id}/roles", + response_model=ProjectRoleListResponse, +) +async def list_project_roles( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.list_roles(workspace_id, project_id) + + +@router.post( + "/{project_id}/roles", + response_model=ProjectRoleItem, + status_code=status.HTTP_201_CREATED, +) +async def add_project_role( + workspace_id: int, + project_id: int, + body: ProjectRoleAddRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) + # Get the project Group ID and Bearer token + brearer_token = current_user.credentials + workspace = await workspace_repo.getById(current_user, workspace_id) + project_group_id = str(workspace.tdeiProjectGroupId) + return await project_repo.add_role( + workspace_id, + project_id, + body, + user_token=brearer_token, + project_group_id=project_group_id, + ) + + +@router.get( + "/{project_id}/roles/{user_id}", + response_model=ProjectRoleItem, +) +async def get_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.get_role(workspace_id, project_id, user_id) + + +@router.put( + "/{project_id}/roles/{user_id}", + response_model=ProjectRoleItem, +) +async def put_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + response: Response, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + """Idempotent upsert. 201 on insert, 200 on update. Last-LEAD guarded.""" + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) + item, created = await project_repo.upsert_role( + workspace_id, project_id, user_id, body + ) + if created: + response.status_code = status.HTTP_201_CREATED + return item + + +@router.patch( + "/{project_id}/roles/{user_id}", + response_model=ProjectRoleItem, +) +async def update_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) + return await project_repo.update_role(workspace_id, project_id, user_id, body) + + +@router.delete( + "/{project_id}/roles/{user_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def remove_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) + await project_repo.remove_role(workspace_id, project_id, user_id) + + +@router.delete("/{project_id}/aoi", status_code=status.HTTP_204_NO_CONTENT) +async def delete_project_aoi( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + await project_repo.delete_aoi(workspace_id, project_id, current_user) + + +# --------------------------------------------------------------------------- +# /me/* — caller-scoped views. +# +# Mounted on `me_router` so the path prefix is `/me/workspaces/{wid}/...` +# instead of the project-scoped router's `/workspaces/{wid}/...`. Reads +# only; no writes here. +# --------------------------------------------------------------------------- + + +@me_router.get( + "/roles", + response_model=SelfProjectRolesResponse, +) +async def list_self_project_roles( + workspace_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + """Per-project role for the caller across every project in the workspace. + + Returns the workspace-level role and a per-project list with the + project-LEVEL role override where one exists, else the workspace role. + Single round-trip for the project-list page. + """ + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.list_self_project_roles(workspace_id, current_user) diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py new file mode 100644 index 0000000..85fc0b2 --- /dev/null +++ b/api/src/tasking/projects/schemas.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Any, Literal, Optional +from uuid import UUID + +from geoalchemy2 import Geometry +from pydantic import BaseModel +from pydantic import Field as PydField +from sqlalchemy import Column +from sqlalchemy import Enum as SAEnum +from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import Field, SQLModel + +# --------------------------------------------------------------------------- +# Enums (mirrors of postgres enums in the migration) +# --------------------------------------------------------------------------- + + +class ProjectStatus(StrEnum): + DRAFT = "draft" + OPEN = "open" + DONE = "done" + + +class TaskBoundaryType(StrEnum): + GRID = "grid" + IMPORT = "import" + + +# --------------------------------------------------------------------------- +# Table model +# --------------------------------------------------------------------------- + + +class TaskingProject(SQLModel, table=True): + """Tasking project — lifecycle, AOI, settings.""" + + __tablename__ = "tasking_projects" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + + workspace_id: int = Field(nullable=False, index=True) + + name: str = Field(max_length=255, nullable=False) + instructions: Optional[str] = None + + status: ProjectStatus = Field( + default=ProjectStatus.DRAFT, + sa_column=Column( + SAEnum( + ProjectStatus, + name="tasking_project_status", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=False, + ), + ) + + review_required: bool = Field(default=True, nullable=False) + lock_timeout_hours: int = Field(default=8, nullable=False) + + task_boundary_type: Optional[TaskBoundaryType] = Field( + default=None, + sa_column=Column( + SAEnum( + TaskBoundaryType, + name="tasking_task_boundary_type", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=True, + ), + ) + + # PostGIS MultiPolygon in EPSG:4326. Stored as WKB and converted + # to / from GeoJSON in the repository layer. + aoi: Optional[Any] = Field( + default=None, + sa_column=Column(Geometry(geometry_type="MULTIPOLYGON", srid=4326)), + ) + + created_by: UUID = Field(nullable=False) + created_by_name: Optional[str] = None + + created_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + updated_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False, "onupdate": datetime.now}, + ) + deleted_at: Optional[datetime] = None + + # Custom Imagery data for the project. Stored as JSONB in the database and converted to / from a Pydantic model in the repository layer. + custom_imagery: Optional[Any] = Field( + default=None, sa_column=Column(JSONB, nullable=True, default=None) + ) + + description: Optional[str] = Field(default=None, nullable=True) + + +# --------------------------------------------------------------------------- +# Project role enum + table +# --------------------------------------------------------------------------- + + +class ProjectRoleType(StrEnum): + LEAD = "lead" + VALIDATOR = "validator" + CONTRIBUTOR = "contributor" + + +class TaskingProjectRole(SQLModel, table=True): + """Per-project role override stored in ``tasking_project_roles``. + + Maps a user (`users.auth_uid`) to a role within a single tasking + project. Created at project-seed time and managed via the + ``/projects/{pid}/roles`` endpoints thereafter. Cascades on project + delete; the user FK is intentionally non-cascading so user records + are not destroyed by project deletions. + """ + + __tablename__ = "tasking_project_roles" # type: ignore[assignment] + + project_id: int = Field(primary_key=True, nullable=False) + user_auth_uid: str = Field(primary_key=True, nullable=False) + role: ProjectRoleType = Field( + sa_column=Column( + SAEnum( + ProjectRoleType, + name="workspace_role", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=False, + ), + ) + updated_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False, "onupdate": datetime.now}, + ) + + +# --------------------------------------------------------------------------- +# GeoJSON input shapes — accepted by the AOI endpoints. Polygon inputs +# are upcast to single-member MultiPolygon at the repository layer. +# --------------------------------------------------------------------------- + + +class _Polygon(BaseModel): + type: Literal["Polygon"] + coordinates: list[list[list[float]]] + + +class _MultiPolygon(BaseModel): + type: Literal["MultiPolygon"] + coordinates: list[list[list[list[float]]]] + + +class _Feature(BaseModel): + type: Literal["Feature"] + geometry: _Polygon | _MultiPolygon + properties: Optional[dict[str, Any]] = None + + +class _FeatureCollection(BaseModel): + type: Literal["FeatureCollection"] + features: list[_Feature] = PydField(min_length=1, max_length=1) + + +AoiInput = _Polygon | _MultiPolygon | _Feature | _FeatureCollection + + +__all__ = [ + "AoiInput", + "ProjectRoleType", + "ProjectStatus", + "TaskBoundaryType", + "TaskingProject", + "TaskingProjectRole", + "_Feature", + "_FeatureCollection", + "_MultiPolygon", + "_Polygon", +] diff --git a/api/src/tasking/tasks/__init__.py b/api/src/tasking/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/tasks/dtos.py b/api/src/tasking/tasks/dtos.py new file mode 100644 index 0000000..79a8bdb --- /dev/null +++ b/api/src/tasking/tasks/dtos.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal, Optional +from uuid import UUID + +from pydantic import Field as PydField + +from api.src.tasking.projects.dtos import Pagination, WireModel +from api.src.tasking.tasks.schemas import FeedbackReason, TaskStatus + +# --------------------------------------------------------------------------- +# Task boundary GeoJSON (input for /tasks/validate and /tasks/save) +# --------------------------------------------------------------------------- + + +class TaskBoundaryPolygon(WireModel): + type: Literal["Polygon"] + coordinates: list[list[list[float]]] + + +class TaskBoundaryFeature(WireModel): + type: Literal["Feature"] + geometry: TaskBoundaryPolygon + properties: Optional[dict[str, Any]] = None + + +class TaskBoundariesFeatureCollection(WireModel): + type: Literal["FeatureCollection"] + features: list[TaskBoundaryFeature] = PydField(min_length=1) + + +GridSource = Literal["grid", "import"] + + +# --------------------------------------------------------------------------- +# Task detail / list +# --------------------------------------------------------------------------- + + +class TaskLockSummary(WireModel): + user_id: UUID + user_name: Optional[str] = None + locked_at: datetime + expires_at: datetime + + +class LastMapper(WireModel): + user_id: UUID + user_name: Optional[str] = None + + +class TaskResponse(WireModel): + id: int + task_number: int + status: TaskStatus + geometry: TaskBoundaryPolygon + area_sqkm: float + lock: Optional[TaskLockSummary] = None + last_mapper: Optional[LastMapper] = None + created_at: datetime + updated_at: datetime + + +class TaskListResponse(WireModel): + tasks: list[TaskResponse] + pagination: Pagination + + +# --------------------------------------------------------------------------- +# Validate / Save +# --------------------------------------------------------------------------- + + +class ValidateWarning(WireModel): + task_index: int + issue: Literal["polygon_exceeds_grid_size"] + area_sqkm: Optional[float] = None + + +class ValidatePreviewResponse(WireModel): + valid: bool + warnings: list[ValidateWarning] = PydField(default_factory=list) + source: GridSource = "import" + feature_collection: TaskBoundariesFeatureCollection + + +class SaveTasksRequest(WireModel): + source: GridSource + feature_collection: TaskBoundariesFeatureCollection + + +class SaveTasksResponse(WireModel): + project_id: int + task_boundary_type: GridSource + task_count: int + tasks: list[TaskResponse] + idempotency_key: Optional[str] = None + replayed: bool = False + + +# --------------------------------------------------------------------------- +# Submit / lock +# --------------------------------------------------------------------------- + + +class FeedbackInput(WireModel): + reason_category: Optional[FeedbackReason] = None + notes: str = PydField(min_length=1, max_length=4000) + + +class SubmitRequest(WireModel): + # osm_changeset_id: int = PydField(ge=1) + done: bool + feedback: Optional[FeedbackInput] = None + + +class SubmitTaskChangeset(WireModel): + osm_changeset_id: int = PydField(ge=1) + + +class SubmitTaskChangesetResponse(WireModel): + osm_changeset_id: int = PydField(ge=1) + task_number: int + project_id: int + workspace_id: int + inserted_id: Optional[int] = ( + None # ID of the newly inserted changeset row, if applicable + ) + + +class ExistingLockSummary(WireModel): + task_number: int + task_status: TaskStatus + locked_at: datetime + expires_at: datetime + + +__all__ = [ + "ExistingLockSummary", + "FeedbackInput", + "GridSource", + "LastMapper", + "SaveTasksRequest", + "SaveTasksResponse", + "SubmitRequest", + "TaskBoundariesFeatureCollection", + "TaskBoundaryFeature", + "TaskBoundaryPolygon", + "TaskListResponse", + "TaskLockSummary", + "TaskResponse", + "ValidatePreviewResponse", + "ValidateWarning", +] diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py new file mode 100644 index 0000000..40e8232 --- /dev/null +++ b/api/src/tasking/tasks/repository.py @@ -0,0 +1,1192 @@ +from __future__ import annotations + +import hashlib +import json +import math +from datetime import datetime, timedelta +from typing import Any, Optional +from uuid import UUID + +from fastapi import HTTPException, status +from geoalchemy2.shape import from_shape, to_shape +from shapely.geometry import MultiPolygon as ShapelyMultiPolygon +from shapely.geometry import Polygon as ShapelyPolygon +from shapely.geometry import shape as shapely_shape +from sqlalchemy import func, select, text, update +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.exc import IntegrityError +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import ( + AlreadyExistsException, + ForbiddenException, + NotFoundException, +) +from api.core.security import UserInfo +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import Pagination +from api.src.tasking.projects.schemas import ProjectStatus, TaskingProject +from api.src.tasking.tasks.dtos import ( + ExistingLockSummary, + FeedbackInput, + LastMapper, + SaveTasksRequest, + SaveTasksResponse, + SubmitRequest, + TaskBoundariesFeatureCollection, + TaskBoundaryFeature, + TaskBoundaryPolygon, + TaskListResponse, + TaskLockSummary, + TaskResponse, + ValidatePreviewResponse, + ValidateWarning, +) +from api.src.tasking.tasks.schemas import ( + LockReleaseReason, + TaskingChangeset, + TaskingFeedback, + TaskingLock, + TaskingTask, + TaskStatus, +) + +# Equirectangular approximation for area calculations on small +# EPSG:4326 polygons: 1 degree latitude ≈ 111.32 km. Sufficient for +# the grid-size warning threshold; precise areas need a metric +# reprojection. +_DEG2_TO_KM2 = 111.32 * 111.32 + +# Threshold for the `polygon_exceeds_grid_size` warning. Default is +# 5000 m per side (matching the conventional Tasking Manager grid). +# Overridable via the `TM_TASKING_GRID_SIZE_METERS` environment +# variable; read once at import time. +import os as _os # noqa: E402 + +try: + _GRID_SIZE_M = float(_os.getenv("TM_TASKING_GRID_SIZE_METERS", "5000")) +except ValueError: + _GRID_SIZE_M = 5000.0 +_GRID_MAX_KM2 = (_GRID_SIZE_M / 1000.0) ** 2 + + +# --------------------------------------------------------------------------- +# Geometry helpers +# --------------------------------------------------------------------------- + + +def _polygon_to_shapely(geom_dict: dict) -> ShapelyPolygon: + try: + geom = shapely_shape(geom_dict) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid task geometry: {e}", + ) from None + if not isinstance(geom, ShapelyPolygon): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Each task feature must be a Polygon", + ) + if not geom.is_valid: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Task polygon is not valid (self-intersection or invalid ring)", + ) + return geom + + +def _shapely_polygon_to_geojson(geom: ShapelyPolygon) -> TaskBoundaryPolygon: + raw = geom.__geo_interface__ + return TaskBoundaryPolygon(type="Polygon", coordinates=raw["coordinates"]) + + +def _polygon_area_km2(geom: ShapelyPolygon) -> float: + return float(geom.area) * _DEG2_TO_KM2 + + +# AOI containment is checked with `aoi.covers(poly)`, which uses exact +# predicates. Grid generation produces cells clipped against the AOI; +# the clipped vertices land on (or fractionally outside) the AOI edge +# due to floating-point rounding, so `covers` returns False even though +# the cell is topologically inside the AOI. +# +# Treat a polygon as inside the AOI when the area of `poly.difference(aoi)` +# is below this fraction of the polygon's own area. 1e-9 corresponds to +# ~1 µm² at this latitude — well below any real-world AOI authoring error. +_AOI_COVER_REL_TOLERANCE = 1e-9 + + +def _aoi_covers(aoi_geom, poly: ShapelyPolygon) -> bool: + """Tolerant containment test for AOI vs. task polygon.""" + if aoi_geom.covers(poly): + return True + poly_area = poly.area + if poly_area == 0: + return True + overrun = poly.difference(aoi_geom).area + return overrun <= _AOI_COVER_REL_TOLERANCE * poly_area + + +def _generate_grid_over_aoi( + aoi: ShapelyMultiPolygon, + cell_size_m: float, +) -> list[ShapelyPolygon]: + """Build a regular grid of square cells over the AOI bounding box, + clip each cell to the AOI, and return the resulting polygons. + + Sizes are converted from meters using an equirectangular + approximation: + - 1° latitude ≈ 111 320 m everywhere. + - 1° longitude ≈ 111 320 · cos(centre latitude) m. + + Adequate for small project AOIs; a proper metric reprojection + (pyproj) is required for global-scale precision. + """ + minx, miny, maxx, maxy = aoi.bounds + center_lat = (miny + maxy) / 2.0 + lat_step = cell_size_m / 111_320.0 + lon_step = cell_size_m / (111_320.0 * max(math.cos(math.radians(center_lat)), 0.01)) + + cells: list[ShapelyPolygon] = [] + # Safety cap for accidental large-AOI + small-cell combinations. + cell_cap = 50_000 + + y = miny + while y < maxy: + x = minx + while x < maxx: + cell = ShapelyPolygon( + [ + (x, y), + (x + lon_step, y), + (x + lon_step, y + lat_step), + (x, y + lat_step), + (x, y), + ] + ) + if cell.intersects(aoi): + clipped = cell.intersection(aoi) + if not clipped.is_empty and clipped.area > 0: + # `intersection` can return a Polygon, MultiPolygon, + # or GeometryCollection; retain polygon pieces only. + geoms = ( + list( + clipped.geoms # pyright: ignore[reportAttributeAccessIssue] + ) + 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_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + 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 + ) # pyright: ignore[reportArgumentType] + & (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_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + 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_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + None + ) + ) + ) + ) + return rs.scalar_one_or_none() + + async def _project_role( + self, project_id: int, user: UserInfo, workspace_id: int + ) -> Optional[str]: + """Effective project role: explicit row overrides workspace-level. + + Returns one of `lead`, `validator`, `contributor`, or `None` + (outsider). Workspace LEAD beats an explicit non-lead row so + a workspace lead is never accidentally demoted by a stale role + assignment. + """ + rs = await self.session.execute( + text( + "SELECT role FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(user.user_uuid)}, + ) + explicit = rs.scalar_one_or_none() + + # Workspace LEAD always wins. + if user.isWorkspaceLead(workspace_id): + return "lead" + if explicit: + return str(explicit) + if user.isWorkspaceValidator(workspace_id): + return "validator" + if user.isWorkspaceContributor(workspace_id): + return "contributor" + return None + + async def _audit( + self, + *, + event_type: AuditEventType, + project_id: int, + task_id: Optional[int], + actor_uuid: UUID, + details: Optional[dict[str, Any]] = None, + ) -> None: + await self.session.execute( + text( + "INSERT INTO tasking_audit_events " + "(event_type, project_id, task_id, actor_user_auth_uid, details) " + "VALUES (:et, :pid, :tid, :uid, CAST(:dt AS jsonb))" + ), + { + "et": event_type, + "pid": project_id, + "tid": task_id, + "uid": str(actor_uuid), + "dt": json.dumps(details or {}), + }, + ) + + async def _lookup_user_display(self, user_auth_uid: Optional[str]) -> Optional[str]: + if not user_auth_uid: + return None + rs = await self.session.execute( + text("SELECT display_name FROM users WHERE auth_uid = :uid"), + {"uid": user_auth_uid}, + ) + return rs.scalar_one_or_none() + + async def _to_task_response(self, task: TaskingTask) -> TaskResponse: + geom_shape = to_shape(task.geometry) if task.geometry is not None else None + if geom_shape is None or not isinstance(geom_shape, ShapelyPolygon): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Task geometry missing or non-Polygon", + ) + + lock_row = await self._get_active_lock(task.id) # type: ignore[arg-type] + lock_summary: Optional[TaskLockSummary] = None + if lock_row is not None: + display = await self._lookup_user_display(lock_row.user_auth_uid) + lock_summary = TaskLockSummary( + user_id=UUID(lock_row.user_auth_uid), + user_name=display, + locked_at=lock_row.locked_at, + expires_at=lock_row.expires_at, + ) + + last_mapper: Optional[LastMapper] = None + if task.last_mapper_id: + display = await self._lookup_user_display(task.last_mapper_id) + last_mapper = LastMapper( + user_id=UUID(task.last_mapper_id), user_name=display + ) + + return TaskResponse( + id=task.id, # type: ignore[arg-type] + task_number=task.task_number, + status=task.status, + geometry=_shapely_polygon_to_geojson(geom_shape), + area_sqkm=float(task.area_sqkm), + lock=lock_summary, + last_mapper=last_mapper, + created_at=task.created_at, + updated_at=task.updated_at, + ) + + # ---- grid generation ------------------------------------------------- + + async def generate_grid( + self, + workspace_id: int, + project_id: int, + cell_size_m: int, + ) -> TaskBoundariesFeatureCollection: + """Preview-only grid generation: returns a FeatureCollection of + clipped grid cells over the project AOI without persisting. + + Caller previews the returned shapes and then commits the same + FeatureCollection via `POST /tasks/save` (with + ``source: "grid"``). LEAD-only; project must be in `draft` + with an AOI set. + """ + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Tasks can only be generated while the project is in draft", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required before generating a grid", + ) + + aoi_geom = to_shape(project.aoi) + if isinstance(aoi_geom, ShapelyPolygon): + aoi_geom = ShapelyMultiPolygon([aoi_geom]) + + cells = _generate_grid_over_aoi( + aoi_geom, float(cell_size_m) # pyright: ignore[reportArgumentType] + ) + if not cells: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Grid produced no cells — AOI may be too small for " + "the chosen cellSizeMeters." + ), + ) + + features = [ + TaskBoundaryFeature( + type="Feature", + geometry=_shapely_polygon_to_geojson(cell), + properties={"cellIndex": idx}, + ) + for idx, cell in enumerate(cells) + ] + return TaskBoundariesFeatureCollection( + type="FeatureCollection", + features=features, + ) + + # ---- validate -------------------------------------------------------- + + async def validate( + self, + workspace_id: int, + project_id: int, + fc: TaskBoundariesFeatureCollection, + ) -> ValidatePreviewResponse: + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Tasks can only be validated while the project is in draft", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required before validating tasks", + ) + + aoi_geom = to_shape(project.aoi) + if isinstance(aoi_geom, ShapelyPolygon): + aoi_geom = ShapelyMultiPolygon([aoi_geom]) + + warnings: list[ValidateWarning] = [] + for idx, feat in enumerate(fc.features): + poly = _polygon_to_shapely(feat.geometry.model_dump()) + if not _aoi_covers(aoi_geom, poly): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Task feature {idx} is not fully inside the project AOI", + ) + area_km2 = _polygon_area_km2(poly) + if area_km2 > _GRID_MAX_KM2: + warnings.append( + ValidateWarning( + task_index=idx, + issue="polygon_exceeds_grid_size", + area_sqkm=round(area_km2, 4), + ) + ) + + return ValidatePreviewResponse( + valid=True, + warnings=warnings, + source="import", + feature_collection=fc, + ) + + # ---- save ------------------------------------------------------------ + + async def save( + self, + workspace_id: int, + project_id: int, + current_user: UserInfo, + body: SaveTasksRequest, + idempotency_key: Optional[str], + ) -> tuple[SaveTasksResponse, bool]: + """Bulk-insert tasks. Returns (response, replayed).""" + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Tasks can only be saved while the project is in draft", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required before saving tasks", + ) + + body_bytes = json.dumps(body.model_dump(mode="json"), sort_keys=True).encode() + body_hash = hashlib.sha256(body_bytes).hexdigest() + + # Idempotent replay path. + if idempotency_key: + rs = await self.session.execute( + text( + "SELECT body_hash, response_json " + "FROM tasking_task_save_idempotency " + "WHERE project_id = :pid AND key = :k" + ), + {"pid": project.id, "k": idempotency_key}, + ) + row = rs.first() + if row is not None: + if row.body_hash != body_hash: + raise AlreadyExistsException( + "Idempotency key reused with a different request" + ) + stored = row.response_json + if isinstance(stored, str): + stored = json.loads(stored) + # Stored payload was serialised with `replayed=False` + # at first-write time; set it to True on replay so the + # caller can distinguish a replay from a fresh save. + stored["replayed"] = True + return SaveTasksResponse(**stored), True + + # Refuse if tasks already exist (re-upload AOI to wipe). + existing = await self.session.execute( + text("SELECT 1 FROM tasking_tasks WHERE project_id = :pid LIMIT 1"), + {"pid": project.id}, + ) + if existing.scalar() is not None: + raise AlreadyExistsException("Tasks already saved") + + # Re-validate every feature against AOI (atomic guarantee). + aoi_geom = to_shape(project.aoi) + if isinstance(aoi_geom, ShapelyPolygon): + aoi_geom = ShapelyMultiPolygon([aoi_geom]) + + created: list[TaskingTask] = [] + for idx, feat in enumerate(body.feature_collection.features): + poly = _polygon_to_shapely(feat.geometry.model_dump()) + if not _aoi_covers(aoi_geom, poly): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Task feature {idx} is not fully inside the project AOI", + ) + task = TaskingTask( + project_id=project.id, # type: ignore[arg-type] + task_number=idx + 1, + area_sqkm=round( + _polygon_area_km2(poly), 4 + ), # pyright: ignore[reportArgumentType] + 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 # pyright: ignore[reportArgumentType] + ) + .values( + task_boundary_type=body.source, + updated_at=datetime.now(), + ) + ) + + await self._audit( + event_type=AuditEventType.TASKS_CREATED, + project_id=project.id, # type: ignore[arg-type] + task_id=None, + actor_uuid=current_user.user_uuid, + details={"taskCount": len(created), "source": body.source}, + ) + + task_responses = [await self._to_task_response(t) for t in created] + response = SaveTasksResponse( + project_id=project.id, # type: ignore[arg-type] + task_boundary_type=body.source, + task_count=len(created), + tasks=task_responses, + idempotency_key=idempotency_key, + replayed=False, + ) + + # Persist idempotency record (committed in the same txn). + if idempotency_key: + payload = response.model_dump(mode="json") + await self.session.execute( + text( + "INSERT INTO tasking_task_save_idempotency " + "(project_id, key, body_hash, response_json) " + "VALUES (:pid, :k, :bh, CAST(:rj AS jsonb))" + ), + { + "pid": project.id, + "k": idempotency_key, + "bh": body_hash, + "rj": json.dumps(payload), + }, + ) + + await self.session.commit() + return response, False + + # ---- list / get ------------------------------------------------------ + + async def list_tasks( + self, + workspace_id: int, + project_id: int, + *, + status_filter: Optional[TaskStatus] = None, + locked_by_user_id: Optional[UUID] = None, + last_mapper_id: Optional[UUID] = None, + page: int = 1, + page_size: int = 200, + ) -> TaskListResponse: + await self._get_project(workspace_id, project_id) + + where = TaskingTask.project_id == project_id + if status_filter is not None: + where = where & (TaskingTask.status == status_filter) + if last_mapper_id is not None: + where = where & (TaskingTask.last_mapper_id == str(last_mapper_id)) + + total_q = await self.session.execute( + select(func.count()) + .select_from(TaskingTask) + .where(where) # pyright: ignore[reportArgumentType] + ) + 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) # pyright: ignore[reportArgumentType] + .order_by( + TaskingTask.task_number.asc() # pyright: ignore[reportAttributeAccessIssue] + ) + .limit(page_size) + .offset(offset) + ) + tasks = list(rows.scalars().all()) + + responses: list[TaskResponse] = [] + for t in tasks: + tr = await self._to_task_response(t) + if locked_by_user_id is not None and ( + tr.lock is None or tr.lock.user_id != locked_by_user_id + ): + continue + responses.append(tr) + + return TaskListResponse( + tasks=responses, + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + async def get_task( + self, workspace_id: int, project_id: int, task_number: int + ) -> TaskResponse: + await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + return await self._to_task_response(task) + + async def get_task_geojson( + self, workspace_id: int, project_id: int, task_number: int + ) -> TaskBoundariesFeatureCollection: + await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + geom_shape = to_shape(task.geometry) if task.geometry is not None else None + if geom_shape is None or not isinstance(geom_shape, ShapelyPolygon): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Task geometry missing or non-Polygon", + ) + task_geometry = _shapely_polygon_to_geojson(geom_shape) + task_feature = TaskBoundaryFeature( + type="Feature", + geometry=task_geometry, + properties={"taskNumber": task.task_number}, + ) + task_feature_collection = TaskBoundariesFeatureCollection( + type="FeatureCollection", + features=[task_feature], + ) + return task_feature_collection + + # ---- lock / unlock / extend / reset ---------------------------------- + + async def lock_task( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.OPEN: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project is not open", + ) + + task = await self._get_task(project_id, task_number) + + # Eligibility table. + role = await self._project_role(project_id, current_user, workspace_id) + if role is None: + raise ForbiddenException("User has no access to this project") + + if task.status in (TaskStatus.TO_MAP, TaskStatus.TO_REMAP): + if role not in ("contributor", "validator", "lead"): + raise ForbiddenException( + "Role does not permit locking this task for mapping" + ) + elif task.status == TaskStatus.TO_REVIEW: + if role not in ("validator", "lead"): + raise ForbiddenException( + "Role does not permit locking this task for validation" + ) + if task.last_mapper_id and task.last_mapper_id == str( + current_user.user_uuid + ): + raise ForbiddenException("Cannot validate a task you last mapped") + else: + raise ForbiddenException("Task is in a terminal state") + + # One active lock per task. + if await self._get_active_lock(task.id) is not None: # type: ignore[arg-type] + raise AlreadyExistsException("Task is already locked") + + # One active lock per (project, user). + other = await self._get_active_lock_for_user_in_project( + project_id, str(current_user.user_uuid) + ) + if other is not None: + other_task_rs = await self.session.execute( + select(TaskingTask).where( + TaskingTask.id + == other.task_id # pyright: ignore[reportArgumentType] + ) + ) + other_task = other_task_rs.scalar_one() + summary = ExistingLockSummary( + task_number=other_task.task_number, + task_status=other_task.status, + locked_at=other.locked_at, + expires_at=other.expires_at, + ) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": "User already holds a lock in this project", + "existing_lock": summary.model_dump(mode="json"), + }, + ) + + now = datetime.now() + expires_at = now + timedelta(hours=project.lock_timeout_hours) + lock = TaskingLock( + task_id=task.id, # type: ignore[arg-type] + project_id=project_id, + user_auth_uid=str(current_user.user_uuid), + task_status_at_lock=task.status, + locked_at=now, + expires_at=expires_at, + ) + + try: + self.session.add(lock) + await self.session.flush() + await self._audit( + event_type=AuditEventType.TASK_LOCKED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={"taskNumber": task.task_number}, + ) + await self.session.commit() + except IntegrityError: + await self.session.rollback() + raise AlreadyExistsException( + "Task is already locked or user already holds a lock in this project" + ) + + return await self._to_task_response(task) + + async def unlock_task( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + force: bool = False, + ) -> None: + await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Task has no active lock", + ) + + if force: + if not current_user.isWorkspaceLead(workspace_id): + raise ForbiddenException("Only LEAD may force-release a lock") + release_reason = LockReleaseReason.LEAD_RELEASE + else: + if lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Only the lock holder may release it") + release_reason = LockReleaseReason.MANUAL + + now = datetime.now() + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) # pyright: ignore[reportArgumentType] + .values(released_at=now, release_reason=release_reason) + ) + await self._audit( + event_type=AuditEventType.TASK_UNLOCKED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "releaseReason": release_reason.value, + }, + ) + await self.session.commit() + + async def extend_lock( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Task has no active lock to extend", + ) + if lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Only the lock holder may extend the lock") + + # `expires_at` comes back tz-aware from Postgres (TIMESTAMPTZ); + # the SQLModel column is typed as naive `datetime`. Strip tzinfo + # before re-binding to avoid asyncpg's offset-aware/naive + # mismatch error. + prev = lock.expires_at + if prev.tzinfo is not None: + prev = prev.replace(tzinfo=None) + new_expiry = prev + timedelta(hours=project.lock_timeout_hours) + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) # pyright: ignore[reportArgumentType] + .values(expires_at=new_expiry) + ) + await self._audit( + event_type=AuditEventType.TASK_LOCK_EXTENDED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "expiresAt": new_expiry.isoformat(), + }, + ) + await self.session.commit() + return await self._to_task_response(task) + + async def reset_task( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + if project.status == ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Cannot reset a task while the project is in draft", + ) + + task = await self._get_task(project_id, task_number) + + now = datetime.now() + # Release any active lock. + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is not None: + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) # pyright: ignore[reportArgumentType] + .values( + released_at=now, + release_reason=LockReleaseReason.RESET, + ) + ) + await self._audit( + event_type=AuditEventType.TASK_UNLOCKED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "releaseReason": LockReleaseReason.RESET.value, + }, + ) + + previous_status = task.status + if previous_status != TaskStatus.TO_MAP: + await self.session.execute( + update(TaskingTask) + .where(TaskingTask.id == task.id) # pyright: ignore[reportArgumentType] + .values( + status=TaskStatus.TO_MAP, + last_mapper_id=None, + updated_at=now, + ) + ) + await self._audit( + event_type=AuditEventType.TASK_STATE_CHANGED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "from": previous_status.value, + "to": TaskStatus.TO_MAP.value, + }, + ) + else: + # Clear last_mapper_id even if state was already to_map. + await self.session.execute( + update(TaskingTask) + .where(TaskingTask.id == task.id) # pyright: ignore[reportArgumentType] + .values(last_mapper_id=None, updated_at=now) + ) + + await self._audit( + event_type=AuditEventType.TASK_RESET, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={"taskNumber": task.task_number}, + ) + + await self.session.commit() + refreshed = await self._get_task(project_id, task_number) + return await self._to_task_response(refreshed) + + # ---- submit ---------------------------------------------------------- + + async def submit( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + body: SubmitRequest, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None or lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Caller does not hold the active lock") + + # Effective role for the *current* task status — drives the + # state transition table. + if task.status in (TaskStatus.TO_MAP, TaskStatus.TO_REMAP): + actor_role = "mapper" + elif task.status == TaskStatus.TO_REVIEW: + actor_role = "validator" + else: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Task is in a terminal state", + ) + + # Feedback is only meaningful in validator context. + # if body.feedback is not None and actor_role != "validator": + # raise HTTPException( + # status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + # detail="feedback is only accepted in validator context", + # ) + + now = datetime.now() + + # # Record the changeset row. + # cs = TaskingChangeset( + # task_id=task.id, # type: ignore[arg-type] + # project_id=project_id, + # lock_id=lock.id, # type: ignore[arg-type] + # user_auth_uid=str(current_user.user_uuid), + # osm_changeset_id=body.osm_changeset_id, + # submitted_at=now, + # ) + # self.session.add(cs) + # await self.session.flush() + + # await self._audit( + # event_type=AuditEventType.CHANGESET_SUBMITTED, + # project_id=project_id, + # task_id=task.id, + # actor_uuid=current_user.user_uuid, + # details={ + # "taskNumber": task.task_number, + # "osmChangesetId": body.osm_changeset_id, + # "done": body.done, + # }, + # ) + + if not body.done: + # Slide lock expiry from submitted_at + lock_timeout_hours. + new_expiry = now + timedelta(hours=project.lock_timeout_hours) + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) # pyright: ignore[reportArgumentType] + .values(expires_at=new_expiry) + ) + await self._audit( + event_type=AuditEventType.TASK_LOCK_RENEWED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "expiresAt": new_expiry.isoformat(), + }, + ) + await self.session.commit() + refreshed = await self._get_task(project_id, task_number) + return await self._to_task_response(refreshed) + + # done = True → resolve next status per transition table. + previous_status = task.status + new_last_mapper = task.last_mapper_id + + if actor_role == "mapper": + new_last_mapper = str(current_user.user_uuid) + if previous_status == TaskStatus.TO_MAP: + new_status = ( + TaskStatus.TO_REVIEW + if project.review_required + else TaskStatus.COMPLETED + ) + else: # to_remap + new_status = TaskStatus.TO_REVIEW + else: # validator + if body.feedback is not None: + if body.feedback.reason_category is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="feedback.reasonCategory is required when sending feedback", + ) + # Insert the remap feedback row. + self.session.add( + TaskingFeedback( + task_id=task.id, # type: ignore[arg-type] + project_id=project_id, + author_user_auth_uid=str(current_user.user_uuid), + reason_category=body.feedback.reason_category, + notes=body.feedback.notes, + created_at=now, + ) + ) + await self._audit( + event_type=AuditEventType.FEEDBACK_SUBMITTED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "reasonCategory": body.feedback.reason_category.value, + }, + ) + new_status = TaskStatus.TO_REMAP + else: + new_status = TaskStatus.COMPLETED + + # Release the lock (auto_unlock). + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) # pyright: ignore[reportArgumentType] + .values( + released_at=now, + release_reason=LockReleaseReason.AUTO_UNLOCK, + ) + ) + await self._audit( + event_type=AuditEventType.TASK_UNLOCKED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "releaseReason": LockReleaseReason.AUTO_UNLOCK.value, + }, + ) + + # Apply state transition. + await self.session.execute( + update(TaskingTask) + .where(TaskingTask.id == task.id) # pyright: ignore[reportArgumentType] + .values( + status=new_status, + last_mapper_id=new_last_mapper, + updated_at=now, + ) + ) + if new_status != previous_status: + await self._audit( + event_type=AuditEventType.TASK_STATE_CHANGED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "from": previous_status.value, + "to": new_status.value, + }, + ) + + await self.session.commit() + refreshed = await self._get_task(project_id, task_number) + return await self._to_task_response(refreshed) + + async def submit_changeset( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + changesetId: int, + ) -> int: + project = await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None or lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Caller does not hold the active lock") + now = datetime.now() + + # Record the changeset row. + cs = TaskingChangeset( + task_id=task.id, # type: ignore[arg-type] + project_id=project_id, + lock_id=lock.id, # type: ignore[arg-type] + user_auth_uid=str(current_user.user_uuid), + osm_changeset_id=changesetId, + submitted_at=now, + ) + self.session.add(cs) + await self.session.flush() + # Get the id of the newly inserted changeset row + changeset_row_id = cs.id # type: ignore[assignment] + print(f"Inserted changeset row with ID: {changeset_row_id}") + + # Audit record for changeset submission + await self._audit( + event_type=AuditEventType.CHANGESET_SUBMITTED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "osmChangesetId": changesetId, + }, + ) + await self.session.commit() + # PK is populated after flush(); SQLModel still types it ``int | None``. + return cs.id # type: ignore[return-value] + + +__all__ = ["TaskingTaskRepository"] diff --git a/api/src/tasking/tasks/routes.py b/api/src/tasking/tasks/routes.py new file mode 100644 index 0000000..08fe0b3 --- /dev/null +++ b/api/src/tasking/tasks/routes.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +from typing import Annotated, Optional +from uuid import UUID + +from fastapi import ( + APIRouter, + Body, + Depends, + Header, + HTTPException, + Query, + Response, + status, +) +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.security import UserInfo, validate_token +from api.src.tasking.tasks.dtos import ( + SaveTasksRequest, + SaveTasksResponse, + SubmitRequest, + SubmitTaskChangeset, + SubmitTaskChangesetResponse, + TaskBoundariesFeatureCollection, + TaskListResponse, + TaskResponse, + ValidatePreviewResponse, +) +from api.src.tasking.tasks.repository import TaskingTaskRepository +from api.src.tasking.tasks.schemas import TaskStatus +from api.src.workspaces.repository import WorkspaceRepository + +router = APIRouter( + prefix="/workspaces/{workspace_id}/tasking/projects/{project_id}", + tags=["tasking-tasks"], +) + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def get_task_repo( + session: AsyncSession = Depends(get_osm_session), +) -> TaskingTaskRepository: + return TaskingTaskRepository(session) + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +async def assert_workspace_visible( + workspace_id: int, + current_user: UserInfo, + workspace_repo: WorkspaceRepository, +) -> None: + await workspace_repo.getById(current_user, workspace_id) + + +def assert_workspace_lead(workspace_id: int, current_user: UserInfo) -> None: + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User does not have permission to edit this workspace", + ) + + +# --------------------------------------------------------------------------- +# Tasks — validate / save / list / get +# --------------------------------------------------------------------------- + + +@router.post("/tasks/grid", response_model=TaskBoundariesFeatureCollection) +async def generate_grid( + workspace_id: int, + project_id: int, + cell_size_meters: int = Query(1000, ge=50, le=100_000), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + """Generate a regular grid of square cells over the project AOI. + + LEAD-only preview — does NOT persist. The client posts the same + FeatureCollection back through `POST /tasks/save` to commit. + """ + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await task_repo.generate_grid(workspace_id, project_id, cell_size_meters) + + +@router.post("/tasks/validate", response_model=ValidatePreviewResponse) +async def validate_tasks( + workspace_id: int, + project_id: int, + body: TaskBoundariesFeatureCollection = Body(...), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await task_repo.validate(workspace_id, project_id, body) + + +@router.post("/tasks/save", response_model=SaveTasksResponse) +async def save_tasks( + workspace_id: int, + project_id: int, + body: SaveTasksRequest, + response: Response, + idempotency_key: Annotated[ + Optional[str], Header(alias="Idempotency-Key", min_length=8, max_length=128) + ] = None, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + payload, replayed = await task_repo.save( + workspace_id, project_id, current_user, body, idempotency_key + ) + response.status_code = status.HTTP_200_OK if replayed else status.HTTP_201_CREATED + return payload + + +@router.get("/tasks", response_model=TaskListResponse) +async def list_tasks( + workspace_id: int, + project_id: int, + status_filter: Annotated[Optional[TaskStatus], Query(alias="status")] = None, + locked_by_user_id: Optional[UUID] = Query(default=None), + last_mapper_id: Optional[UUID] = Query(default=None), + page: int = Query(1, ge=1), + page_size: int = Query(200, ge=1, le=1000), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.list_tasks( + workspace_id, + project_id, + status_filter=status_filter, + locked_by_user_id=locked_by_user_id, + last_mapper_id=last_mapper_id, + page=page, + page_size=page_size, + ) + + +@router.get("/tasks/{task_number}", response_model=TaskResponse) +async def get_task( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.get_task(workspace_id, project_id, task_number) + + +@router.get( + "/tasks/{task_number}/boundary.geojson", + response_model=TaskBoundariesFeatureCollection, +) +async def get_task_geojson( + workspace_id: int, + project_id: int, + task_number: int, + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + return await task_repo.get_task_geojson(workspace_id, project_id, task_number) + + +# --------------------------------------------------------------------------- +# Locks — acquire / release / extend / reset +# --------------------------------------------------------------------------- + + +@router.post("/tasks/{task_number}/lock", response_model=TaskResponse) +async def lock_task( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.lock_task( + workspace_id, project_id, task_number, current_user + ) + + +@router.delete( + "/tasks/{task_number}/lock", + status_code=status.HTTP_204_NO_CONTENT, +) +async def unlock_task( + workspace_id: int, + project_id: int, + task_number: int, + force: bool = Query(False), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await task_repo.unlock_task( + workspace_id, + project_id, + task_number, + current_user, + force=force, + ) + + +@router.post("/tasks/{task_number}/extend", response_model=TaskResponse) +async def extend_lock( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.extend_lock( + workspace_id, project_id, task_number, current_user + ) + + +@router.post("/tasks/{task_number}/reset", response_model=TaskResponse) +async def reset_task( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await task_repo.reset_task( + workspace_id, project_id, task_number, current_user + ) + + +# --------------------------------------------------------------------------- +# Submit — Done? flow +# --------------------------------------------------------------------------- + + +@router.post("/tasks/{task_number}/submit", response_model=TaskResponse) +async def submit_task( + workspace_id: int, + project_id: int, + task_number: int, + body: SubmitRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.submit( + workspace_id, project_id, task_number, current_user, body + ) + + +@router.post( + "/tasks/{task_number}/submit-changeset", response_model=SubmitTaskChangesetResponse +) +async def submit_changeset( + workspace_id: int, + project_id: int, + task_number: int, + changeset_model: SubmitTaskChangeset, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + cs_id = await task_repo.submit_changeset( + workspace_id, + project_id, + task_number, + current_user, + changeset_model.osm_changeset_id, + ) + return SubmitTaskChangesetResponse( + osm_changeset_id=changeset_model.osm_changeset_id, + task_number=task_number, + project_id=project_id, + workspace_id=workspace_id, + inserted_id=cs_id, + ) diff --git a/api/src/tasking/tasks/schemas.py b/api/src/tasking/tasks/schemas.py new file mode 100644 index 0000000..f3c114b --- /dev/null +++ b/api/src/tasking/tasks/schemas.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from enum import StrEnum +from typing import Any, Optional + +from geoalchemy2 import Geometry +from sqlalchemy import Column +from sqlalchemy import Enum as SAEnum +from sqlmodel import Field, SQLModel + +# --------------------------------------------------------------------------- +# Enums (mirrors of postgres enums in the migration) +# --------------------------------------------------------------------------- + + +class TaskStatus(StrEnum): + TO_MAP = "to_map" + TO_REVIEW = "to_review" + TO_REMAP = "to_remap" + COMPLETED = "completed" + + +class LockReleaseReason(StrEnum): + AUTO_UNLOCK = "auto_unlock" + MANUAL = "manual" + LEAD_RELEASE = "lead_release" + STALE_TIMEOUT = "stale_timeout" + RESET = "reset" + + +class FeedbackReason(StrEnum): + INCOMPLETE_MAPPING = "incomplete_mapping" + DATA_QUALITY_ISSUE = "data_quality_issue" + WRONG_AREA = "wrong_area" + OTHER = "other" + + +def _task_status_column(*, nullable: bool = False) -> Column: + return Column( + SAEnum( + TaskStatus, + name="tasking_task_status", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=nullable, + ) + + +def _release_reason_column() -> Column: + return Column( + SAEnum( + LockReleaseReason, + name="tasking_lock_release_reason", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=True, + ) + + +def _feedback_reason_column() -> Column: + return Column( + SAEnum( + FeedbackReason, + name="tasking_feedback_reason", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=True, + ) + + +# --------------------------------------------------------------------------- +# Table models +# --------------------------------------------------------------------------- + + +class TaskingTask(SQLModel, table=True): + """Per-project task polygon — saved as part of a bulk batch.""" + + __tablename__ = "tasking_tasks" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + project_id: int = Field(nullable=False, index=True) + task_number: int = Field(nullable=False) + area_sqkm: Decimal = Field(nullable=False) + + status: TaskStatus = Field( + default=TaskStatus.TO_MAP, + sa_column=_task_status_column(), + ) + + last_mapper_id: Optional[str] = Field(default=None, nullable=True) + + geometry: Optional[Any] = Field( + default=None, + sa_column=Column( + Geometry(geometry_type="POLYGON", srid=4326), + nullable=False, + ), + ) + + created_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + updated_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False, "onupdate": datetime.now}, + ) + + +class TaskingLock(SQLModel, table=True): + """Active / historical lock on a task. + + Active rows have `released_at IS NULL`. Two partial unique indexes + enforce: at most one active lock per task, and at most one active + lock per (project, user). + """ + + __tablename__ = "tasking_locks" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + task_id: int = Field(nullable=False) + project_id: int = Field(nullable=False) + user_auth_uid: str = Field(nullable=False) + + task_status_at_lock: TaskStatus = Field( + sa_column=_task_status_column(), + ) + + locked_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + expires_at: datetime = Field(nullable=False) + released_at: Optional[datetime] = None + + release_reason: Optional[LockReleaseReason] = Field( + default=None, + sa_column=_release_reason_column(), + ) + + +class TaskingChangeset(SQLModel, table=True): + """One row per `/submit` call — links a lock session to an OSM changeset.""" + + __tablename__ = "tasking_changesets" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + task_id: int = Field(nullable=False) + project_id: int = Field(nullable=False) + lock_id: int = Field(nullable=False) + user_auth_uid: str = Field(nullable=False) + osm_changeset_id: int = Field(nullable=False) + + submitted_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + + +class TaskingFeedback(SQLModel, table=True): + """Per-task feedback row (remap rejections + free-form notes).""" + + __tablename__ = "tasking_feedback" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + task_id: int = Field(nullable=False) + project_id: int = Field(nullable=False) + author_user_auth_uid: str = Field(nullable=False) + + reason_category: Optional[FeedbackReason] = Field( + default=None, + sa_column=_feedback_reason_column(), + ) + notes: str = Field(nullable=False) + + created_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + + +__all__ = [ + "FeedbackReason", + "LockReleaseReason", + "TaskStatus", + "TaskingChangeset", + "TaskingFeedback", + "TaskingLock", + "TaskingTask", +] diff --git a/api/src/teams/repository.py b/api/src/teams/repository.py index 9604282..c7a9f9e 100644 --- a/api/src/teams/repository.py +++ b/api/src/teams/repository.py @@ -9,7 +9,7 @@ WorkspaceTeamItem, WorkspaceTeamUpdate, ) -from api.src.workspaces.schemas import User +from api.src.users.schemas import User class WorkspaceTeamRepository: @@ -18,21 +18,32 @@ def __init__(self, session: AsyncSession): self.session = session async def get_all(self, workspace_id: int) -> list[WorkspaceTeamItem]: - result = await self.session.exec( + result = await self.session.exec( # pyright: ignore[reportCallIssue] select(WorkspaceTeam) - .options(selectinload(WorkspaceTeam.users)) - .where(WorkspaceTeam.workspace_id == workspace_id) + .options( + selectinload(WorkspaceTeam.users) # pyright: ignore[reportArgumentType] + ) + .where( + WorkspaceTeam.workspace_id + == workspace_id # pyright: ignore[reportArgumentType] + ) ) - return [WorkspaceTeamItem.from_team(x) for x in result.scalars()] + return [WorkspaceTeamItem.from_team(x) for x in result.scalars().all()] async def get(self, id: int, load_members: bool = False) -> WorkspaceTeam: - query = select(WorkspaceTeam).where(WorkspaceTeam.id == id) + query = select(WorkspaceTeam).where( + WorkspaceTeam.id == id # pyright: ignore[reportArgumentType] + ) if load_members: - query = query.options(selectinload(WorkspaceTeam.users)) + query = query.options( + selectinload(WorkspaceTeam.users) # pyright: ignore[reportArgumentType] + ) - result = await self.session.exec(query) + result = await self.session.exec( # pyright: ignore[reportCallIssue] + query # pyright: ignore[reportArgumentType] + ) team = result.scalar_one_or_none() if not team: @@ -50,8 +61,11 @@ async def assert_team_in_workspace(self, id: int, workspace_id: int): team_exists = await self.session.scalar( select( exists() - .where(WorkspaceTeam.id == id) - .where(WorkspaceTeam.workspace_id == workspace_id) + .where(WorkspaceTeam.id == id) # pyright: ignore[reportArgumentType] + .where( + WorkspaceTeam.workspace_id + == workspace_id # pyright: ignore[reportArgumentType] + ) ) ) @@ -59,14 +73,13 @@ async def assert_team_in_workspace(self, id: int, workspace_id: int): raise NotFoundException(f"Team {id} not in workspace {workspace_id}") async def create(self, workspace_id: int, data: WorkspaceTeamCreate) -> int: - team = WorkspaceTeam() - team.workspace_id = workspace_id - team.name = data.name + team = WorkspaceTeam(name=data.name, workspace_id=workspace_id) self.session.add(team) await self.session.commit() await self.session.refresh(team) + assert team.id is not None return team.id async def update(self, id: int, data: WorkspaceTeamUpdate): @@ -77,17 +90,23 @@ async def update(self, id: int, data: WorkspaceTeamUpdate): await self.session.commit() async def delete(self, id: int) -> None: - await self.session.exec(delete(WorkspaceTeam).where(WorkspaceTeam.id == id)) + await self.session.execute(delete(WorkspaceTeam).where(WorkspaceTeam.id == id)) # type: ignore[arg-type] await self.session.commit() async def get_members(self, id: int) -> list[User]: - result = await self.session.exec(select(User).where(User.teams.any(id=id))) + result = await self.session.exec( # pyright: ignore[reportCallIssue] + select(User).where( # pyright: ignore[reportArgumentType] + User.teams.any(id=id) # pyright: ignore[reportAttributeAccessIssue] + ) + ) return result.scalars().all() async def add_member(self, id: int, user_id: int): - user_result = await self.session.exec( - select(User).options(selectinload(User.teams)).where(User.id == user_id) + user_result = await self.session.exec( # pyright: ignore[reportCallIssue] + select(User) + .options(selectinload(User.teams)) # pyright: ignore[reportArgumentType] + .where(User.id == user_id) # pyright: ignore[reportArgumentType] ) user = user_result.scalar_one_or_none() @@ -104,8 +123,10 @@ async def add_member(self, id: int, user_id: int): await self.session.commit() async def remove_member(self, id: int, user_id: int): - user_result = await self.session.exec( - select(User).options(selectinload(User.teams)).where(User.id == user_id) + user_result = await self.session.exec( # pyright: ignore[reportCallIssue] + select(User) + .options(selectinload(User.teams)) # pyright: ignore[reportArgumentType] + .where(User.id == user_id) # pyright: ignore[reportArgumentType] ) user = user_result.scalar_one_or_none() diff --git a/api/src/teams/routes.py b/api/src/teams/routes.py index 5437ab1..678989a 100644 --- a/api/src/teams/routes.py +++ b/api/src/teams/routes.py @@ -9,8 +9,9 @@ WorkspaceTeamItem, WorkspaceTeamUpdate, ) -from api.src.workspaces.repository import OSMRepository, WorkspaceRepository -from api.src.workspaces.schemas import User +from api.src.users.repository import UserRepository +from api.src.users.schemas import User +from api.src.workspaces.repository import WorkspaceRepository router = APIRouter(prefix="/workspaces/{workspace_id}/teams", tags=["teams"]) @@ -22,10 +23,10 @@ def get_workspace_repo( return repo -def get_osm_repo( +def get_user_repo( session: AsyncSession = Depends(get_osm_session), -) -> OSMRepository: - repository = OSMRepository(session) +) -> UserRepository: + repository = UserRepository(session) return repository @@ -36,6 +37,13 @@ def get_team_repo( return repo +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace member of any level +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly calls the repository to fetch the team from the database +# @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamItem + + @router.get("") async def get_all_teams_for_workspace( workspace_id: int, @@ -48,6 +56,13 @@ async def get_all_teams_for_workspace( return await team_repo.get_all(workspace_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly calls the repository to add the team to the database +# @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamCreate + + @router.post("", status_code=status.HTTP_201_CREATED) async def create_team_for_workspace( workspace_id: int, @@ -56,11 +71,25 @@ async def create_team_for_workspace( team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ) -> int: + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads can create teams", + ) + # Repo guards if workspace doesn't exist or user cannot access: await workspace_repo.getById(current_user, workspace_id) return await team_repo.create(workspace_id, team) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace member of any level +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly calls the repository to fetch the team from the database +# @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamItem + + @router.get("/{team_id}") async def get_team_for_workspace( workspace_id: int, @@ -75,6 +104,16 @@ async def get_team_for_workspace( return await team_repo.get_item(team_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint properly calls the repository to remove the team from the workspace +# @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamUpdate +# @test: Test that this method properly calls the repo to update the team + + @router.put("/{team_id}", status_code=status.HTTP_204_NO_CONTENT) async def update_team_for_workspace( workspace_id: int, @@ -84,12 +123,26 @@ async def update_team_for_workspace( team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads can update teams", + ) + # Repo guards if workspace doesn't exist or user cannot access: await workspace_repo.getById(current_user, workspace_id) await team_repo.assert_team_in_workspace(team_id, workspace_id) await team_repo.update(team_id, team) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint properly calls the repository to remove the team from the workspace + + @router.delete("/{team_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_team_from_workspace( workspace_id: int, @@ -98,12 +151,28 @@ async def delete_team_from_workspace( team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads can delete teams", + ) + # Repo guards if workspace doesn't exist or user cannot access: await workspace_repo.getById(current_user, workspace_id) await team_repo.assert_team_in_workspace(team_id, workspace_id) await team_repo.delete(team_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a member team +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the team is not associated with the workspace +# @test: Test that this endpoint properly handles the case where the user is not a member of the workspace passed +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team passed +# @test: Test that this endpoint properly calls the repository to fetch the users of the team + + @router.get("/{team_id}/members") async def get_members_in_workspace_team( workspace_id: int, @@ -118,23 +187,42 @@ async def get_members_in_workspace_team( return await team_repo.get_members(team_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a member of the workspace via the team +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user is not a member of the workspace passed +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team passed +# @test: Test that this endpoint properly calls the repository to add the user to the team + + @router.post("/{team_id}/members") async def join_workspace_team( workspace_id: int, team_id: int, workspace_repo=Depends(get_workspace_repo), - osm_repo=Depends(get_osm_repo), + user_repo=Depends(get_user_repo), team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ) -> User: # Repo guards if workspace doesn't exist or user cannot access: await workspace_repo.getById(current_user, workspace_id) await team_repo.assert_team_in_workspace(team_id, workspace_id) - user = await osm_repo.get_current_user(current_user) + user = await user_repo.get_current_user(current_user) await team_repo.add_member(team_id, user.id) return user +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint doesn't allow changing teams the user doesn't have workspace lead permissions for, or users not already associated with the workspace +# @test: Test that this endpoint properly calls the repository to add the user to the team + + @router.put("/{team_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def add_member_to_workspace_team( workspace_id: int, @@ -144,12 +232,30 @@ async def add_member_to_workspace_team( team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads can add team members", + ) + # Repo guards if workspace doesn't exist or user cannot access: await workspace_repo.getById(current_user, workspace_id) await team_repo.assert_team_in_workspace(team_id, workspace_id) await team_repo.add_member(team_id, user_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that the endpoint properly validates that the team needs to be associated with this workspace and errors if not +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user is not associated with the team +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint doesn't allow changing teams the user doesn't have workspace lead permissions for, or users not already associated with the team +# @test: Test that this endpoint properly calls the repository to remove the user from the team + + @router.delete("/{team_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_member_from_workspace_team( workspace_id: int, @@ -159,6 +265,12 @@ async def delete_member_from_workspace_team( team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads can remove team members", + ) + # Repo guards if workspace doesn't exist or user cannot access: await workspace_repo.getById(current_user, workspace_id) await team_repo.assert_team_in_workspace(team_id, workspace_id) diff --git a/api/src/teams/schemas.py b/api/src/teams/schemas.py index c0def20..3a625ba 100644 --- a/api/src/teams/schemas.py +++ b/api/src/teams/schemas.py @@ -1,15 +1,19 @@ -from typing import Self, TYPE_CHECKING +from typing import TYPE_CHECKING, Self from sqlmodel import Field, Relationship, SQLModel if TYPE_CHECKING: - from api.src.workspaces.schemas import User + from api.src.users.schemas import User +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python are properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceTeamUser(SQLModel, table=True): """Team to User link table""" - __tablename__ = "team_user" + __tablename__ = "team_user" # type: ignore[assignment] team_id: int | None = Field(default=None, primary_key=True, foreign_key="teams.id") user_id: int | None = Field(default=None, primary_key=True, foreign_key="users.id") @@ -21,6 +25,10 @@ class WorkspaceTeamBase(SQLModel): name: str = Field(min_length=1) +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python are properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceTeam(WorkspaceTeamBase, table=True): """Workspace teams""" @@ -43,16 +51,13 @@ class WorkspaceTeamItem(WorkspaceTeamBase): @classmethod def from_team(cls, team: WorkspaceTeam) -> Self: + assert team.id is not None # persisted team always has an id return cls(id=team.id, name=team.name, member_count=len(team.users)) class WorkspaceTeamCreate(WorkspaceTeamBase): """New workspace team DTO""" - pass - class WorkspaceTeamUpdate(WorkspaceTeamBase): """Modify workspace team DTO""" - - pass diff --git a/api/src/users/repository.py b/api/src/users/repository.py new file mode 100644 index 0000000..d8177fd --- /dev/null +++ b/api/src/users/repository.py @@ -0,0 +1,120 @@ +from uuid import UUID + +from sqlalchemy import delete, select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import NotFoundException +from api.core.security import UserInfo +from api.src.users.schemas import ( + User, + WorkspaceUserRole, + WorkspaceUserRoleItem, + WorkspaceUserRoleType, +) + + +class UserRepository: + + def __init__(self, session: AsyncSession): + self.session = session + + async def get_privileged_workspace_members( + self, + workspace_id: int, + ) -> list[WorkspaceUserRoleItem]: + # The table only stores "lead" and "validator" role assignments. Project + # group members implicitly have the base "contributor" role. + # + query = ( + select( # pyright: ignore[reportCallIssue] + User, WorkspaceUserRole.role # pyright: ignore[reportArgumentType] + ) + .join(WorkspaceUserRole, User.auth_uid == WorkspaceUserRole.user_auth_uid) + .where(WorkspaceUserRole.workspace_id == workspace_id) + ) + result = await self.session.execute(query) + + return [ + WorkspaceUserRoleItem( + id=user.id, + auth_uid=user.auth_uid, + display_name=user.display_name, + role=role, + ) + for user, role in result.all() + ] + + async def get_current_user(self, current_user: UserInfo) -> User: + result = await self.session.exec( # pyright: ignore[reportCallIssue] + select(User).where( + User.auth_uid + == str(current_user.user_uuid) # pyright: ignore[reportArgumentType] + ) + ) + + # Current user should exist--throw if it doesn't: + return result.scalar_one() + + async def assign_member_role( + self, + workspace_id: int, + user_id: UUID, + role: WorkspaceUserRoleType, + ) -> None: + # Ensure the user has a local user record (signed in at least once): + user_exists = await self.session.scalar( + select( # pyright: ignore[reportCallIssue] + User.id # pyright: ignore[reportArgumentType] + ).where( # pyright: ignore[reportCallIssue] + User.auth_uid == str(user_id) + ) + ) + if not user_exists: + raise NotFoundException( + f"User {user_id} has not signed in to Workspaces yet" + ) + + await self.session.execute( + pg_insert(WorkspaceUserRole) + .values( + user_auth_uid=str(user_id), + workspace_id=workspace_id, + role=role, + ) + .on_conflict_do_update( + index_elements=["user_auth_uid", "workspace_id"], + set_={"role": role}, + ) + ) + await self.session.commit() + + async def remove_member_role( + self, + workspace_id: int, + user_id: UUID, + ) -> None: + query = delete(WorkspaceUserRole).where( + ( + WorkspaceUserRole.workspace_id == workspace_id + ) # pyright: ignore[reportArgumentType] + & (WorkspaceUserRole.user_auth_uid == str(user_id)) + ) + + result = await self.session.execute(query) + + if result.rowcount != 1: # pyright: ignore[reportAttributeAccessIssue] + raise NotFoundException( + f"No role assigned for workspace {workspace_id}, user {user_id}" + ) + + await self.session.commit() + + async def remove_all_member_roles(self, workspace_id: int) -> None: + await self.session.execute( + delete(WorkspaceUserRole).where( + WorkspaceUserRole.workspace_id + == workspace_id # pyright: ignore[reportArgumentType] + ) + ) + await self.session.commit() diff --git a/api/src/users/routes.py b/api/src/users/routes.py new file mode 100644 index 0000000..50197c9 --- /dev/null +++ b/api/src/users/routes.py @@ -0,0 +1,114 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, 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, evict_user_from_cache, validate_token +from api.src.users.repository import UserRepository +from api.src.users.schemas import SetRoleRequest, WorkspaceUserRoleItem +from api.src.workspaces.repository import WorkspaceRepository + +router = APIRouter(prefix="/workspaces/{workspace_id}/users", tags=["users"]) + + +def get_user_repo( + session: AsyncSession = Depends(get_osm_session), +) -> UserRepository: + repository = UserRepository(session) + return repository + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not associated with this workspace at contributor or above +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user is not associated with the workspace (returns a 403 error) + + +@router.get("", response_model=list[WorkspaceUserRoleItem]) +async def get_privileged_workspace_members( + workspace_id: int, + current_user: UserInfo = Depends(validate_token), + user_repo: UserRepository = Depends(get_user_repo), +): + if not current_user.isWorkspaceContributor(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Project group membership required to view members", + ) + + return await user_repo.get_privileged_workspace_members(workspace_id) + + +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user is not associated with the workspace +# @test: Test that this endpoint properly evicts any cached data for the user after the user is deleted so that their next request reflects the deletion rather than serving stale data for up to an hour +# @test: Test that this endpoint properly allows users to be workspace leads or validators, and to unset the user of either role and become a contributor again +# @test: Test that this endpoint doesn't allow changing workspaces the user doesn't have workspace lead permissions for, or roles for users not already associated with the workspace +# @test: Test that this endpoint doesn't allow setting the workspace role to a POC + + +@router.put("/{user_id}/role", status_code=status.HTTP_204_NO_CONTENT) +async def assign_member_role( + workspace_id: int, + user_id: UUID, + body: SetRoleRequest, + current_user: UserInfo = Depends(validate_token), + user_repo: UserRepository = Depends(get_user_repo), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), +): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Must be a workspace owner to assign roles", + ) + + # Ensure that the workspace exists in the tasks DB before we write to the + # OSM DB. TODO: remove the check when we merge the DBs with the proper FK + # constraints that enforce referential integrity internally. + # + await workspace_repo.getById(current_user, workspace_id) + + await user_repo.assign_member_role(workspace_id, user_id, body.role) + evict_user_from_cache(user_id) + + +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user is not associated with the workspace +# @test: Test that this endpoint properly evicts any cached data for the user after the user is deleted so that their next request reflects the deletion rather than serving stale data for up to an hour + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_member_role( + workspace_id: int, + user_id: UUID, + current_user: UserInfo = Depends(validate_token), + user_repo: UserRepository = Depends(get_user_repo), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), +): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Must be a workspace owner to remove roles", + ) + + # Ensure that the workspace exists in the tasks DB before we write to the + # OSM DB. TODO: remove the check when we merge the DBs with the proper FK + # constraints that enforce referential integrity internally. + # + await workspace_repo.getById(current_user, workspace_id) + + await user_repo.remove_member_role(workspace_id, user_id) + evict_user_from_cache(user_id) diff --git a/api/src/users/schemas.py b/api/src/users/schemas.py new file mode 100644 index 0000000..be2ebbe --- /dev/null +++ b/api/src/users/schemas.py @@ -0,0 +1,91 @@ +from enum import StrEnum +from typing import TYPE_CHECKING + +from pydantic import field_validator +from sqlalchemy import Column, Enum +from sqlmodel import Field, Relationship, SQLModel + +from api.src.teams.schemas import WorkspaceTeamUser + +if TYPE_CHECKING: + from api.src.teams.schemas import WorkspaceTeam + + +class WorkspaceUserRoleType(StrEnum): + LEAD = "lead" + VALIDATOR = "validator" + CONTRIBUTOR = "contributor" + + +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data +class WorkspaceUserRole(SQLModel, table=True): + """Associates users with workspaces and their roles""" + + __tablename__ = "user_workspace_roles" # type: ignore[assignment] + + # this is the TDEI auth user UUID, from the token + user_auth_uid: str = Field(foreign_key="users.auth_uid", primary_key=True) + # workspace_id lives in a different DB (task DB), so no FK constraint here + workspace_id: int = Field(primary_key=True) + + role: WorkspaceUserRoleType = Field( + sa_column=Column( + Enum( + WorkspaceUserRoleType, + name="workspace_role", + create_type=False, + values_callable=lambda e: [m.value for m in e], + ), + nullable=False, + ) + ) + + +class SetRoleRequest(SQLModel): + role: WorkspaceUserRoleType + + @field_validator("role") + @classmethod + def privileged_roles_only(cls, v: WorkspaceUserRoleType) -> WorkspaceUserRoleType: + if v == WorkspaceUserRoleType.CONTRIBUTOR: + raise ValueError("cannot assign implicit role 'contributor' directly") + return v + + +class WorkspaceUserRoleItem(SQLModel): + """ + User with their workspace role. DTO for use in the context of a particular + workspace + """ + + id: int + auth_uid: str + display_name: str + role: WorkspaceUserRoleType + + +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data +class User(SQLModel, table=True): + """Users in the OSM DB""" + + __tablename__ = "users" # type: ignore[assignment] + + # User ID referred to by parts of the code based on the OSM DB schema: + id: int = Field(default=None, primary_key=True) + + # Principal ID from the TDEI OIDC gateway ("subject" in an access token). + # It differs from the TDEI user ID: + auth_uid: str = Field(unique=True, index=True) + + email: str = Field(unique=True, index=True) + display_name: str = Field(nullable=False) + + teams: list["WorkspaceTeam"] = Relationship( + back_populates="users", link_model=WorkspaceTeamUser + ) diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index 080f103..90ac74f 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -1,20 +1,23 @@ -from typing import Any, cast -from uuid import UUID - from sqlalchemy import delete, select, text, update from sqlalchemy.exc import IntegrityError from sqlmodel.ext.asyncio.session import AsyncSession -from api.core.exceptions import AlreadyExistsException, NotFoundException +from api.core.exceptions import ( + AlreadyExistsException, + ForbiddenException, + NotFoundException, +) +from api.core.json_schema import get_http_client from api.core.security import UserInfo from api.src.workspaces.schemas import ( + ImagerySettingsPatch, QuestDefinitionType, - User, + QuestSettingsPatch, Workspace, + WorkspaceCreate, WorkspaceImagery, WorkspaceLongQuest, - WorkspaceUserRole, - WorkspaceUserRoleType, + WorkspacePatch, ) @@ -24,20 +27,21 @@ def __init__(self, session: AsyncSession): self.session = session async def create( - self, current_user: UserInfo, workspace_data: dict[str, Any] + self, current_user: UserInfo, workspace_data: WorkspaceCreate ) -> Workspace: workspace = Workspace( - **workspace_data, + **workspace_data.model_dump(), createdBy=current_user.user_uuid, # type: ignore[reportArgumentType] createdByName=current_user.user_name, ) - try: - if workspace.tdeiProjectGroupId not in current_user.getProjectGroupIds(): - raise ValueError( - "User does not have permissions to create a workspace in that project group." - ) + if str(workspace.tdeiProjectGroupId) not in current_user.getProjectGroupIds(): + raise ForbiddenException( + "User does not have permissions to create a workspace in that " + "project group." + ) + try: self.session.add(workspace) await self.session.commit() await self.session.refresh(workspace) @@ -71,7 +75,7 @@ async def update( self, current_user: UserInfo, workspace_id: int, - workspace_data: dict[str, Any], + workspace_data: WorkspacePatch, ) -> Workspace: query = ( update(Workspace) @@ -79,113 +83,111 @@ async def update( (Workspace.id == workspace_id) & (Workspace.tdeiProjectGroupId.in_(current_user.getProjectGroupIds())) # type: ignore[attr-defined] ) - .values(**workspace_data) + .values(**workspace_data.model_dump(exclude_unset=True)) ) result = await self.session.execute(query) - if result.rowcount != 1: + if result.rowcount != 1: # type: ignore[attr-defined] raise NotFoundException(f"Update failed for workspace id {workspace_id}") await self.session.commit() return await self.getById(current_user, workspace_id) - async def createLongformQuest( + async def save_longform_quest( self, current_user: UserInfo, workspace_id: int, - longform_quest_data: dict[str, Any], - ) -> Workspace | None: - query = select(Workspace).where( - (Workspace.id == workspace_id) - & (Workspace.tdeiProjectGroupId.in_(current_user.getProjectGroupIds())) # type: ignore[attr-defined] - ) - result = await self.session.execute(query) - workspace = result.scalar_one_or_none() - if workspace: - workspace.longFormQuestDef = WorkspaceLongQuest( - **longform_quest_data, - modifiedBy=current_user.user_uuid, # type: ignore[reportArgumentType] - modifiedByName=current_user.user_name, - workspace_id=workspace_id, - ) - await self.session.commit() - await self.session.refresh(workspace) - return workspace - - async def updateLongformQuest( - self, - current_user: UserInfo, - workspace_id: int, - longform_quest_data: dict[str, Any], - ) -> Workspace: - update_data = longform_quest_data - update_data["modifiedBy"] = current_user.user_uuid - update_data["modifiedByName"] = current_user.user_name - - quest_type = longform_quest_data.get("type") - update_data["type"] = QuestDefinitionType[ - quest_type.name if quest_type else "NONE" - ].value - + longform_quest_data: QuestSettingsPatch, + ) -> None: query = ( update(WorkspaceLongQuest) - .values(**update_data) - .where(WorkspaceLongQuest.workspace_id == workspace_id) # type: ignore[reportArgumentType] + .values( + type=QuestDefinitionType[longform_quest_data.type].value, + definition=longform_quest_data.definition, + url=longform_quest_data.url, + modifiedBy=current_user.user_uuid, + modifiedByName=current_user.user_name, + ) + .where( + WorkspaceLongQuest.workspace_id + == workspace_id # pyright: ignore[reportArgumentType] + ) ) result = await self.session.execute(query) - if result.rowcount == 0: - raise NotFoundException(f"Workspace with id {workspace_id} not found") - - await self.session.commit() - return await self.getById(current_user, workspace_id) - - async def createImageryDef( - self, - current_user: UserInfo, - workspace_id: int, - imagery_def_data: dict[str, Any], - ) -> Workspace | None: - query = select(Workspace).where( - (Workspace.id == workspace_id) - & (Workspace.tdeiProjectGroupId.in_(current_user.getProjectGroupIds())) # type: ignore[attr-defined] - ) - result = await self.session.execute(query) - workspace = result.scalar_one_or_none() - if workspace: - workspace.imageryListDef = WorkspaceImagery( - **imagery_def_data, - modifiedBy=current_user.user_uuid, # type: ignore[reportArgumentType] - modifiedByName=current_user.user_name, - workspace_id=workspace_id, + if result.rowcount == 0: # pyright: ignore[reportAttributeAccessIssue] + self.session.add( + WorkspaceLongQuest( # pyright: ignore[reportCallIssue] + workspace_id=workspace_id, + type=QuestDefinitionType[longform_quest_data.type].value, + definition=longform_quest_data.definition, + url=longform_quest_data.url, + modifiedBy=current_user.user_uuid, + modifiedByName=current_user.user_name, + ) ) + await self.session.commit() - await self.session.refresh(workspace) - return workspace - async def updateImageryDef( + @staticmethod + async def resolve_quest_def(quest: WorkspaceLongQuest | None) -> str | None: + """ + Resolve a WorkspaceLongQuest to its raw JSON string content. + + - JSON type: returns the stored definition string + - URL type: fetches and returns the remote content + - NONE or missing: returns None + """ + + if quest is None or quest.type == QuestDefinitionType.NONE: + return None + if quest.type == QuestDefinitionType.JSON: + return quest.definition or None + + if quest.url: + client = get_http_client() + if client is None: + return None + try: + response = await client.get(quest.url, timeout=10) + return response.text + except Exception: + return None + + return None + + async def save_imagery_def( self, current_user: UserInfo, workspace_id: int, - imagery_def_data: dict[str, Any], - ) -> Workspace: - update_data = imagery_def_data - update_data["modifiedBy"] = current_user.user_uuid - update_data["modifiedByName"] = current_user.user_name - + imagery_def_data: ImagerySettingsPatch, + ) -> None: query = ( update(WorkspaceImagery) - .values(**update_data) - .where(WorkspaceImagery.workspace_id == workspace_id) # type: ignore[reportArgumentType] + .values( + definition=imagery_def_data.definition, + modifiedBy=current_user.user_uuid, + modifiedByName=current_user.user_name, + ) + .where( + WorkspaceImagery.workspace_id + == workspace_id # pyright: ignore[reportArgumentType] + ) ) result = await self.session.execute(query) - if result.rowcount != 1: - raise NotFoundException(f"Update failed for workspace id {workspace_id}") + if result.rowcount == 0: # type: ignore[attr-defined] + self.session.add( + WorkspaceImagery( # pyright: ignore[reportCallIssue] + workspace_id=workspace_id, + definition=imagery_def_data.definition, + modifiedBy=current_user.user_uuid, + modifiedByName=current_user.user_name, + ) + ) await self.session.commit() - return await self.getById(current_user, workspace_id) async def delete(self, current_user: UserInfo, workspace_id: int) -> None: query = delete(Workspace).where( @@ -195,93 +197,7 @@ async def delete(self, current_user: UserInfo, workspace_id: int) -> None: result = await self.session.execute(query) - if result.rowcount != 1: + if result.rowcount != 1: # type: ignore[attr-defined] raise NotFoundException(f"Workspace delete failed for id {workspace_id}") await self.session.commit() - - -class OSMRepository: - - def __init__(self, session: AsyncSession): - self.session = session - - async def getWorkspaceBBox( - self, - current_user: UserInfo, - workspace_id: int, - ): - await self.session.execute( - text(f"SET search_path TO 'workspace-{workspace_id}', public") - ) - - sql_query = text( - "select MAX(latitude) AS max_lat, MAX(longitude) AS max_lon, \ - MIN(latitude) AS min_lat, MIN(longitude) AS min_lon from nodes" - ) - - result = await self.session.execute(sql_query) - retVal = result.mappings().first() - - if retVal is None: - raise NotFoundException(f"Workspace with id {workspace_id} not found") - - return retVal - - async def getAllUsers( - self, - ): - query = select(User) - result = await self.session.execute(query) - return list(result.scalars().all()) - - async def get_current_user(self, current_user: UserInfo) -> User: - result = await self.session.exec( - select(User).where(User.auth_uid == str(current_user.user_uuid)) - ) - - # Current user should exist--throw if it doesn't: - return result.scalar_one() - - async def addUserToWorkspaceWithRole( - self, - current_user: UserInfo, - workspace_id: int, - user_id: UUID, - role: WorkspaceUserRoleType, - ) -> None: - - userRole = WorkspaceUserRole( - auth_user_uid=cast(UUID, current_user.user_uuid), - workspace_id=workspace_id, - role=role, - ) - - try: - self.session.add(userRole) - await self.session.commit() - except IntegrityError: - await self.session.rollback() - raise AlreadyExistsException( - "User association with that workspace already exists" - ) - - async def removeUserFromWorkspace( - self, - current_user: UserInfo, - workspace_id: int, - user_id: UUID, - ) -> None: - query = delete(WorkspaceUserRole).where( - (WorkspaceUserRole.workspace_id == workspace_id) # type: ignore[reportArgumentType] - & (WorkspaceUserRole.auth_user_uid == user_id) - ) - - result = await self.session.execute(query) - - if result.rowcount != 1: - raise NotFoundException( - f"User association removal failed for workspace {workspace_id} and user {user_id}" - ) - - await self.session.commit() diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 86d890d..430509c 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -1,19 +1,30 @@ -from typing import Any +import json from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session +from api.core.json_schema import ( + validate_imagery_definition_schema, + validate_quest_definition_schema, +) from api.core.logging import get_logger -from api.core.security import UserInfo, validate_token -from api.src.workspaces.repository import OSMRepository, WorkspaceRepository +from api.core.security import UserInfo, evict_user_from_cache, validate_token +from api.src.osm.repository import OSMRepository +from api.src.osm.routes import get_osm_repo +from api.src.users.repository import UserRepository +from api.src.users.schemas import WorkspaceUserRoleType +from api.src.workspaces.repository import WorkspaceRepository from api.src.workspaces.schemas import ( - QuestDefinitionType, - Workspace, + ImagerySettingsPatch, + QuestDefinitionTypeName, + QuestSettingsPatch, + QuestSettingsResponse, + WorkspaceCreate, WorkspaceImagery, - WorkspaceLongQuest, - WorkspaceUserRoleType, + WorkspacePatch, + WorkspaceResponse, ) # Set up logger for this module @@ -29,185 +40,323 @@ def get_workspace_repository( return repository -def get_osm_repository( +def get_user_repository( session: AsyncSession = Depends(get_osm_session), -) -> OSMRepository: - repository = OSMRepository(session) - return repository +) -> UserRepository: + return UserRepository(session) + + +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this method properly calls the repository method to fetch the workspace and that the repository method properly fetches the workspace from the database +# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same +# @test: Test that this method properly checks permissions to see if the user has access to the workspace and if they don't, it doesn't appear in the list +# @test: Test that this method properly handles the case where the workspace does not exist and doesn't include it +# @test: Test that this method properly handles inputs that match the schema in WorkspaceResponse +# @test: Test that this method's results match the values of the fetch-by-workspace-id method below; all workspaces in this list are retrievable via that method # Returns list of workspaces user has access to as JSON payload on success--returns empty JSON list if none -@router.get("/mine", response_model=list[Workspace]) +@router.get("/mine", response_model=list[WorkspaceResponse]) async def get_my_workspaces( repository: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), -) -> list[Workspace]: +) -> list[WorkspaceResponse]: try: workspaces = await repository.getAll(current_user) - return workspaces + return [WorkspaceResponse.from_workspace(ws, current_user) for ws in workspaces] except Exception as e: logger.error(f"Failed to fetch workspaces: {str(e)}") raise +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this method properly calls the repository method to fetch the workspace and that the repository method properly fetches the workspace from the database +# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same +# @test: Test that this method properly checks permissions to see if the user has access to the workspace and returns a 403 if they do not +# @test: Test that this method properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly handles inputs that match the schema in WorkspaceResponse + + # Returns JSON payload or 204 if not found -@router.get("/{workspace_id}", response_model=Workspace) +@router.get("/{workspace_id}", response_model=WorkspaceResponse) async def get_workspace( workspace_id: int, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), -) -> Workspace: +) -> WorkspaceResponse: try: workspace = await repository_ws.getById(current_user, workspace_id) - - if workspace is None: - raise HTTPException( - status_code=status.HTTP_204_NO_CONTENT, - detail="No Content", - ) - - return workspace + quest_def_str = await WorkspaceRepository.resolve_quest_def( + workspace.longFormQuestDef + ) + try: + quest_def = json.loads(quest_def_str) if quest_def_str else None + except json.JSONDecodeError: + quest_def = None + + return WorkspaceResponse.from_workspace( + workspace, + current_user, + imagery_list_def=( + workspace.imageryListDef.definition + if workspace.imageryListDef + else None + ), + long_form_quest_def=quest_def, + ) except Exception as e: logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same +# @test: Test taht this workspace returns proper values for a workspace that exists and that the bbox matches the expected values +# @test: Test that this method properly handles the case where the workspace does not exist and returns a 404 + + @router.get("/{workspace_id}/bbox", response_model=None) async def get_workspace_bbox( workspace_id: int, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), - repository_osm: OSMRepository = Depends(get_osm_repository), + repository_osm: OSMRepository = Depends(get_osm_repo), current_user: UserInfo = Depends(validate_token), ): try: # this first query is for permissions checking - workspace = await repository_ws.getById(current_user, workspace_id) - if workspace is None: - raise HTTPException( - status_code=status.HTTP_204_NO_CONTENT, - detail="No Content", - ) - - bbox = await repository_osm.getWorkspaceBBox(current_user, workspace_id) - - if bbox is None: - raise HTTPException( - status_code=status.HTTP_204_NO_CONTENT, - detail="No Content", - ) - + await repository_ws.getById(current_user, workspace_id) + bbox = await repository_osm.getWorkspaceBBox(workspace_id) + return bbox except Exception as e: logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly evicts any cached data for the user after the workspace is deleted so that their next request reflects the deletion rather than serving stale data for up to an hour +# @test: Test that this method properly calls the repository method to create the workspace and that the repository method properly creates the workspace in the database +# @test: Test that this method properly sets the creator as the lead of the workspace and that the repository method properly assigns the lead role in the database +# @test: Test that this method properly handles inputs that match the schema in WorkspaceCreate and that the repository method properly creates the workspace in the database with those values +# @test: Test that this method properly handles inputs that do not match the schema in WorkspaceCreate and that the repository method properly raises an error and does not update the workspace in the database with those values +# @test: Test that this method won't allow users to modify an existing workspace in any way, or create a workspace with the same workspace_id as an existing one + + # Returns 201 on success? -@router.post("/", response_model=Workspace, status_code=status.HTTP_201_CREATED) +@router.post("", status_code=status.HTTP_201_CREATED) async def create_workspace( - workspace_data: dict[str, Any], + workspace_data: WorkspaceCreate, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), + repository_users: UserRepository = Depends(get_user_repository), current_user: UserInfo = Depends(validate_token), -) -> Workspace: +) -> dict[str, int]: try: workspace = await repository_ws.create(current_user, workspace_data) - return workspace + assert workspace.id is not None # freshly persisted workspace has an id + + # Assign the creator as lead so that non-POC members can manage their + # own workspace: + # + await repository_users.assign_member_role( + workspace.id, + current_user.user_uuid, + WorkspaceUserRoleType.LEAD, + ) + + # Evict the creator's cache so their next request reflects the new + # workspace and lead role rather than serving stale data for up to + # an hour: + # + evict_user_from_cache(current_user.user_uuid) + + return {"workspaceId": workspace.id} except Exception as e: logger.error(f"Failed to create workspace: {str(e)}") raise -# Returns the updated workspace on success. -@router.patch("/{workspace_id}", response_model=Workspace) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly calls the repository method to update the workspace and that the repository method properly updates the workspace in the database +# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same +# @test: Test that this method properly handles inputs that match the schema in WorkspacePatch and that the repository method properly updates the workspace in the database with those values +# @test: Test that this method properly handles inputs that do not match the schema in WorkspacePatch and that the repository method properly raises an error and does not update the workspace in the database with those values + + +@router.patch("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) async def update_workspace( workspace_id: int, - workspace_data: dict[str, Any], + workspace_data: WorkspacePatch, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), -) -> Workspace: - if current_user.isWorkspaceLead(workspace_id) is False: +) -> None: + if not current_user.isWorkspaceLead(workspace_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User does not have permission to update this workspace", ) - try: - workspace = await repository_ws.update( - current_user, workspace_id, workspace_data + if not workspace_data.model_fields_set: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No fields provided to update.", ) - return workspace + + try: + await repository_ws.update(current_user, workspace_id, workspace_data) except Exception as e: logger.error(f"Failed to update workspace {workspace_id}: {str(e)}") raise +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly evicts any cached data for the user after the workspace is deleted so that their next request reflects the deletion rather than serving stale data for up to an hour +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly calls the repository method to delete the workspace and that the repository method properly deletes the workspace from the database +# @test: Test that this method properly handles the case where the workspace has members and that the repository method properly removes all member roles from the database +# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same + + # Returns 204 on success @router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_workspace( workspace_id: int, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), + repository_users: UserRepository = Depends(get_user_repository), current_user: UserInfo = Depends(validate_token), ) -> None: - if current_user.isWorkspaceLead(workspace_id) is False: + if not current_user.isWorkspaceLead(workspace_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User does not have permission to delete this workspace", ) try: + members = await repository_users.get_privileged_workspace_members(workspace_id) await repository_ws.delete(current_user, workspace_id) + await repository_users.remove_all_member_roles(workspace_id) + for member in members: + evict_user_from_cache(UUID(member.auth_uid)) except Exception as e: logger.error(f"Failed to delete workspace {workspace_id}: {str(e)}") raise -### QUESTS +# QUESTS + +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly calls the repository method to fetch the long quest definition + +# FIXME: Why are there two methods to fetch the long quest? One for legacy purposes? Can we migrate callers? + + +# Return the resolved quest definition content as JSON, or 204 if not set: +@router.get("/{workspace_id}/quests/long") +async def get_long_quest_def( + workspace_id: int, + repository_ws: WorkspaceRepository = Depends(get_workspace_repository), + current_user: UserInfo = Depends(validate_token), +) -> Response: + try: + workspace = await repository_ws.getById(current_user, workspace_id) + definition = await WorkspaceRepository.resolve_quest_def( + workspace.longFormQuestDef + ) + + if definition is None: + return Response(status_code=status.HTTP_204_NO_CONTENT) + + return Response(content=definition, media_type="application/json") + except Exception as e: + logger.error( + f"Failed to fetch quest def for workspace {workspace_id}: {str(e)}" + ) + raise + + +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly calls the repository method to fetch the long quest definition, and if it's not defined, the default value as defined +# in this method # Returns JSON payload or 204 if not set -@router.get("/{workspace_id}/quests/long/settings", response_model=WorkspaceLongQuest) +@router.get( + "/{workspace_id}/quests/long/settings", response_model=QuestSettingsResponse +) async def get_long_quest_settings( workspace_id: int, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), -) -> WorkspaceLongQuest | None: +) -> QuestSettingsResponse: try: workspace = await repository_ws.getById(current_user, workspace_id) + quest = workspace.longFormQuestDef - if workspace.longFormQuestDef is None: - return WorkspaceLongQuest( + if quest is None: + return QuestSettingsResponse( workspace_id=workspace_id, - type=QuestDefinitionType.NONE, + type=QuestDefinitionTypeName.NONE, definition=None, url=None, - modifiedAt=workspace.createdAt, - modifiedBy=workspace.createdBy, - modifiedByName="", + modified_at=workspace.createdAt, + modified_by=workspace.createdBy, + modified_by_name="", ) - else: - return workspace.longFormQuestDef + + return QuestSettingsResponse( + workspace_id=quest.workspace_id, + type=QuestDefinitionTypeName(quest.type.name), + definition=quest.definition, + url=quest.url, + modified_at=quest.modifiedAt, + modified_by=quest.modifiedBy, + modified_by_name=quest.modifiedByName, + ) except Exception as e: logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly validates the long quest definition against the JSON schema and returns a 400 if the definition is invalid +# @test: Test that this endpoint properly saves the long quest definition to the database and returns a 204 on success +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles input values that are properly formed, malformed and edge cases, including empty lists, null values, and large payloads +# @test: Test that this method properly calls the repository method to save the long quest definition and that the repository method properly saves the definition to the database + + # Returns 204 on success @router.patch( "/{workspace_id}/quests/long/settings", status_code=status.HTTP_204_NO_CONTENT ) async def update_long_quest_settings( workspace_id: int, - long_quest_data: dict[str, Any], + long_quest_data: QuestSettingsPatch, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), ) -> None: - if current_user.isWorkspaceLead(workspace_id) is False: + 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", ) + if long_quest_data.type == QuestDefinitionTypeName.JSON: + if long_quest_data.definition is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'definition' is required for JSON quest type.", + ) + await validate_quest_definition_schema(long_quest_data.definition) + try: - await repository_ws.updateLongformQuest( + await repository_ws.save_longform_quest( current_user, workspace_id, long_quest_data ) except Exception as e: @@ -215,7 +364,12 @@ async def update_long_quest_settings( raise -### IMAGERY +# IMAGERY + +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly calls the repository method to fetch the imagery definition # Returns JSON payload or 204 if not set @@ -237,79 +391,42 @@ async def get_imagery_settings( modifiedByName="", ) else: - return WorkspaceImagery(**workspace.imageryListDef.model_dump()) + return workspace.imageryListDef except Exception as e: logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly validates the imagery definition against the JSON schema and returns a 400 if the definition is invalid +# @test: Test that this endpoint properly saves the imagery definition to the database and returns a 204 on success +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles input values that are properly formed, malformed and edge cases, including empty lists, null values, and large payloads +# @test: Test that this method properly calls the repository method to save the imagery definition and that the repository method properly saves the definition to the database + + # Returns 204 on success @router.patch( "/{workspace_id}/imagery/settings", status_code=status.HTTP_204_NO_CONTENT ) async def update_imagery_settings( workspace_id: int, - imagery_data: dict[str, Any], + imagery_data: ImagerySettingsPatch, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), ) -> None: - if current_user.isWorkspaceLead(workspace_id) is False: + 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", ) + if imagery_data.definition: + await validate_imagery_definition_schema(imagery_data.definition) + try: - await repository_ws.updateImageryDef(current_user, workspace_id, imagery_data) + await repository_ws.save_imagery_def(current_user, workspace_id, imagery_data) except Exception as e: logger.error(f"Failed to update workspace {workspace_id}: {str(e)}") raise - - -### USERS - - -@router.get("/{workspace_id}/users") -async def get_users( - workspace_id: int, - current_user: UserInfo = Depends(validate_token), - repository_osm: OSMRepository = Depends(get_osm_repository), -): - return await repository_osm.getAllUsers() - - -@router.post("/{workspace_id}/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def add_user_with_role( - workspace_id: int, - user_id: UUID, - role: WorkspaceUserRoleType, - current_user: UserInfo = Depends(validate_token), - repository_osm: OSMRepository = Depends(get_osm_repository), -): - if current_user.isWorkspaceLead(workspace_id) is False: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="User does not have permission to edit this workspace", - ) - - return await repository_osm.addUserToWorkspaceWithRole( - current_user, workspace_id, user_id, role - ) - - -@router.delete("/{workspace_id}/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def remove_user_with_role( - workspace_id: int, - user_id: UUID, - current_user: UserInfo = Depends(validate_token), - repository_osm: OSMRepository = Depends(get_osm_repository), -): - if current_user.isWorkspaceLead(workspace_id) is False: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="User does not have permission to edit this workspace", - ) - - return await repository_osm.removeUserFromWorkspace( - current_user, workspace_id, user_id - ) diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index 2c4e423..9614338 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -1,17 +1,16 @@ from datetime import datetime from enum import IntEnum, StrEnum -from typing import Any, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Optional, Self from uuid import UUID from geoalchemy2 import Geometry +from pydantic import model_validator from sqlalchemy import JSON as SAJson -from sqlalchemy import Column, SmallInteger, TypeDecorator, Unicode +from sqlalchemy import Boolean, Column, SmallInteger, TypeDecorator, Unicode from sqlmodel import Field, Relationship, SQLModel -from api.src.teams.schemas import WorkspaceTeamUser - if TYPE_CHECKING: - from api.src.teams.schemas import WorkspaceTeam + from api.core.security import UserInfo class IntEnumType(TypeDecorator): @@ -74,12 +73,16 @@ class QuestDefinitionType(IntEnum): URL = 2 -class WorkspaceUserRoleType(StrEnum): - LEAD = "lead" - VALIDATOR = "validator" - CONTRIBUTOR = "contributor" +class QuestDefinitionTypeName(StrEnum): + NONE = "NONE" + JSON = "JSON" + URL = "URL" +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceLongQuest(SQLModel, table=True): """Stores mobile app quest definitions for a workspace""" @@ -104,6 +107,10 @@ class WorkspaceLongQuest(SQLModel, table=True): modifiedByName: str +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceImagery(SQLModel, table=True): """Stores imagery list for a workspace""" @@ -121,38 +128,141 @@ class WorkspaceImagery(SQLModel, table=True): modifiedByName: str -class WorkspaceUserRole(SQLModel, table=True): - """Associates users with workspaces and their roles""" +class WorkspaceCreate(SQLModel): + """Fields the client may supply when creating a workspace""" - __tablename__ = "user_workspace_roles" # type: ignore[assignment] + type: WorkspaceType + title: str + description: Optional[str] = None + tdeiProjectGroupId: UUID + tdeiRecordId: Optional[UUID] = None + tdeiServiceId: Optional[UUID] = None + tdeiMetadata: Optional[Any] = None - # this is the TDEI auth user UUID, from the token - auth_user_uid: str = Field(foreign_key="users.auth_uid", primary_key=True) - workspace_id: int = Field(foreign_key="workspaces.id", primary_key=True) - role: WorkspaceUserRoleType = Field( - sa_column=Column(StrEnumType(WorkspaceUserRoleType), nullable=False) - ) +class WorkspacePatch(SQLModel): + """Fields the client may supply when updating a workspace""" + + title: Optional[str] = None + description: Optional[str] = None + externalAppAccess: Optional[ExternalAppsDefinitionType] = None + autoFlagReview: Optional[bool] = None -class User(SQLModel, table=True): - """Users""" +class QuestSettingsPatch(SQLModel): + """Fields the client may supply when saving long-form quest settings""" + + type: QuestDefinitionTypeName + definition: Optional[str] = None + url: Optional[str] = None - __tablename__ = "users" # type: ignore[assignment] + @model_validator(mode="after") + def validate_quest_settings(self) -> "QuestSettingsPatch": + if self.type == QuestDefinitionTypeName.NONE: + if self.definition: + raise ValueError("'definition' field not allowed when type is NONE.") + if self.url: + raise ValueError("'url' field not allowed when type is NONE.") + elif self.type == QuestDefinitionTypeName.JSON: + if not self.definition: + raise ValueError("'definition' is required when type is JSON.") + if self.url: + raise ValueError("'url' field not allowed when type is JSON.") + # Inexpensive early check. Full JSON parse and schema validation + # must call validate_quest_definition_schema(): + if not self.definition.strip().startswith("{"): + raise ValueError("'definition' must be a JSON object.") + elif self.type == QuestDefinitionTypeName.URL: + if not self.url: + raise ValueError("'url' is required when type is URL.") + if self.definition: + raise ValueError("'definition' field not allowed when type is URL.") + + return self + + +class QuestSettingsResponse(SQLModel): + """Quest settings serialized for API responses""" + + workspace_id: int + type: QuestDefinitionTypeName + definition: Optional[str] = None + url: Optional[str] = None + modified_at: datetime + modified_by: UUID + modified_by_name: str - id: int = Field(default=None, primary_key=True) - # this is the user ID from the TDEI authentication system - auth_uid: str = Field(unique=True, index=True) +class ImagerySettingsPatch(SQLModel): + """Fields the client may supply when saving imagery settings""" - email: str = Field(unique=True, index=True) - display_name: str = Field(nullable=False) + definition: Optional[list[Any]] = None - teams: list["WorkspaceTeam"] = Relationship( - back_populates="users", link_model=WorkspaceTeamUser - ) +class WorkspaceResponse(SQLModel): + """ + Workspace serialized for API responses. Includes the effective role for the + user making the request. + """ + id: int + type: WorkspaceType + title: str + description: Optional[str] = None + tdeiProjectGroupId: UUID + tdeiRecordId: Optional[UUID] = None + tdeiServiceId: Optional[UUID] = None + tdeiMetadata: Optional[Any] = None + createdAt: datetime + createdBy: UUID + createdByName: str + externalAppAccess: ExternalAppsDefinitionType + kartaViewToken: Optional[str] = None + autoFlagReview: bool = False + role: str + # Included in single-workspace GET for mobile app consumption. TODO: remove + # this when the app fetches these from dedicated endpoints: + longFormQuestDef: Optional[Any] = None + imageryListDef: Optional[Any] = None + + # @test: Test that this class properly serializes the workspace data for API responses, including the effective role for the user making the request + # @test: Test that the values are populated in this class match the expected values from the database and that the relationships are correctly serialized + + @classmethod + def from_workspace( + cls, + workspace: "Workspace", + user: "UserInfo", + *, + imagery_list_def: Any = None, + long_form_quest_def: Any = None, + ) -> Self: + assert workspace.id is not None # persisted workspace always has an id + return cls( + id=workspace.id, + type=workspace.type, + title=workspace.title, + description=workspace.description, + tdeiProjectGroupId=workspace.tdeiProjectGroupId, + tdeiRecordId=workspace.tdeiRecordId, + tdeiServiceId=workspace.tdeiServiceId, + tdeiMetadata=workspace.tdeiMetadata, + createdAt=workspace.createdAt, + createdBy=workspace.createdBy, + createdByName=workspace.createdByName, + externalAppAccess=workspace.externalAppAccess, + kartaViewToken=workspace.kartaViewToken, + autoFlagReview=workspace.autoFlagReview, + role=user.effective_role(workspace.id), + imageryListDef=imagery_list_def, + longFormQuestDef=long_form_quest_def, + ) + + +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class Workspace(SQLModel, table=True): """Workspaces""" @@ -191,6 +301,11 @@ class Workspace(SQLModel, table=True): kartaViewToken: Optional[str] = None + autoFlagReview: bool = Field( + default=False, + sa_column=Column(Boolean, nullable=False, server_default="false"), + ) + longFormQuestDef: Optional[WorkspaceLongQuest] = Relationship( sa_relationship_kwargs={ "uselist": False, diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..8bfeae3 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,71 @@ +services: + database: + image: postgis/postgis:16-3.4 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: testing + POSTGRES_DB: workspaces-osm-local + ports: + - 5432:5432 + volumes: + - ./data/db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d workspaces-osm-local"] + interval: 10s + timeout: 5s + retries: 5 + osm-cgimap: + image: opensidewalksdev.azurecr.io/workspaces-osm-cgimap:dev + environment: + CGIMAP_INSTANCES: 10 + CGIMAP_HOST: database + CGIMAP_USERNAME: postgres + CGIMAP_PASSWORD: testing + CGIMAP_DBNAME: workspaces-osm-local + CGIMAP_MAX_CHANGESET_ELEMENTS: 100000000 # max features per import or save + CGIMAP_MAX_PAYLOAD: 1000000000 # max size of dataset uploads + CGIMAP_MAP_NODES: 100000000 # max number of nodes per requeset + CGIMAP_MAP_AREA: 1 # max area per request in square degrees + ports: + - 8000 + osm-rails: + image: opensidewalksdev.azurecr.io/workspaces-osm-rails:dev + environment: + RAILS_ENV: production + SECRET_KEY_BASE: osm_secret_key + WS_OSM_DB_HOST: database + WS_OSM_DB_USER: postgres + WS_OSM_DB_PASS: testing + WS_OSM_DB_NAME: workspaces-osm-local + stdin_open: true + tty: true + ports: + - 3000 + command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"] + + osm-rails-worker: + image: opensidewalksdev.azurecr.io/workspaces-osm-rails:dev + environment: + RAILS_ENV: production + SECRET_KEY_BASE: osm_secret_key + WS_OSM_DB_HOST: database + WS_OSM_DB_USER: postgres + WS_OSM_DB_PASS: testing + WS_OSM_DB_NAME: workspaces-osm-local + stdin_open: true + tty: true + command: ["bundle", "exec", "rake", "jobs:work"] + + backend: + build: . + container_name: workspaces-backend + volumes: + - .:/app + ports: + - 8000:8000 + environment: + TDEI_BACKEND_URL: ${TDEI_BACKEND_URL} + TDEI_OIDC_REALM: ${TDEI_OIDC_REALM} + TDEI_OIDC_URL: ${TDEI_OIDC_URL} + OSM_DATABASE_URL: postgresql+asyncpg://postgres:testing@database:5432/workspaces-osm-local + TASK_DATABASE_URL: postgresql+asyncpg://postgres:testing@database:5432/workspaces-tasks-local \ No newline at end of file diff --git a/docs/db-schema/db-schema.png b/docs/db-schema/db-schema.png new file mode 100644 index 0000000..fbd1187 Binary files /dev/null and b/docs/db-schema/db-schema.png differ diff --git a/docs/requirements/project-roles-integration.md b/docs/requirements/project-roles-integration.md new file mode 100644 index 0000000..87b25a9 --- /dev/null +++ b/docs/requirements/project-roles-integration.md @@ -0,0 +1,19 @@ +# User Role Management — Workspaces + Tasking Manager + +## Current Behaviour (Workspaces) + +- Users authenticate via TDEI and inherit **implicit contributor** access across all workspaces in their project groups — no manual provisioning needed. +- The user who creates a workspace is automatically assigned the **`lead`** role for that workspace and recorded in the system. +- Today, all collaboration within a workspace operates under this single workspace-level role model. + +## New Requirement (Tasking Manager) + +Tasking Manager introduces **project-level roles** (`lead`, `validator`, `contributor`) so that work inside a workspace can be delegated and reviewed by specific people, not just by anyone with workspace access. + +To deliver this, two new capabilities are needed: + +### 1. User Search +A workspace lead creating or managing a project must be able to search for the right users from within their project group — by name or username — and pick them. This requires **integration with the TDEI user-search API** to surface candidate users in the UI. + +### 2. Role Assignment +Once selected, users are assigned a project-level role (`lead`, `validator`, or `contributor`) and recorded against the project. The lead can later **add**, **change**, or **remove** these assignments as the project evolves, with safeguards to ensure every project always has at least one `lead`. \ No newline at end of file diff --git a/docs/tasking-mvp/tasking-mvp.openapi.json b/docs/tasking-mvp/tasking-mvp.openapi.json new file mode 100644 index 0000000..6e066dc --- /dev/null +++ b/docs/tasking-mvp/tasking-mvp.openapi.json @@ -0,0 +1,3358 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Workspaces Tasking Manager API", + "version": "1.0.0", + "description": "Tasking Manager API for the Workspaces Tasking Manager MVP.\n" + }, + "servers": [ + { + "url": "/api/v1", + "description": "workspaces-backend FastAPI app \u2014 all routers are mounted under `/api/v1` (see `api/main.py`)." + } + ], + "tags": [ + { + "name": "projects", + "description": "Tasking project CRUD and lifecycle." + }, + { + "name": "aoi", + "description": "Project area-of-interest upload, retrieval, deletion." + }, + { + "name": "tasks", + "description": "Task validation, persistence and read access." + }, + { + "name": "locks", + "description": "Lock lifecycle on a task." + }, + { + "name": "submit", + "description": "Done? submit flow \u2014 changeset + state transition." + }, + { + "name": "roles", + "description": "Workspace and project role management." + }, + { + "name": "audit", + "description": "Audit trail." + }, + { + "name": "stats", + "description": "Project and user statistics." + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/workspaces/{workspace_id}/tasking/projects": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + } + ], + "get": { + "tags": [ + "projects" + ], + "summary": "List tasking projects in a workspace.", + "description": "Paginated list of non-deleted projects. Any workspace contributor.", + "operationId": "listProjects", + "parameters": [ + { + "in": "query", + "name": "status", + "schema": { + "$ref": "#/components/schemas/ProjectStatus" + } + }, + { + "in": "query", + "name": "textSearch", + "schema": { + "type": "string", + "maxLength": 255 + }, + "description": "Case-insensitive substring match on `name`." + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 20 + } + }, + { + "in": "query", + "name": "orderBy", + "schema": { + "type": "string", + "enum": [ + "createdAt", + "updatedAt", + "name" + ], + "default": "createdAt" + } + }, + { + "in": "query", + "name": "orderByType", + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + } + } + ], + "responses": { + "200": { + "description": "Paginated list.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/WorkspaceNotFound" + } + } + }, + "post": { + "tags": [ + "projects" + ], + "summary": "Create a tasking project (optionally with AOI and role assignments).", + "description": "LEAD only. Creates a project in `draft` status.\n\nOptional inline AOI may be provided (same validation as\n`POST .../aoi`).\n\nOptional `roleAssignments` may be provided to seed the\nproject-level role overrides table (`tasking_project_roles`) in\nthe same transaction. The creator is auto-added with\n`role = lead`; other users may be added with any of `lead`,\n`validator`, `contributor`.\n\nActivation later requires at least one user assigned with\n`contributor` or `validator` so the project has at least one\nworker before it goes live \u2014 see `POST .../activate`.\n", + "operationId": "createProject", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/WorkspaceNotFound" + }, + "409": { + "description": "A non-deleted project with this name already exists.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "projects" + ], + "summary": "Get a tasking project.", + "operationId": "getProject", + "responses": { + "200": { + "description": "Project detail.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + }, + "patch": { + "tags": [ + "projects" + ], + "summary": "Update editable settings.", + "description": "LEAD only. Per-status mutability:\n\n| Field | draft | open | done |\n|--------------------|-------|------|------|\n| name | yes | yes | no |\n| instructions | yes | yes | no |\n| lockTimeoutHours | yes | yes | no |\n| reviewRequired | yes | NO | no |\n\nAOI is updated through the dedicated AOI endpoints, not PATCH.\nImmutable-field writes return 422.\n", + "operationId": "updateProject", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + }, + "delete": { + "tags": [ + "projects" + ], + "summary": "Soft-delete a tasking project.", + "description": "LEAD only. Sets `deletedAt`, hard-deletes child tasks, retains\naudit rows (flagged with `project_deleted=true`). Returns 409\nif any task currently has an active lock \u2014 force-release first.\n", + "operationId": "deleteProject", + "responses": { + "204": { + "description": "Soft-deleted." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "Project has active task locks.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/activate": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "projects" + ], + "summary": "Activate a draft project (`draft` \u2192 `open`).", + "description": "LEAD only. Pre-conditions (all must hold; 422 with a clear\n`detail` otherwise):\n - Project has a `name`.\n - Project has an AOI set.\n - Project has at least one saved task.\n - At least one user is allocated to the project with role\n `contributor` or `validator` in `tasking_project_roles`\n (the creator's auto-`lead` row does not satisfy this \u2014 a\n worker must be assigned).\n", + "operationId": "activateProject", + "responses": { + "200": { + "description": "Activated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/close": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "projects" + ], + "summary": "Close an open project (`open` \u2192 `done`).", + "description": "LEAD only. Requires every task to be `completed` and no active\nlocks.\n", + "operationId": "closeProject", + "responses": { + "200": { + "description": "Closed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "One or more tasks are still locked or not completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/reset": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "projects" + ], + "summary": "Reset all tasks in a project back to `to_map`.", + "description": "LEAD only. Restarts work on the whole project so contributors\ncan re-map and re-validate.\n\nEffect (single transaction):\n - Releases every active lock with `release_reason = 'reset'`,\n emitting one `task_unlocked` audit event per released lock.\n - Sets every task's `status` to `to_map`, clears\n `last_mapper_id`, emits a `task_state_changed` audit event\n for every task whose status actually changed.\n - If the project was `done`, transitions it back to `open`.\n - Emits one `project_reset` audit event for the project.\n\nTasks themselves (geometry, history of changesets and remap\nfeedback) are NOT deleted \u2014 only their lifecycle state is\nrewound. Stats and audit history remain intact.\n", + "operationId": "resetProject", + "responses": { + "200": { + "description": "Reset complete.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "description": "Project is in `draft` (nothing to reset).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/aoi": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "aoi" + ], + "summary": "Get the AOI of a project.", + "description": "Returns the AOI as a GeoJSON Feature (Polygon, EPSG:4326).\n`404` if no AOI is set.\n", + "operationId": "getProjectAoi", + "responses": { + "200": { + "description": "AOI.", + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + }, + "post": { + "tags": [ + "aoi" + ], + "summary": "Upload or replace the AOI.", + "description": "LEAD only. Accepts a GeoJSON `Feature`, `FeatureCollection`\n(single feature), or bare geometry. Geometry may be either\n`Polygon` or `MultiPolygon` (EPSG:4326). Single-Polygon inputs\nare upcast to MultiPolygon on insert; the storage column is\n`GEOMETRY(MultiPolygon, 4326)`.\n\nProject must be in `draft`. Replacing an AOI hard-deletes any\nsaved tasks.\n", + "operationId": "uploadProjectAoi", + "requestBody": { + "required": true, + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/AoiUploadRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AoiUploadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "AOI stored.", + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + }, + "delete": { + "tags": [ + "aoi" + ], + "summary": "Delete the AOI.", + "description": "LEAD only. Project must be in `draft`. Clears the AOI, hard-\ndeletes any saved tasks (releasing their locks in the same\ntransaction), clears `taskBoundaryType`. Returns 404 if no AOI\nis set.\n", + "operationId": "deleteProjectAoi", + "responses": { + "204": { + "description": "AOI deleted." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "description": "Project not found, or project has no AOI.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Project is not in `draft`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/validate": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "tasks" + ], + "summary": "Validate a GeoJSON FeatureCollection of candidate task boundaries.", + "description": "LEAD only. Does NOT persist anything \u2014 the client must POST each\nvalidated Feature to `/tasks/save` to commit.\n\nProject preconditions: status `draft`, AOI uploaded.\n\nHard validation (422 on failure):\n - Top-level type must be `FeatureCollection`.\n - Every feature geometry must be `Polygon` (no MultiPolygon).\n - Coordinates valid lon/lat (EPSG:4326).\n - Every polygon lies within the project AOI (centroid-inside\n is not sufficient).\n - At least one feature.\n\nNon-blocking warning:\n - `polygon_exceeds_grid_size` \u2014 area exceeds\n `TM_TASKING_GRID_SIZE_METERS`\u00b2 (km\u00b2).\n", + "operationId": "validateTasks", + "requestBody": { + "required": true, + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + } + } + }, + "responses": { + "200": { + "description": "Validation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidatePreviewResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/save": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/IdempotencyKeyHeader" + } + ], + "post": { + "tags": [ + "tasks" + ], + "summary": "Persist a validated FeatureCollection as the project's tasks (bulk).", + "description": "LEAD only. Commits the **entire** FeatureCollection as\n`tasking_tasks` rows in a single transaction.\n\nEffect (single transaction; all-or-nothing):\n 1. Re-validates every feature against the project AOI (same\n rules as `POST /tasks/validate`). Any hard failure aborts\n the transaction \u2014 no rows are written.\n 2. Allocates sequential `taskNumber`s starting at 1, in the\n order features appear in the request.\n 3. Bulk-inserts the `tasking_tasks` rows.\n 4. Sets `tasking_projects.task_boundary_type` to `source`.\n 5. Emits one `task_created` audit event per task.\n\nProject preconditions:\n - Project must be in `draft`.\n - Project must have an AOI set.\n - Project must currently have **no tasks** (a second save is\n rejected with 409 `\"Tasks already saved\"`). To replace\n tasks, re-upload the AOI in `draft` \u2014 that wipes existing\n tasks and re-enables save.\n\nIdempotency: `Idempotency-Key` is honoured at the batch level.\nSame key + same body within the configured TTL replays the\noriginal response (HTTP 200, `replayed: true`). Same key with\ndifferent body returns 409\n`\"Idempotency key reused with a different request\"`. See the\ntop-level idempotency contract.\n", + "operationId": "saveTasks", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveTasksRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Tasks created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveTasksResponse" + } + } + } + }, + "200": { + "description": "Idempotent replay (same key + same body); `replayed: true`.\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveTasksResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "One of:\n - \"Tasks already saved\" \u2014 project already has tasks; re-upload AOI to start over.\n - \"Idempotency key reused with a different request\".\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "tasks" + ], + "summary": "List tasks (geometry always included; serves list + map views).", + "description": "Paginated. Any workspace contributor.\n", + "operationId": "listTasks", + "parameters": [ + { + "in": "query", + "name": "status", + "schema": { + "$ref": "#/components/schemas/TaskStatus" + } + }, + { + "in": "query", + "name": "lockedByUserId", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "lastMapperId", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200 + } + } + ], + "responses": { + "200": { + "description": "Tasks.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "get": { + "tags": [ + "tasks" + ], + "summary": "Get task detail (geometry, status, lock, last mapper).", + "operationId": "getTask", + "responses": { + "200": { + "description": "Task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Workspace, project, or task not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/lock": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "locks" + ], + "summary": "Acquire a lock on a task.", + "description": "Eligibility:\n\n| Task status | Allowed roles |\n|-------------|---------------------------------------------------|\n| `to_map` | CONTRIBUTOR, LEAD |\n| `to_remap` | CONTRIBUTOR, LEAD |\n| `to_review` | VALIDATOR, LEAD \u2014 and NOT this task's last_mapper |\n| `completed` | (none) |\n\nOther rules:\n - No other active lock on this task.\n - Caller holds no other active lock in this project (one-lock-\n per-project rule). 409 response includes `existingLock`.\n - Project status is `open`.\n\nSide effects:\n - INSERT `tasking_locks` with\n `expires_at = NOW() + project.lock_timeout_hours`.\n - Emit `task_locked` audit event.\n", + "operationId": "lockTask", + "responses": { + "200": { + "description": "Lock acquired. Body is the updated task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Role does not permit locking in this status, or self-validation guard hit.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "409": { + "description": "One of:\n - \"Task is already locked\".\n - \"User already holds a lock in this project\" \u2014 body includes `existingLock`.\n - \"Project is not open\".\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LockConflictError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "locks" + ], + "summary": "Release a lock on a task.", + "description": "Default: caller must be the active lock holder\n(`release_reason = manual`).\n\nWith `?force=true`: caller must be LEAD\n(`release_reason = lead_release`).\n\nTask `status` is unchanged in both modes. Force-release does\nnot relock \u2014 LEAD must POST `/lock` separately to take over.\n", + "operationId": "unlockTask", + "parameters": [ + { + "in": "query", + "name": "force", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "204": { + "description": "Released." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Not the lock holder (default mode) or not LEAD (force mode).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "409": { + "description": "Task has no active lock.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/extend": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "locks" + ], + "summary": "Extend the expiry of your own lock.", + "description": "Caller must hold the active lock. Adds the project's\n`lock_timeout_hours` to the current `expires_at` (slides from\nthe existing expiry, not from `NOW()`). Emits\n`task_lock_extended`.\n", + "operationId": "extendTaskLock", + "responses": { + "200": { + "description": "Lock extended.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller does not hold the active lock.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "409": { + "description": "Task has no active lock to extend.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/reset": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "locks" + ], + "summary": "Reset a single task back to `to_map`.", + "description": "LEAD only. Restarts work on a task \u2014 useful when a contributor\nor validator pushed a task into a wrong terminal state and\nsomeone needs to redo it.\n\nEffect (single transaction):\n - If an active lock exists, it is released with\n `release_reason = 'reset'` and a `task_unlocked` audit\n event is emitted.\n - Task `status` is set to `to_map`, `last_mapper_id` is\n cleared, and a `task_state_changed` audit event is emitted\n (only when the status actually changed).\n - A `task_reset` audit event is emitted.\n\nExisting changeset rows and remap-feedback rows are NOT\ndeleted \u2014 only the live task state is rewound.\n\nPre-conditions: project status is `open` or `done`.\n", + "operationId": "resetTask", + "responses": { + "200": { + "description": "Task reset.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "422": { + "description": "Project is in `draft` (no tasks to reset yet).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/submit": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "submit" + ], + "summary": "Submit an OSM changeset and optionally transition state.", + "description": "The \"Done?\" flow. Caller must hold the active lock.\n\nEffective actor role is inferred from current task status:\n - `to_map` / `to_remap` \u2192 mapper context\n - `to_review` \u2192 validator context\n (LEAD may act in either context.)\n\nState transition (when `done:true`):\n\n| Current | Effective role | Feedback? | New status |\n|-------------|--------------------|-----------|---------------------------------------------------------|\n| `to_map` | contributor / lead | n/a | `to_review` if `review_required` else `completed` |\n| `to_remap` | contributor / lead | n/a | `to_review` |\n| `to_review` | validator / lead | no | `completed` |\n| `to_review` | validator / lead | yes | `to_remap` (and INSERT `tasking_feedback` with `reasonCategory` set) |\n\nWith `done:false` the state is unchanged but the lock's\n`expires_at` slides forward to `submitted_at + lock_timeout_hours`\n(emits `task_lock_renewed`).\n\n`osmChangesetId` is supplied by the client. In practice it is\nauto-populated by the editor (Rapid in the iframe) on save \u2014\nthe UI does not ask the user to type it. The id may optionally\nbe verified by looking it up in the internal `osm-web` service\n(same network); a verification miss returns `422` with detail\n\"Invalid OSM changeset id\" and nothing is written.\n\n`last_mapper_id` is updated only when the effective role is\nmapper.\n\nFeedback rules:\n - `feedback` is meaningful only in validator context on\n `to_review`. Otherwise its presence returns 422.\n - For `to_review` \u2192 `to_remap`, `feedback.reasonCategory` is\n required (422 if missing).\n", + "operationId": "submitTask", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Submission accepted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller does not hold the active lock.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/users": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "List privileged workspace members (lead + validator role rows).", + "description": "Returns the rows in `user_workspace_roles` for this workspace,\njoined with their `users` record. Visible to any workspace\ncontributor (`isWorkspaceContributor`).\n\nDoes NOT include implicit contributors \u2014 only users with an\nexplicit LEAD or VALIDATOR row. The UI uses this for the team\nmanagement table; broader user listings come from the TDEI\nproxy (see `/assignable-users`).\n", + "operationId": "listWorkspaceMembers", + "responses": { + "200": { + "description": "Privileged members.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkspaceUserRoleItem" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller is not a member of the workspace's project group.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/users/{user_id}/role": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "put": { + "tags": [ + "roles" + ], + "summary": "Assign (upsert) a privileged role for a user on a workspace.", + "description": "LEAD only. Idempotent upsert into `user_workspace_roles`.\n\nThe body's `role` must be `lead` or `validator` \u2014 assigning\n`contributor` is rejected with 422 (\"cannot assign implicit\nrole 'contributor' directly\"). Contributor is the default for\nany project-group member; to remove an explicit role and fall\nback to contributor, call `DELETE /users/{user_id}` below.\n\nThe named user must have signed in to Workspaces at least once\n(i.e. exist in `users.auth_uid`). If they have not, returns\n404 with detail \"User {uuid} has not signed in to Workspaces\nyet\".\n\nSide effect: the target user's `UserInfo` cache entry is\nevicted (`evict_user_from_cache`) so their next request\nreflects the new role immediately.\n", + "operationId": "assignWorkspaceMemberRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetRoleRequest" + } + } + } + }, + "responses": { + "204": { + "description": "Role assigned (or updated)." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller is not LEAD on this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Workspace not found, or the named user has not signed in to Workspaces yet.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "\"cannot assign implicit role 'contributor' directly\".", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/users/{user_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "delete": { + "tags": [ + "roles" + ], + "summary": "Remove a user's privileged role on a workspace.", + "description": "LEAD only. Deletes the row from `user_workspace_roles`; the\nuser's role falls back to implicit `contributor` (or to\nwhatever TDEI grants \u2014 `poc` still maps to LEAD).\n\nSide effect: the target user's `UserInfo` cache entry is\nevicted.\n", + "operationId": "removeWorkspaceMemberRole", + "responses": { + "204": { + "description": "Role row removed." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller is not LEAD on this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No role row exists for this user/workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/roles": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "List role assignments on a project.", + "description": "Returns every row in `tasking_project_roles` for the project,\njoined with `users.display_name` for human-readable labels.\nAny workspace contributor; the workspace tenancy gate still\napplies (404 for outsiders).\n", + "operationId": "listProjectRoles", + "responses": { + "200": { + "description": "All role assignments.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + }, + "post": { + "tags": [ + "roles" + ], + "summary": "Add a role assignment to a project.", + "description": "Workspace LEAD **or** project LEAD only. Inserts a row in\n`tasking_project_roles`. Duplicate `(project_id, user_id)` pairs\nreturn 409 \u2014 use PATCH to change a role. The `user_id` must\nalready exist in `users` (i.e. the user has signed in to\nWorkspaces at least once); otherwise 422 with a\n`missing_user_ids` list.\n", + "operationId": "addProjectRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleAddRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Role added.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "User already has a role on this project \u2014 use PATCH.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "`user_id` does not exist in the `users` table.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "missing_user_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/roles/{user_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "Get a single user's role on a project.", + "description": "Returns the single `tasking_project_roles` row for this user on\nthis project. 404 if no assignment exists. Any workspace\ncontributor; the tenancy gate still applies.\n", + "operationId": "getProjectRole", + "responses": { + "200": { + "description": "Role assignment.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "User has no role on this project, or project not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "tags": [ + "roles" + ], + "summary": "Upsert a user's role on a project.", + "description": "Workspace LEAD **or** project LEAD only. Idempotent insert-or-\nupdate on `tasking_project_roles`. Returns **201** when the row\nis created, **200** when an existing row is updated.\n\n**Last-LEAD guard**: a PUT that demotes the only remaining LEAD\nreturns 422 \u2014 assign another LEAD first.\n\nFor partial-update semantics, prefer PATCH.\n", + "operationId": "putProjectRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Existing assignment updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "201": { + "description": "New assignment created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "description": "One of:\n - `user_id` does not exist in `users` (insert path).\n - Would remove the last LEAD (demote path).\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "tags": [ + "roles" + ], + "summary": "Change a user's role on a project.", + "description": "Workspace LEAD **or** project LEAD only. Updates the row in\n`tasking_project_roles`. Returns 404 if the user has no role\nassignment yet (use POST to add one).\n\n**Last-LEAD guard**: demoting the only remaining LEAD on the\nproject returns 422 \u2014 assign another LEAD first.\n", + "operationId": "updateProjectRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Role updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "description": "User has no role on this project, or project not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Would remove the last LEAD on the project.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "roles" + ], + "summary": "Remove a role assignment from a project.", + "description": "Workspace LEAD **or** project LEAD only. Deletes the row from\n`tasking_project_roles`.\n\n**Last-LEAD guard**: removing the only remaining LEAD on the\nproject returns 422 \u2014 assign another LEAD first.\n", + "operationId": "removeProjectRole", + "responses": { + "204": { + "description": "Role removed." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "description": "User has no role on this project, or project not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Would remove the last LEAD on the project.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/me/workspaces/{workspace_id}/tasking/projects/roles": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "List the caller's role for every project in a workspace.", + "description": "Single round-trip for the project-list page: returns one entry\nper project with the caller's role on that project.\n", + "operationId": "listSelfProjectRoles", + "responses": { + "200": { + "description": "Project roles for the caller.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelfProjectRolesResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/WorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/audit": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "audit" + ], + "summary": "List project-level audit events.", + "description": "Paginated, newest first. Any workspace contributor. Soft-deleted\nprojects are visible only with `include_deleted=true`.\n", + "operationId": "listProjectAudit", + "parameters": [ + { + "in": "query", + "name": "event_type", + "schema": { + "$ref": "#/components/schemas/AuditEventType" + } + }, + { + "in": "query", + "name": "task_number", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "in": "query", + "name": "actor_user_id", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "occurred_from", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "occurred_to", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "include_deleted", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "in": "query", + "name": "order_by_type", + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + } + } + ], + "responses": { + "200": { + "description": "Events.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditEventListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/audit": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "get": { + "tags": [ + "audit" + ], + "summary": "List task-level audit events.", + "description": "Newest first. Any workspace contributor.", + "operationId": "listTaskAudit", + "parameters": [ + { + "in": "query", + "name": "event_type", + "schema": { + "$ref": "#/components/schemas/AuditEventType" + } + }, + { + "in": "query", + "name": "actor_user_id", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "occurred_from", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "occurred_to", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 25 + } + }, + { + "in": "query", + "name": "order_by_type", + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + } + } + ], + "responses": { + "200": { + "description": "Events for the task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditEventListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/stats": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "stats" + ], + "summary": "Project statistics summary.", + "description": "Live-computed. Any workspace contributor.\n\nField semantics:\n - `tasksByStatus` always zero-fills all four states.\n - `percentMapped` \u2014 share of tasks past `to_map` at least\n once. Rounded to nearest integer.\n - `percentCompleted` \u2014 share whose current status is\n `completed`.\n - `avgTaskDurationMinutes` \u2014 mean across completed tasks of\n the sum of `(released_at - locked_at)` over that task's\n locks. `null` when no task is completed yet.\n - `uniqueMappers` \u2014 distinct submitters with at least one\n changeset while task was in `to_map` or `to_remap`.\n - `uniqueValidators` \u2014 distinct submitters with at least one\n changeset while task was in `to_review`.\n", + "operationId": "getProjectStats", + "responses": { + "200": { + "description": "Stats.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStats" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/users/{user_id}/stats": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "get": { + "tags": [ + "stats" + ], + "summary": "Cross-project stats for a user within a workspace.", + "description": "`totals` sums across the workspace's non-deleted projects.\n`byProject` lists only projects with non-zero contribution.\n\nCounting rules:\n - `tasksMapped` \u2014 distinct tasks where the user submitted at\n least one `done:true` changeset while the task was in\n `to_map` or `to_remap`.\n - `tasksValidated` \u2014 distinct tasks where the user submitted\n at least one `done:true` changeset while in `to_review`.\n - `totalMappingMinutes` / `totalValidationMinutes` \u2014 sum of\n `(released_at - locked_at)` over the user's lock sessions\n in mapping/validation context.\n - `totalAreaSqkm` \u2014 sum of `tasking_tasks.area_sqkm` over\n tasks the user mapped (each task counted once).\n", + "operationId": "getUserStats", + "responses": { + "200": { + "description": "User stats.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStats" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Workspace not found, user has no activity, or outside tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "parameters": { + "WorkspaceIdPath": { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + "ProjectIdPath": { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + "TaskNumberPath": { + "name": "task_number", + "in": "path", + "required": true, + "description": "Sequential, human-readable id scoped to the project. Starts at 1.", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + "UserIdPath": { + "name": "user_id", + "in": "path", + "required": true, + "description": "TDEI auth user uuid.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "IdempotencyKeyHeader": { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "description": "Client-generated unique value (UUID recommended). Scoped per project.", + "schema": { + "type": "string", + "minLength": 8, + "maxLength": 128 + } + } + }, + "schemas": { + "ProjectStatus": { + "type": "string", + "enum": [ + "draft", + "open", + "done" + ] + }, + "TaskStatus": { + "type": "string", + "enum": [ + "to_map", + "to_review", + "to_remap", + "completed" + ] + }, + "TaskBoundaryType": { + "type": "string", + "enum": [ + "grid", + "import" + ], + "nullable": true + }, + "GridSource": { + "type": "string", + "enum": [ + "grid", + "import" + ] + }, + "WorkspaceUserRoleType": { + "type": "string", + "enum": [ + "lead", + "validator", + "contributor" + ] + }, + "LockReleaseReason": { + "type": "string", + "enum": [ + "auto_unlock", + "manual", + "lead_release", + "stale_timeout", + "reset" + ], + "description": "`auto_unlock` \u2014 released by a successful `/submit` with `done:true`.\n`manual` \u2014 caller released their own lock via `DELETE /lock`.\n`lead_release` \u2014 LEAD force-released via `DELETE /lock?force=true`.\n`stale_timeout` \u2014 released by the background job (`expires_at < NOW()`).\n`reset` \u2014 released by `POST /reset` on the task or its project.\n" + }, + "FeedbackReason": { + "type": "string", + "enum": [ + "incomplete_mapping", + "data_quality_issue", + "wrong_area", + "other" + ], + "description": "Reason categories persisted to `tasking_feedback.reason_category`.\nRequired when feedback is used to drive a `to_review \u2192 to_remap`\ntransition (see `POST /submit`); optional for generic free-form\nnotes on a task.\n" + }, + "AuditEventType": { + "type": "string", + "enum": [ + "project_created", + "project_activated", + "project_closed", + "project_edited", + "project_deleted", + "aoi_uploaded", + "aoi_deleted", + "task_created", + "task_state_changed", + "task_locked", + "task_lock_extended", + "task_lock_renewed", + "task_unlocked", + "task_reset", + "project_reset", + "changeset_submitted", + "feedback_submitted" + ] + }, + "GeoJsonPolygon": { + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Polygon" + ] + }, + "coordinates": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "items": { + "type": "number" + } + } + } + } + } + }, + "GeoJsonMultiPolygon": { + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiPolygon" + ] + }, + "coordinates": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "items": { + "type": "number" + } + } + } + } + } + } + }, + "AoiGeometry": { + "description": "AOI accepts either a single Polygon or a MultiPolygon (EPSG:4326). Servers store both as MultiPolygon (single-Polygon inputs are upcast on insert).", + "oneOf": [ + { + "$ref": "#/components/schemas/GeoJsonPolygon" + }, + { + "$ref": "#/components/schemas/GeoJsonMultiPolygon" + } + ] + }, + "AoiFeature": { + "type": "object", + "required": [ + "type", + "geometry", + "properties" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Feature" + ] + }, + "geometry": { + "$ref": "#/components/schemas/AoiGeometry" + }, + "properties": { + "type": "object", + "additionalProperties": true + } + } + }, + "AoiFeatureCollection": { + "type": "object", + "required": [ + "type", + "features" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "FeatureCollection" + ] + }, + "features": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "$ref": "#/components/schemas/AoiFeature" + } + } + } + }, + "AoiUploadRequest": { + "oneOf": [ + { + "$ref": "#/components/schemas/AoiFeature" + }, + { + "$ref": "#/components/schemas/AoiFeatureCollection" + }, + { + "$ref": "#/components/schemas/AoiGeometry" + } + ] + }, + "TaskBoundaryFeature": { + "type": "object", + "required": [ + "type", + "geometry" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Feature" + ] + }, + "geometry": { + "$ref": "#/components/schemas/GeoJsonPolygon" + }, + "properties": { + "type": "object", + "additionalProperties": true + } + } + }, + "TaskBoundariesFeatureCollection": { + "type": "object", + "required": [ + "type", + "features" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "FeatureCollection" + ] + }, + "features": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/TaskBoundaryFeature" + } + } + } + }, + "Project": { + "type": "object", + "required": [ + "id", + "workspaceId", + "name", + "status", + "reviewRequired", + "lockTimeoutHours", + "createdBy", + "createdAt", + "updatedAt", + "hasAoi", + "taskCount" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "workspaceId": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string", + "maxLength": 255 + }, + "instructions": { + "type": "string", + "nullable": true + }, + "status": { + "$ref": "#/components/schemas/ProjectStatus" + }, + "reviewRequired": { + "type": "boolean", + "default": true + }, + "lockTimeoutHours": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "default": 8 + }, + "taskBoundaryType": { + "$ref": "#/components/schemas/TaskBoundaryType" + }, + "hasAoi": { + "type": "boolean" + }, + "taskCount": { + "type": "integer", + "minimum": 0 + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "createdByName": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ProjectListItem": { + "type": "object", + "required": [ + "id", + "name", + "status", + "taskCount", + "percentCompleted", + "createdAt" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ProjectStatus" + }, + "taskCount": { + "type": "integer", + "minimum": 0 + }, + "percentCompleted": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "createdByName": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ProjectListResponse": { + "type": "object", + "required": [ + "results", + "pagination" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectListItem" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "ProjectCreateRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Unique within the workspace (case-insensitive) among non-deleted projects." + }, + "instructions": { + "type": "string", + "maxLength": 10000, + "nullable": true + }, + "reviewRequired": { + "type": "boolean", + "default": true, + "description": "If false, tasks transition straight to `completed` on mapper submit. Immutable after activation." + }, + "lockTimeoutHours": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "default": 8 + }, + "aoi": { + "allOf": [ + { + "$ref": "#/components/schemas/AoiUploadRequest" + } + ], + "nullable": true, + "description": "Optional inline AOI (same validation as `POST .../aoi`)." + }, + "roleAssignments": { + "type": "array", + "description": "Optional list of project-level role assignments to seed at\ncreation. Each entry is upserted into `tasking_project_roles`\nin the same transaction as the project insert.\n\nThe creator is auto-added as `lead` on `tasking_project_roles`\nand does not need to appear here.\n\nEach `userId` must be a member of the workspace's TDEI\nproject group; entries failing this check are rejected with\n422 and no rows are written.\n", + "items": { + "$ref": "#/components/schemas/ProjectRoleAssignment" + } + } + } + }, + "ProjectRoleAssignment": { + "type": "object", + "required": [ + "userId", + "role" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "ProjectUpdateRequest": { + "type": "object", + "description": "All fields optional. Server enforces per-status mutability.", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "instructions": { + "type": "string", + "maxLength": 10000, + "nullable": true + }, + "lockTimeoutHours": { + "type": "integer", + "minimum": 1, + "maximum": 720 + }, + "reviewRequired": { + "type": "boolean" + } + } + }, + "TaskLockSummary": { + "type": "object", + "required": [ + "userId", + "lockedAt", + "expiresAt" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "userName": { + "type": "string", + "nullable": true + }, + "lockedAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "LastMapper": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "userName": { + "type": "string", + "nullable": true + } + } + }, + "Task": { + "type": "object", + "required": [ + "id", + "taskNumber", + "status", + "geometry", + "areaSqkm", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "taskNumber": { + "type": "integer", + "minimum": 1 + }, + "status": { + "$ref": "#/components/schemas/TaskStatus" + }, + "geometry": { + "$ref": "#/components/schemas/GeoJsonPolygon" + }, + "areaSqkm": { + "type": "number" + }, + "lock": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/TaskLockSummary" + } + ] + }, + "lastMapper": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/LastMapper" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "TaskListResponse": { + "type": "object", + "required": [ + "tasks", + "pagination" + ], + "properties": { + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Task" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "ValidateWarning": { + "type": "object", + "required": [ + "taskIndex", + "issue" + ], + "properties": { + "taskIndex": { + "type": "integer", + "description": "0-based index into the submitted `features` array." + }, + "issue": { + "type": "string", + "enum": [ + "polygon_exceeds_grid_size" + ] + }, + "areaSqkm": { + "type": "number" + } + } + }, + "ValidatePreviewResponse": { + "type": "object", + "required": [ + "valid", + "warnings", + "source", + "featureCollection" + ], + "properties": { + "valid": { + "type": "boolean", + "description": "True when no hard failures. Warnings do not affect this." + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidateWarning" + } + }, + "source": { + "$ref": "#/components/schemas/GridSource", + "description": "Always `import` for this response. Pass through unchanged to `/save`." + }, + "featureCollection": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + } + }, + "SaveTasksRequest": { + "type": "object", + "required": [ + "source", + "featureCollection" + ], + "properties": { + "source": { + "$ref": "#/components/schemas/GridSource", + "description": "Sets `tasking_projects.task_boundary_type` for the project." + }, + "featureCollection": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + } + }, + "SaveTasksResponse": { + "type": "object", + "required": [ + "projectId", + "taskBoundaryType", + "taskCount", + "tasks" + ], + "properties": { + "projectId": { + "type": "integer", + "format": "int64" + }, + "taskBoundaryType": { + "$ref": "#/components/schemas/GridSource" + }, + "taskCount": { + "type": "integer", + "minimum": 1, + "description": "Number of tasks created (== `featureCollection.features.length`)." + }, + "tasks": { + "type": "array", + "description": "All created tasks in the same order as the request's `features[]`. `taskNumber` is sequential starting at 1.", + "items": { + "$ref": "#/components/schemas/Task" + } + }, + "idempotencyKey": { + "type": "string", + "nullable": true + }, + "replayed": { + "type": "boolean", + "default": false, + "description": "True on idempotent replay (HTTP 200 case)." + } + } + }, + "Feedback": { + "type": "object", + "description": "Feedback payload \u2014 persisted to `tasking_feedback`. Used by\n`POST /submit` to attach a validator's remap-rejection note,\nand reusable by any future endpoint that records free-form\ntask feedback.\n", + "required": [ + "notes" + ], + "properties": { + "reasonCategory": { + "allOf": [ + { + "$ref": "#/components/schemas/FeedbackReason" + } + ], + "nullable": true, + "description": "Required when the feedback drives `to_review \u2192 to_remap`; optional otherwise." + }, + "notes": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + } + } + }, + "SubmitRequest": { + "type": "object", + "required": [ + "osmChangesetId", + "done" + ], + "properties": { + "osmChangesetId": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "done": { + "type": "boolean", + "description": "`true` \u2014 auto-unlocks and transitions state per the table.\n`false` \u2014 records the changeset, state unchanged, lock\n`expires_at` slides forward to `submitted_at +\nlock_timeout_hours`.\n" + }, + "feedback": { + "allOf": [ + { + "$ref": "#/components/schemas/Feedback" + } + ], + "description": "Meaningful only in validator context on `to_review`. When\nprovided here, `feedback.reasonCategory` is required (drives\nthe `to_remap` transition) and the row is persisted to\n`tasking_feedback`.\n" + } + } + }, + "ExistingLockSummary": { + "type": "object", + "required": [ + "taskNumber", + "taskStatus", + "lockedAt", + "expiresAt" + ], + "properties": { + "taskNumber": { + "type": "integer", + "minimum": 1 + }, + "taskStatus": { + "$ref": "#/components/schemas/TaskStatus" + }, + "lockedAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "LockConflictError": { + "allOf": [ + { + "$ref": "#/components/schemas/Error" + }, + { + "type": "object", + "properties": { + "existingLock": { + "$ref": "#/components/schemas/ExistingLockSummary" + } + } + } + ] + }, + "UserSummary": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "displayName": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "nullable": true + } + } + }, + "AssignableRoleType": { + "type": "string", + "enum": [ + "lead", + "validator" + ], + "description": "Roles assignable via `PUT /workspaces/{wid}/users/{uid}/role`.\nCONTRIBUTOR is implicit (project-group membership grants it\nautomatically) and cannot be set by this endpoint.\n" + }, + "SetRoleRequest": { + "type": "object", + "required": [ + "role" + ], + "properties": { + "role": { + "$ref": "#/components/schemas/AssignableRoleType" + } + } + }, + "WorkspaceUserRoleItem": { + "type": "object", + "description": "Privileged-member DTO returned by `GET /workspaces/{wid}/users`.\nMirrors the shipped `WorkspaceUserRoleItem` from\n`api/src/users/schemas.py` on the `roles` branch.\n", + "required": [ + "id", + "auth_uid", + "email", + "display_name", + "role" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "Internal OSM-DB `users.id`." + }, + "auth_uid": { + "type": "string", + "description": "TDEI auth user uuid as string (`users.auth_uid`)." + }, + "email": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "ProjectRoleItem": { + "type": "object", + "description": "One row of `tasking_project_roles`, joined with `users` for the\ndisplay name. Returned by every `/projects/{pid}/roles[/{uid}]`\nendpoint.\n", + "required": [ + "user_id", + "role", + "updated_at" + ], + "properties": { + "user_id": { + "type": "string", + "format": "uuid", + "description": "Maps to `users.auth_uid`." + }, + "user_name": { + "type": "string", + "nullable": true, + "description": "Mirror of `users.display_name`, joined at read time." + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "ProjectRoleListResponse": { + "type": "object", + "required": [ + "results" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "ProjectRoleAddRequest": { + "type": "object", + "description": "Body for `POST /projects/{pid}/roles`.", + "required": [ + "user_id", + "role" + ], + "additionalProperties": false, + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "ProjectRoleUpdateRequest": { + "type": "object", + "description": "Body for `PATCH /projects/{pid}/roles/{user_id}`.", + "required": [ + "role" + ], + "additionalProperties": false, + "properties": { + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "SelfProjectRoleItem": { + "type": "object", + "required": [ + "project_id", + "project_name", + "project_status", + "role" + ], + "properties": { + "project_id": { + "type": "integer", + "format": "int64" + }, + "project_name": { + "type": "string" + }, + "project_status": { + "$ref": "#/components/schemas/ProjectStatus" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "SelfProjectRolesResponse": { + "type": "object", + "required": [ + "workspace_role", + "projects" + ], + "properties": { + "workspace_role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelfProjectRoleItem" + } + } + } + }, + "ActorRef": { + "type": "object", + "required": [ + "user_id" + ], + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "display_name": { + "type": "string", + "nullable": true + } + } + }, + "AuditEvent": { + "type": "object", + "required": [ + "id", + "event_type", + "project_id", + "actor", + "occurred_at", + "details" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "event_type": { + "$ref": "#/components/schemas/AuditEventType" + }, + "project_id": { + "type": "integer", + "format": "int64" + }, + "task_id": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "task_number": { + "type": "integer", + "nullable": true, + "description": "Convenience copy from `details` (so list renderers don't peek inside JSONB)." + }, + "actor": { + "$ref": "#/components/schemas/ActorRef" + }, + "occurred_at": { + "type": "string", + "format": "date-time" + }, + "details": { + "type": "object", + "additionalProperties": true + }, + "project_deleted": { + "type": "boolean", + "default": false + } + } + }, + "AuditEventListResponse": { + "type": "object", + "required": [ + "results", + "pagination" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuditEvent" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "TaskStatusCounts": { + "type": "object", + "required": [ + "to_map", + "to_review", + "to_remap", + "completed" + ], + "properties": { + "to_map": { + "type": "integer", + "minimum": 0 + }, + "to_review": { + "type": "integer", + "minimum": 0 + }, + "to_remap": { + "type": "integer", + "minimum": 0 + }, + "completed": { + "type": "integer", + "minimum": 0 + } + } + }, + "ProjectStats": { + "type": "object", + "required": [ + "projectId", + "totalTasks", + "tasksByStatus", + "percentMapped", + "percentCompleted", + "uniqueMappers", + "uniqueValidators" + ], + "properties": { + "projectId": { + "type": "integer", + "format": "int64" + }, + "totalTasks": { + "type": "integer", + "minimum": 0 + }, + "tasksByStatus": { + "$ref": "#/components/schemas/TaskStatusCounts" + }, + "percentMapped": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "percentCompleted": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "avgTaskDurationMinutes": { + "type": "number", + "nullable": true + }, + "uniqueMappers": { + "type": "integer", + "minimum": 0 + }, + "uniqueValidators": { + "type": "integer", + "minimum": 0 + } + } + }, + "UserStatsTotals": { + "type": "object", + "required": [ + "tasksMapped", + "tasksValidated", + "totalMappingMinutes", + "totalValidationMinutes", + "totalChangesets", + "totalAreaSqkm" + ], + "properties": { + "tasksMapped": { + "type": "integer", + "minimum": 0 + }, + "tasksValidated": { + "type": "integer", + "minimum": 0 + }, + "totalMappingMinutes": { + "type": "integer", + "minimum": 0 + }, + "totalValidationMinutes": { + "type": "integer", + "minimum": 0 + }, + "totalChangesets": { + "type": "integer", + "minimum": 0 + }, + "totalAreaSqkm": { + "type": "number", + "minimum": 0 + } + } + }, + "UserStatsByProject": { + "type": "object", + "required": [ + "projectId", + "projectName", + "tasksMapped", + "tasksValidated" + ], + "properties": { + "projectId": { + "type": "integer", + "format": "int64" + }, + "projectName": { + "type": "string" + }, + "tasksMapped": { + "type": "integer", + "minimum": 0 + }, + "tasksValidated": { + "type": "integer", + "minimum": 0 + }, + "totalChangesets": { + "type": "integer", + "minimum": 0 + }, + "totalAreaSqkm": { + "type": "number", + "minimum": 0 + } + } + }, + "UserStats": { + "type": "object", + "required": [ + "workspaceId", + "userId", + "totals", + "byProject" + ], + "properties": { + "workspaceId": { + "type": "integer", + "format": "int64" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "totals": { + "$ref": "#/components/schemas/UserStatsTotals" + }, + "byProject": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserStatsByProject" + } + } + } + }, + "Pagination": { + "type": "object", + "required": [ + "page", + "pageSize", + "total" + ], + "properties": { + "page": { + "type": "integer", + "minimum": 1 + }, + "pageSize": { + "type": "integer", + "minimum": 1 + }, + "total": { + "type": "integer", + "minimum": 0 + } + } + }, + "Error": { + "type": "object", + "required": [ + "detail" + ], + "properties": { + "detail": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + ], + "description": "FastAPI default error shape \u2014 string for raised `HTTPException`, structured list for pydantic validation errors." + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Malformed JSON or schema-level failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Unauthorized": { + "description": "Missing or invalid bearer token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "ForbiddenLeadRequired": { + "description": "Caller is a workspace contributor but does not hold LEAD.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "WorkspaceNotFound": { + "description": "Workspace does not exist, or is outside the caller's tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "ProjectOrWorkspaceNotFound": { + "description": "Workspace or project does not exist, or is outside the caller's tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "TaskOrProjectOrWorkspaceNotFound": { + "description": "Workspace, project, or task does not exist, or is outside the caller's tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "UnprocessableEntity": { + "description": "Well-formed request that violates a business rule.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/docs/tasking-mvp/tasking-mvp.openapi.yaml b/docs/tasking-mvp/tasking-mvp.openapi.yaml new file mode 100644 index 0000000..8076441 --- /dev/null +++ b/docs/tasking-mvp/tasking-mvp.openapi.yaml @@ -0,0 +1,2029 @@ +openapi: 3.0.3 +info: + title: Workspaces Tasking Manager API + version: 1.0.0 + description: | + Tasking Manager API for the Workspaces Tasking Manager MVP. + +servers: + - url: /api/v1 + description: workspaces-backend FastAPI app — all routers are mounted under `/api/v1` (see `api/main.py`). + +tags: + - name: projects + description: Tasking project CRUD and lifecycle. + - name: aoi + description: Project area-of-interest upload, retrieval, deletion. + - name: tasks + description: Task validation, persistence and read access. + - name: locks + description: Lock lifecycle on a task. + - name: submit + description: Done? submit flow — changeset + state transition. + - name: roles + description: Workspace and project role management. + - name: audit + description: Audit trail. + - name: stats + description: Project and user statistics. + +security: + - bearerAuth: [] + +paths: + # ========================================================================= + # Projects + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + get: + tags: [projects] + summary: List tasking projects in a workspace. + description: Paginated list of non-deleted projects. Any workspace contributor. + operationId: listProjects + parameters: + - in: query + name: status + schema: { $ref: "#/components/schemas/ProjectStatus" } + - in: query + name: textSearch + schema: { type: string, maxLength: 255 } + description: Case-insensitive substring match on `name`. + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: pageSize + schema: { type: integer, minimum: 1, maximum: 200, default: 20 } + - in: query + name: orderBy + schema: + type: string + enum: [createdAt, updatedAt, name] + default: createdAt + - in: query + name: orderByType + schema: { type: string, enum: [ASC, DESC], default: DESC } + responses: + "200": + description: Paginated list. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + post: + tags: [projects] + summary: Create a tasking project (optionally with AOI and role assignments). + description: | + LEAD only. Creates a project in `draft` status. + + Optional inline AOI may be provided (same validation as + `POST .../aoi`). + + Optional `roleAssignments` may be provided to seed the + project-level role overrides table (`tasking_project_roles`) in + the same transaction. The creator is auto-added with + `role = lead`; other users may be added with any of `lead`, + `validator`, `contributor`. + + Activation later requires at least one user assigned with + `contributor` or `validator` so the project has at least one + worker before it goes live — see `POST .../activate`. + operationId: createProject + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectCreateRequest" } + responses: + "201": + description: Created. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + "409": + description: A non-deleted project with this name already exists. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [projects] + summary: Get a tasking project. + operationId: getProject + responses: + "200": + description: Project detail. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + patch: + tags: [projects] + summary: Update editable settings. + description: | + LEAD only. Per-status mutability: + + | Field | draft | open | done | + |--------------------|-------|------|------| + | name | yes | yes | no | + | instructions | yes | yes | no | + | lockTimeoutHours | yes | yes | no | + | reviewRequired | yes | NO | no | + + AOI is updated through the dedicated AOI endpoints, not PATCH. + Immutable-field writes return 422. + operationId: updateProject + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectUpdateRequest" } + responses: + "200": + description: Updated. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + delete: + tags: [projects] + summary: Soft-delete a tasking project. + description: | + LEAD only. Sets `deletedAt`, hard-deletes child tasks, retains + audit rows (flagged with `project_deleted=true`). Returns 409 + if any task currently has an active lock — force-release first. + operationId: deleteProject + responses: + "204": + description: Soft-deleted. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: Project has active task locks. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/activate: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [projects] + summary: Activate a draft project (`draft` → `open`). + description: | + LEAD only. Pre-conditions (all must hold; 422 with a clear + `detail` otherwise): + - Project has a `name`. + - Project has an AOI set. + - Project has at least one saved task. + - At least one user is allocated to the project with role + `contributor` or `validator` in `tasking_project_roles` + (the creator's auto-`lead` row does not satisfy this — a + worker must be assigned). + operationId: activateProject + responses: + "200": + description: Activated. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/close: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [projects] + summary: Close an open project (`open` → `done`). + description: | + LEAD only. Requires every task to be `completed` and no active + locks. + operationId: closeProject + responses: + "200": + description: Closed. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: One or more tasks are still locked or not completed. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/reset: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [projects] + summary: Reset all tasks in a project back to `to_map`. + description: | + LEAD only. Restarts work on the whole project so contributors + can re-map and re-validate. + + Effect (single transaction): + - Releases every active lock with `release_reason = 'reset'`, + emitting one `task_unlocked` audit event per released lock. + - Sets every task's `status` to `to_map`, clears + `last_mapper_id`, emits a `task_state_changed` audit event + for every task whose status actually changed. + - If the project was `done`, transitions it back to `open`. + - Emits one `project_reset` audit event for the project. + + Tasks themselves (geometry, history of changesets and remap + feedback) are NOT deleted — only their lifecycle state is + rewound. Stats and audit history remain intact. + operationId: resetProject + responses: + "200": + description: Reset complete. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": + description: Project is in `draft` (nothing to reset). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + # ========================================================================= + # AOI + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/aoi: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [aoi] + summary: Get the AOI of a project. + description: | + Returns the AOI as a GeoJSON Feature (Polygon, EPSG:4326). + `404` if no AOI is set. + operationId: getProjectAoi + responses: + "200": + description: AOI. + content: + application/geo+json: + schema: { $ref: "#/components/schemas/AoiFeature" } + application/json: + schema: { $ref: "#/components/schemas/AoiFeature" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + post: + tags: [aoi] + summary: Upload or replace the AOI. + description: | + LEAD only. Accepts a GeoJSON `Feature`, `FeatureCollection` + (single feature), or bare geometry. Geometry may be either + `Polygon` or `MultiPolygon` (EPSG:4326). Single-Polygon inputs + are upcast to MultiPolygon on insert; the storage column is + `GEOMETRY(MultiPolygon, 4326)`. + + Project must be in `draft`. Replacing an AOI hard-deletes any + saved tasks. + operationId: uploadProjectAoi + requestBody: + required: true + content: + application/geo+json: + schema: { $ref: "#/components/schemas/AoiUploadRequest" } + application/json: + schema: { $ref: "#/components/schemas/AoiUploadRequest" } + responses: + "200": + description: AOI stored. + content: + application/geo+json: + schema: { $ref: "#/components/schemas/AoiFeature" } + application/json: + schema: { $ref: "#/components/schemas/AoiFeature" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + delete: + tags: [aoi] + summary: Delete the AOI. + description: | + LEAD only. Project must be in `draft`. Clears the AOI, hard- + deletes any saved tasks (releasing their locks in the same + transaction), clears `taskBoundaryType`. Returns 404 if no AOI + is set. + operationId: deleteProjectAoi + responses: + "204": + description: AOI deleted. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + description: Project not found, or project has no AOI. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: Project is not in `draft`. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + # ========================================================================= + # Tasks (validate + save + read) + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/validate: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [tasks] + summary: Validate a GeoJSON FeatureCollection of candidate task boundaries. + description: | + LEAD only. Does NOT persist anything — the client must POST each + validated Feature to `/tasks/save` to commit. + + Project preconditions: status `draft`, AOI uploaded. + + Hard validation (422 on failure): + - Top-level type must be `FeatureCollection`. + - Every feature geometry must be `Polygon` (no MultiPolygon). + - Coordinates valid lon/lat (EPSG:4326). + - Every polygon lies within the project AOI (centroid-inside + is not sufficient). + - At least one feature. + + Non-blocking warning: + - `polygon_exceeds_grid_size` — area exceeds + `TM_TASKING_GRID_SIZE_METERS`² (km²). + operationId: validateTasks + requestBody: + required: true + content: + application/geo+json: + schema: + { $ref: "#/components/schemas/TaskBoundariesFeatureCollection" } + application/json: + schema: + { $ref: "#/components/schemas/TaskBoundariesFeatureCollection" } + responses: + "200": + description: Validation result. + content: + application/json: + schema: { $ref: "#/components/schemas/ValidatePreviewResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/save: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/IdempotencyKeyHeader" + post: + tags: [tasks] + summary: Persist a validated FeatureCollection as the project's tasks (bulk). + description: | + LEAD only. Commits the **entire** FeatureCollection as + `tasking_tasks` rows in a single transaction. + + Effect (single transaction; all-or-nothing): + 1. Re-validates every feature against the project AOI (same + rules as `POST /tasks/validate`). Any hard failure aborts + the transaction — no rows are written. + 2. Allocates sequential `taskNumber`s starting at 1, in the + order features appear in the request. + 3. Bulk-inserts the `tasking_tasks` rows. + 4. Sets `tasking_projects.task_boundary_type` to `source`. + 5. Emits one `task_created` audit event per task. + + Project preconditions: + - Project must be in `draft`. + - Project must have an AOI set. + - Project must currently have **no tasks** (a second save is + rejected with 409 `"Tasks already saved"`). To replace + tasks, re-upload the AOI in `draft` — that wipes existing + tasks and re-enables save. + + Idempotency: `Idempotency-Key` is honoured at the batch level. + Same key + same body within the configured TTL replays the + original response (HTTP 200, `replayed: true`). Same key with + different body returns 409 + `"Idempotency key reused with a different request"`. See the + top-level idempotency contract. + operationId: saveTasks + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SaveTasksRequest" } + responses: + "201": + description: Tasks created. + content: + application/json: + schema: { $ref: "#/components/schemas/SaveTasksResponse" } + "200": + description: | + Idempotent replay (same key + same body); `replayed: true`. + content: + application/json: + schema: { $ref: "#/components/schemas/SaveTasksResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: | + One of: + - "Tasks already saved" — project already has tasks; re-upload AOI to start over. + - "Idempotency key reused with a different request". + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [tasks] + summary: List tasks (geometry always included; serves list + map views). + description: | + Paginated. Any workspace contributor. + operationId: listTasks + parameters: + - in: query + name: status + schema: { $ref: "#/components/schemas/TaskStatus" } + - in: query + name: lockedByUserId + schema: { type: string, format: uuid } + - in: query + name: lastMapperId + schema: { type: string, format: uuid } + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: pageSize + schema: { type: integer, minimum: 1, maximum: 1000, default: 200 } + responses: + "200": + description: Tasks. + content: + application/json: + schema: { $ref: "#/components/schemas/TaskListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + get: + tags: [tasks] + summary: Get task detail (geometry, status, lock, last mapper). + operationId: getTask + responses: + "200": + description: Task. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: Workspace, project, or task not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + # ========================================================================= + # Locking + Submit + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/lock: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [locks] + summary: Acquire a lock on a task. + description: | + Eligibility: + + | Task status | Allowed roles | + |-------------|---------------------------------------------------| + | `to_map` | CONTRIBUTOR, LEAD | + | `to_remap` | CONTRIBUTOR, LEAD | + | `to_review` | VALIDATOR, LEAD — and NOT this task's last_mapper | + | `completed` | (none) | + + Other rules: + - No other active lock on this task. + - Caller holds no other active lock in this project (one-lock- + per-project rule). 409 response includes `existingLock`. + - Project status is `open`. + + Side effects: + - INSERT `tasking_locks` with + `expires_at = NOW() + project.lock_timeout_hours`. + - Emit `task_locked` audit event. + operationId: lockTask + responses: + "200": + description: Lock acquired. Body is the updated task. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Role does not permit locking in this status, or self-validation guard hit. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "409": + description: | + One of: + - "Task is already locked". + - "User already holds a lock in this project" — body includes `existingLock`. + - "Project is not open". + content: + application/json: + schema: { $ref: "#/components/schemas/LockConflictError" } + delete: + tags: [locks] + summary: Release a lock on a task. + description: | + Default: caller must be the active lock holder + (`release_reason = manual`). + + With `?force=true`: caller must be LEAD + (`release_reason = lead_release`). + + Task `status` is unchanged in both modes. Force-release does + not relock — LEAD must POST `/lock` separately to take over. + operationId: unlockTask + parameters: + - in: query + name: force + schema: { type: boolean, default: false } + responses: + "204": + description: Released. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Not the lock holder (default mode) or not LEAD (force mode). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "409": + description: Task has no active lock. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/extend: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [locks] + summary: Extend the expiry of your own lock. + description: | + Caller must hold the active lock. Adds the project's + `lock_timeout_hours` to the current `expires_at` (slides from + the existing expiry, not from `NOW()`). Emits + `task_lock_extended`. + operationId: extendTaskLock + responses: + "200": + description: Lock extended. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller does not hold the active lock. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "409": + description: Task has no active lock to extend. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/reset: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [locks] + summary: Reset a single task back to `to_map`. + description: | + LEAD only. Restarts work on a task — useful when a contributor + or validator pushed a task into a wrong terminal state and + someone needs to redo it. + + Effect (single transaction): + - If an active lock exists, it is released with + `release_reason = 'reset'` and a `task_unlocked` audit + event is emitted. + - Task `status` is set to `to_map`, `last_mapper_id` is + cleared, and a `task_state_changed` audit event is emitted + (only when the status actually changed). + - A `task_reset` audit event is emitted. + + Existing changeset rows and remap-feedback rows are NOT + deleted — only the live task state is rewound. + + Pre-conditions: project status is `open` or `done`. + operationId: resetTask + responses: + "200": + description: Task reset. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "422": + description: Project is in `draft` (no tasks to reset yet). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/submit: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [submit] + summary: Submit an OSM changeset and optionally transition state. + description: | + The "Done?" flow. Caller must hold the active lock. + + Effective actor role is inferred from current task status: + - `to_map` / `to_remap` → mapper context + - `to_review` → validator context + (LEAD may act in either context.) + + State transition (when `done:true`): + + | Current | Effective role | Feedback? | New status | + |-------------|--------------------|-----------|---------------------------------------------------------| + | `to_map` | contributor / lead | n/a | `to_review` if `review_required` else `completed` | + | `to_remap` | contributor / lead | n/a | `to_review` | + | `to_review` | validator / lead | no | `completed` | + | `to_review` | validator / lead | yes | `to_remap` (and INSERT `tasking_feedback` with `reasonCategory` set) | + + With `done:false` the state is unchanged but the lock's + `expires_at` slides forward to `submitted_at + lock_timeout_hours` + (emits `task_lock_renewed`). + + `osmChangesetId` is supplied by the client. In practice it is + auto-populated by the editor (Rapid in the iframe) on save — + the UI does not ask the user to type it. The id may optionally + be verified by looking it up in the internal `osm-web` service + (same network); a verification miss returns `422` with detail + "Invalid OSM changeset id" and nothing is written. + + `last_mapper_id` is updated only when the effective role is + mapper. + + Feedback rules: + - `feedback` is meaningful only in validator context on + `to_review`. Otherwise its presence returns 422. + - For `to_review` → `to_remap`, `feedback.reasonCategory` is + required (422 if missing). + operationId: submitTask + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SubmitRequest" } + responses: + "200": + description: Submission accepted. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller does not hold the active lock. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + # ========================================================================= + # Roles + # ========================================================================= + + # NOTE: the workspace-level role surface below mirrors the shipped + # endpoints on the `roles` branch (api/src/users/routes.py). It uses + # `/workspaces/{workspace_id}/users/...`, not `/.../roles/...`. Only + # privileged role rows (LEAD, VALIDATOR) are stored; CONTRIBUTOR is + # implicit for any project-group member and cannot be assigned via PUT. + + /workspaces/{workspace_id}/users: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + get: + tags: [roles] + summary: List privileged workspace members (lead + validator role rows). + description: | + Returns the rows in `user_workspace_roles` for this workspace, + joined with their `users` record. Visible to any workspace + contributor (`isWorkspaceContributor`). + + Does NOT include implicit contributors — only users with an + explicit LEAD or VALIDATOR row. The UI uses this for the team + management table; broader user listings come from the TDEI + proxy (see `/assignable-users`). + operationId: listWorkspaceMembers + responses: + "200": + description: Privileged members. + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/WorkspaceUserRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller is not a member of the workspace's project group. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/users/{user_id}/role: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/UserIdPath" + put: + tags: [roles] + summary: Assign (upsert) a privileged role for a user on a workspace. + description: | + LEAD only. Idempotent upsert into `user_workspace_roles`. + + The body's `role` must be `lead` or `validator` — assigning + `contributor` is rejected with 422 ("cannot assign implicit + role 'contributor' directly"). Contributor is the default for + any project-group member; to remove an explicit role and fall + back to contributor, call `DELETE /users/{user_id}` below. + + The named user must have signed in to Workspaces at least once + (i.e. exist in `users.auth_uid`). If they have not, returns + 404 with detail "User {uuid} has not signed in to Workspaces + yet". + + Side effect: the target user's `UserInfo` cache entry is + evicted (`evict_user_from_cache`) so their next request + reflects the new role immediately. + operationId: assignWorkspaceMemberRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SetRoleRequest" } + responses: + "204": + description: Role assigned (or updated). + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller is not LEAD on this workspace. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: Workspace not found, or the named user has not signed in to Workspaces yet. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: '"cannot assign implicit role ''contributor'' directly".' + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/users/{user_id}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/UserIdPath" + delete: + tags: [roles] + summary: Remove a user's privileged role on a workspace. + description: | + LEAD only. Deletes the row from `user_workspace_roles`; the + user's role falls back to implicit `contributor` (or to + whatever TDEI grants — `poc` still maps to LEAD). + + Side effect: the target user's `UserInfo` cache entry is + evicted. + operationId: removeWorkspaceMemberRole + responses: + "204": + description: Role row removed. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller is not LEAD on this workspace. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: No role row exists for this user/workspace. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/roles: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [roles] + summary: List role assignments on a project. + description: | + Returns every row in `tasking_project_roles` for the project, + joined with `users.display_name` for human-readable labels. + Any workspace contributor; the workspace tenancy gate still + applies (404 for outsiders). + operationId: listProjectRoles + responses: + "200": + description: All role assignments. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + post: + tags: [roles] + summary: Add a role assignment to a project. + description: | + Workspace LEAD **or** project LEAD only. Inserts a row in + `tasking_project_roles`. Duplicate `(project_id, user_id)` pairs + return 409 — use PATCH to change a role. The `user_id` must + already exist in `users` (i.e. the user has signed in to + Workspaces at least once); otherwise 422 with a + `missing_user_ids` list. + operationId: addProjectRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleAddRequest" } + responses: + "201": + description: Role added. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: User already has a role on this project — use PATCH. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: "`user_id` does not exist in the `users` table." + content: + application/json: + schema: + type: object + properties: + detail: + type: object + properties: + message: { type: string } + missing_user_ids: + type: array + items: { type: string, format: uuid } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/roles/{user_id}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/UserIdPath" + get: + tags: [roles] + summary: Get a single user's role on a project. + description: | + Returns the single `tasking_project_roles` row for this user on + this project. 404 if no assignment exists. Any workspace + contributor; the tenancy gate still applies. + operationId: getProjectRole + responses: + "200": + description: Role assignment. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: User has no role on this project, or project not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + put: + tags: [roles] + summary: Upsert a user's role on a project. + description: | + Workspace LEAD **or** project LEAD only. Idempotent insert-or- + update on `tasking_project_roles`. Returns **201** when the row + is created, **200** when an existing row is updated. + + **Last-LEAD guard**: a PUT that demotes the only remaining LEAD + returns 422 — assign another LEAD first. + + For partial-update semantics, prefer PATCH. + operationId: putProjectRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleUpdateRequest" } + responses: + "200": + description: Existing assignment updated. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "201": + description: New assignment created. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": + description: | + One of: + - `user_id` does not exist in `users` (insert path). + - Would remove the last LEAD (demote path). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + patch: + tags: [roles] + summary: Change a user's role on a project. + description: | + Workspace LEAD **or** project LEAD only. Updates the row in + `tasking_project_roles`. Returns 404 if the user has no role + assignment yet (use POST to add one). + + **Last-LEAD guard**: demoting the only remaining LEAD on the + project returns 422 — assign another LEAD first. + operationId: updateProjectRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleUpdateRequest" } + responses: + "200": + description: Role updated. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + description: User has no role on this project, or project not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: Would remove the last LEAD on the project. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + delete: + tags: [roles] + summary: Remove a role assignment from a project. + description: | + Workspace LEAD **or** project LEAD only. Deletes the row from + `tasking_project_roles`. + + **Last-LEAD guard**: removing the only remaining LEAD on the + project returns 422 — assign another LEAD first. + operationId: removeProjectRole + responses: + "204": + description: Role removed. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + description: User has no role on this project, or project not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: Would remove the last LEAD on the project. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /me/workspaces/{workspace_id}/tasking/projects/roles: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + get: + tags: [roles] + summary: List the caller's role for every project in a workspace. + description: | + Single round-trip for the project-list page: returns one entry + per project with the caller's role on that project. + operationId: listSelfProjectRoles + responses: + "200": + description: Project roles for the caller. + content: + application/json: + schema: { $ref: "#/components/schemas/SelfProjectRolesResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + + # ========================================================================= + # Audit + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/audit: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [audit] + summary: List project-level audit events. + description: | + Paginated, newest first. Any workspace contributor. Soft-deleted + projects are visible only with `include_deleted=true`. + operationId: listProjectAudit + parameters: + - in: query + name: event_type + schema: { $ref: "#/components/schemas/AuditEventType" } + - in: query + name: task_number + schema: { type: integer, minimum: 1 } + - in: query + name: actor_user_id + schema: { type: string, format: uuid } + - in: query + name: occurred_from + schema: { type: string, format: date-time } + - in: query + name: occurred_to + schema: { type: string, format: date-time } + - in: query + name: include_deleted + schema: { type: boolean, default: false } + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: page_size + schema: { type: integer, minimum: 1, maximum: 200, default: 50 } + - in: query + name: order_by_type + schema: { type: string, enum: [ASC, DESC], default: DESC } + responses: + "200": + description: Events. + content: + application/json: + schema: { $ref: "#/components/schemas/AuditEventListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/audit: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + get: + tags: [audit] + summary: List task-level audit events. + description: Newest first. Any workspace contributor. + operationId: listTaskAudit + parameters: + - in: query + name: event_type + schema: { $ref: "#/components/schemas/AuditEventType" } + - in: query + name: actor_user_id + schema: { type: string, format: uuid } + - in: query + name: occurred_from + schema: { type: string, format: date-time } + - in: query + name: occurred_to + schema: { type: string, format: date-time } + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: page_size + schema: { type: integer, minimum: 1, maximum: 200, default: 25 } + - in: query + name: order_by_type + schema: { type: string, enum: [ASC, DESC], default: DESC } + responses: + "200": + description: Events for the task. + content: + application/json: + schema: { $ref: "#/components/schemas/AuditEventListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + + # ========================================================================= + # Stats + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/stats: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [stats] + summary: Project statistics summary. + description: | + Live-computed. Any workspace contributor. + + Field semantics: + - `tasksByStatus` always zero-fills all four states. + - `percentMapped` — share of tasks past `to_map` at least + once. Rounded to nearest integer. + - `percentCompleted` — share whose current status is + `completed`. + - `avgTaskDurationMinutes` — mean across completed tasks of + the sum of `(released_at - locked_at)` over that task's + locks. `null` when no task is completed yet. + - `uniqueMappers` — distinct submitters with at least one + changeset while task was in `to_map` or `to_remap`. + - `uniqueValidators` — distinct submitters with at least one + changeset while task was in `to_review`. + operationId: getProjectStats + responses: + "200": + description: Stats. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectStats" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + + /workspaces/{workspace_id}/tasking/users/{user_id}/stats: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/UserIdPath" + get: + tags: [stats] + summary: Cross-project stats for a user within a workspace. + description: | + `totals` sums across the workspace's non-deleted projects. + `byProject` lists only projects with non-zero contribution. + + Counting rules: + - `tasksMapped` — distinct tasks where the user submitted at + least one `done:true` changeset while the task was in + `to_map` or `to_remap`. + - `tasksValidated` — distinct tasks where the user submitted + at least one `done:true` changeset while in `to_review`. + - `totalMappingMinutes` / `totalValidationMinutes` — sum of + `(released_at - locked_at)` over the user's lock sessions + in mapping/validation context. + - `totalAreaSqkm` — sum of `tasking_tasks.area_sqkm` over + tasks the user mapped (each task counted once). + operationId: getUserStats + responses: + "200": + description: User stats. + content: + application/json: + schema: { $ref: "#/components/schemas/UserStats" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: Workspace not found, user has no activity, or outside tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + parameters: + WorkspaceIdPath: + name: workspace_id + in: path + required: true + schema: { type: integer, format: int64 } + ProjectIdPath: + name: project_id + in: path + required: true + schema: { type: integer, format: int64 } + TaskNumberPath: + name: task_number + in: path + required: true + description: Sequential, human-readable id scoped to the project. Starts at 1. + schema: { type: integer, minimum: 1 } + UserIdPath: + name: user_id + in: path + required: true + description: TDEI auth user uuid. + schema: { type: string, format: uuid } + IdempotencyKeyHeader: + name: Idempotency-Key + in: header + required: false + description: Client-generated unique value (UUID recommended). Scoped per project. + schema: + type: string + minLength: 8 + maxLength: 128 + + schemas: + # ---------- Enums ---------- + + ProjectStatus: + type: string + enum: [draft, open, done] + + TaskStatus: + type: string + enum: [to_map, to_review, to_remap, completed] + + TaskBoundaryType: + type: string + enum: [grid, import] + nullable: true + + GridSource: + type: string + enum: [grid, import] + + WorkspaceUserRoleType: + type: string + enum: [lead, validator, contributor] + + LockReleaseReason: + type: string + enum: [auto_unlock, manual, lead_release, stale_timeout, reset] + description: | + `auto_unlock` — released by a successful `/submit` with `done:true`. + `manual` — caller released their own lock via `DELETE /lock`. + `lead_release` — LEAD force-released via `DELETE /lock?force=true`. + `stale_timeout` — released by the background job (`expires_at < NOW()`). + `reset` — released by `POST /reset` on the task or its project. + + FeedbackReason: + type: string + enum: [incomplete_mapping, data_quality_issue, wrong_area, other] + description: | + Reason categories persisted to `tasking_feedback.reason_category`. + Required when feedback is used to drive a `to_review → to_remap` + transition (see `POST /submit`); optional for generic free-form + notes on a task. + + AuditEventType: + type: string + enum: + - project_created + - project_activated + - project_closed + - project_edited + - project_deleted + - aoi_uploaded + - aoi_deleted + - task_created + - task_state_changed + - task_locked + - task_lock_extended + - task_lock_renewed + - task_unlocked + - task_reset + - project_reset + - changeset_submitted + - feedback_submitted + + # ---------- GeoJSON ---------- + + GeoJsonPolygon: + type: object + required: [type, coordinates] + properties: + type: { type: string, enum: [Polygon] } + coordinates: + type: array + minItems: 1 + items: + type: array + minItems: 4 + items: + type: array + minItems: 2 + maxItems: 3 + items: { type: number } + + GeoJsonMultiPolygon: + type: object + required: [type, coordinates] + properties: + type: { type: string, enum: [MultiPolygon] } + coordinates: + type: array + minItems: 1 + items: + type: array + minItems: 1 + items: + type: array + minItems: 4 + items: + type: array + minItems: 2 + maxItems: 3 + items: { type: number } + + AoiGeometry: + description: AOI accepts either a single Polygon or a MultiPolygon (EPSG:4326). Servers store both as MultiPolygon (single-Polygon inputs are upcast on insert). + oneOf: + - $ref: "#/components/schemas/GeoJsonPolygon" + - $ref: "#/components/schemas/GeoJsonMultiPolygon" + + AoiFeature: + type: object + required: [type, geometry, properties] + properties: + type: { type: string, enum: [Feature] } + geometry: { $ref: "#/components/schemas/AoiGeometry" } + properties: + type: object + additionalProperties: true + + AoiFeatureCollection: + type: object + required: [type, features] + properties: + type: { type: string, enum: [FeatureCollection] } + features: + type: array + minItems: 1 + maxItems: 1 + items: { $ref: "#/components/schemas/AoiFeature" } + + AoiUploadRequest: + oneOf: + - $ref: "#/components/schemas/AoiFeature" + - $ref: "#/components/schemas/AoiFeatureCollection" + - $ref: "#/components/schemas/AoiGeometry" + + TaskBoundaryFeature: + type: object + required: [type, geometry] + properties: + type: { type: string, enum: [Feature] } + geometry: { $ref: "#/components/schemas/GeoJsonPolygon" } + properties: + type: object + additionalProperties: true + + TaskBoundariesFeatureCollection: + type: object + required: [type, features] + properties: + type: { type: string, enum: [FeatureCollection] } + features: + type: array + minItems: 1 + items: { $ref: "#/components/schemas/TaskBoundaryFeature" } + + # ---------- Projects ---------- + + Project: + type: object + required: + - id + - workspaceId + - name + - status + - reviewRequired + - lockTimeoutHours + - createdBy + - createdAt + - updatedAt + - hasAoi + - taskCount + properties: + id: { type: integer, format: int64 } + workspaceId: { type: integer, format: int64 } + name: { type: string, maxLength: 255 } + instructions: { type: string, nullable: true } + status: { $ref: "#/components/schemas/ProjectStatus" } + reviewRequired: { type: boolean, default: true } + lockTimeoutHours: + { type: integer, minimum: 1, maximum: 720, default: 8 } + taskBoundaryType: { $ref: "#/components/schemas/TaskBoundaryType" } + hasAoi: { type: boolean } + taskCount: { type: integer, minimum: 0 } + createdBy: { type: string, format: uuid } + createdByName: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + ProjectListItem: + type: object + required: [id, name, status, taskCount, percentCompleted, createdAt] + properties: + id: { type: integer, format: int64 } + name: { type: string } + status: { $ref: "#/components/schemas/ProjectStatus" } + taskCount: { type: integer, minimum: 0 } + percentCompleted: { type: integer, minimum: 0, maximum: 100 } + createdBy: { type: string, format: uuid } + createdByName: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + ProjectListResponse: + type: object + required: [results, pagination] + properties: + results: + type: array + items: { $ref: "#/components/schemas/ProjectListItem" } + pagination: { $ref: "#/components/schemas/Pagination" } + + ProjectCreateRequest: + type: object + required: [name] + properties: + name: + type: string + minLength: 1 + maxLength: 255 + description: Unique within the workspace (case-insensitive) among non-deleted projects. + instructions: + type: string + maxLength: 10000 + nullable: true + reviewRequired: + type: boolean + default: true + description: If false, tasks transition straight to `completed` on mapper submit. Immutable after activation. + lockTimeoutHours: + type: integer + minimum: 1 + maximum: 720 + default: 8 + aoi: + allOf: + - $ref: "#/components/schemas/AoiUploadRequest" + nullable: true + description: Optional inline AOI (same validation as `POST .../aoi`). + roleAssignments: + type: array + description: | + Optional list of project-level role assignments to seed at + creation. Each entry is upserted into `tasking_project_roles` + in the same transaction as the project insert. + + The creator is auto-added as `lead` on `tasking_project_roles` + and does not need to appear here. + + Each `userId` must be a member of the workspace's TDEI + project group; entries failing this check are rejected with + 422 and no rows are written. + items: { $ref: "#/components/schemas/ProjectRoleAssignment" } + + ProjectRoleAssignment: + type: object + required: [userId, role] + properties: + userId: { type: string, format: uuid } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + ProjectUpdateRequest: + type: object + description: All fields optional. Server enforces per-status mutability. + properties: + name: { type: string, minLength: 1, maxLength: 255 } + instructions: { type: string, maxLength: 10000, nullable: true } + lockTimeoutHours: { type: integer, minimum: 1, maximum: 720 } + reviewRequired: { type: boolean } + + # ---------- Tasks ---------- + + TaskLockSummary: + type: object + required: [userId, lockedAt, expiresAt] + properties: + userId: { type: string, format: uuid } + userName: { type: string, nullable: true } + lockedAt: { type: string, format: date-time } + expiresAt: { type: string, format: date-time } + + LastMapper: + type: object + required: [userId] + properties: + userId: { type: string, format: uuid } + userName: { type: string, nullable: true } + + Task: + type: object + required: + [id, taskNumber, status, geometry, areaSqkm, createdAt, updatedAt] + properties: + id: { type: integer, format: int64 } + taskNumber: { type: integer, minimum: 1 } + status: { $ref: "#/components/schemas/TaskStatus" } + geometry: { $ref: "#/components/schemas/GeoJsonPolygon" } + areaSqkm: { type: number } + lock: + nullable: true + allOf: + - $ref: "#/components/schemas/TaskLockSummary" + lastMapper: + nullable: true + allOf: + - $ref: "#/components/schemas/LastMapper" + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + TaskListResponse: + type: object + required: [tasks, pagination] + properties: + tasks: + type: array + items: { $ref: "#/components/schemas/Task" } + pagination: { $ref: "#/components/schemas/Pagination" } + + ValidateWarning: + type: object + required: [taskIndex, issue] + properties: + taskIndex: + type: integer + description: 0-based index into the submitted `features` array. + issue: + type: string + enum: [polygon_exceeds_grid_size] + areaSqkm: + type: number + + ValidatePreviewResponse: + type: object + required: [valid, warnings, source, featureCollection] + properties: + valid: + type: boolean + description: True when no hard failures. Warnings do not affect this. + warnings: + type: array + items: { $ref: "#/components/schemas/ValidateWarning" } + source: + $ref: "#/components/schemas/GridSource" + description: Always `import` for this response. Pass through unchanged to `/save`. + featureCollection: + $ref: "#/components/schemas/TaskBoundariesFeatureCollection" + + SaveTasksRequest: + type: object + required: [source, featureCollection] + properties: + source: + $ref: "#/components/schemas/GridSource" + description: Sets `tasking_projects.task_boundary_type` for the project. + featureCollection: + $ref: "#/components/schemas/TaskBoundariesFeatureCollection" + + SaveTasksResponse: + type: object + required: [projectId, taskBoundaryType, taskCount, tasks] + properties: + projectId: { type: integer, format: int64 } + taskBoundaryType: { $ref: "#/components/schemas/GridSource" } + taskCount: + type: integer + minimum: 1 + description: Number of tasks created (== `featureCollection.features.length`). + tasks: + type: array + description: All created tasks in the same order as the request's `features[]`. `taskNumber` is sequential starting at 1. + items: { $ref: "#/components/schemas/Task" } + idempotencyKey: + type: string + nullable: true + replayed: + type: boolean + default: false + description: True on idempotent replay (HTTP 200 case). + + # ---------- Submit / Lock ---------- + + Feedback: + type: object + description: | + Feedback payload — persisted to `tasking_feedback`. Used by + `POST /submit` to attach a validator's remap-rejection note, + and reusable by any future endpoint that records free-form + task feedback. + required: [notes] + properties: + reasonCategory: + allOf: + - $ref: "#/components/schemas/FeedbackReason" + nullable: true + description: Required when the feedback drives `to_review → to_remap`; optional otherwise. + notes: + type: string + minLength: 1 + maxLength: 4000 + + SubmitRequest: + type: object + required: [osmChangesetId, done] + properties: + osmChangesetId: + type: integer + format: int64 + minimum: 1 + done: + type: boolean + description: | + `true` — auto-unlocks and transitions state per the table. + `false` — records the changeset, state unchanged, lock + `expires_at` slides forward to `submitted_at + + lock_timeout_hours`. + feedback: + allOf: + - $ref: "#/components/schemas/Feedback" + description: | + Meaningful only in validator context on `to_review`. When + provided here, `feedback.reasonCategory` is required (drives + the `to_remap` transition) and the row is persisted to + `tasking_feedback`. + + ExistingLockSummary: + type: object + required: [taskNumber, taskStatus, lockedAt, expiresAt] + properties: + taskNumber: { type: integer, minimum: 1 } + taskStatus: { $ref: "#/components/schemas/TaskStatus" } + lockedAt: { type: string, format: date-time } + expiresAt: { type: string, format: date-time } + + LockConflictError: + allOf: + - $ref: "#/components/schemas/Error" + - type: object + properties: + existingLock: + $ref: "#/components/schemas/ExistingLockSummary" + + # ---------- Roles ---------- + + UserSummary: + type: object + required: [userId] + properties: + userId: { type: string, format: uuid } + displayName: { type: string, nullable: true } + email: { type: string, nullable: true } + + AssignableRoleType: + type: string + enum: [lead, validator] + description: | + Roles assignable via `PUT /workspaces/{wid}/users/{uid}/role`. + CONTRIBUTOR is implicit (project-group membership grants it + automatically) and cannot be set by this endpoint. + + SetRoleRequest: + type: object + required: [role] + properties: + role: { $ref: "#/components/schemas/AssignableRoleType" } + + WorkspaceUserRoleItem: + type: object + description: | + Privileged-member DTO returned by `GET /workspaces/{wid}/users`. + Mirrors the shipped `WorkspaceUserRoleItem` from + `api/src/users/schemas.py` on the `roles` branch. + required: [id, auth_uid, email, display_name, role] + properties: + id: + type: integer + format: int64 + description: Internal OSM-DB `users.id`. + auth_uid: + type: string + description: TDEI auth user uuid as string (`users.auth_uid`). + email: { type: string } + display_name: { type: string } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + ProjectRoleItem: + type: object + description: | + One row of `tasking_project_roles`, joined with `users` for the + display name. Returned by every `/projects/{pid}/roles[/{uid}]` + endpoint. + required: [user_id, role, updated_at] + properties: + user_id: + type: string + format: uuid + description: Maps to `users.auth_uid`. + user_name: + type: string + nullable: true + description: Mirror of `users.display_name`, joined at read time. + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + updated_at: { type: string, format: date-time } + + ProjectRoleListResponse: + type: object + required: [results] + properties: + results: + type: array + items: { $ref: "#/components/schemas/ProjectRoleItem" } + + ProjectRoleAddRequest: + type: object + description: Body for `POST /projects/{pid}/roles`. + required: [user_id, role] + additionalProperties: false + properties: + user_id: + type: string + format: uuid + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + ProjectRoleUpdateRequest: + type: object + description: Body for `PATCH /projects/{pid}/roles/{user_id}`. + required: [role] + additionalProperties: false + properties: + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + SelfProjectRoleItem: + type: object + required: [project_id, project_name, project_status, role] + properties: + project_id: { type: integer, format: int64 } + project_name: { type: string } + project_status: { $ref: "#/components/schemas/ProjectStatus" } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + SelfProjectRolesResponse: + type: object + required: [workspace_role, projects] + properties: + workspace_role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + projects: + type: array + items: { $ref: "#/components/schemas/SelfProjectRoleItem" } + + # ---------- Audit ---------- + + ActorRef: + type: object + required: [user_id] + properties: + user_id: { type: string, format: uuid } + display_name: { type: string, nullable: true } + + AuditEvent: + type: object + required: [id, event_type, project_id, actor, occurred_at, details] + properties: + id: { type: integer, format: int64 } + event_type: { $ref: "#/components/schemas/AuditEventType" } + project_id: { type: integer, format: int64 } + task_id: + type: integer + format: int64 + nullable: true + task_number: + type: integer + nullable: true + description: Convenience copy from `details` (so list renderers don't peek inside JSONB). + actor: { $ref: "#/components/schemas/ActorRef" } + occurred_at: { type: string, format: date-time } + details: + type: object + additionalProperties: true + project_deleted: + type: boolean + default: false + + AuditEventListResponse: + type: object + required: [results, pagination] + properties: + results: + type: array + items: { $ref: "#/components/schemas/AuditEvent" } + pagination: { $ref: "#/components/schemas/Pagination" } + + # ---------- Stats ---------- + + TaskStatusCounts: + type: object + required: [to_map, to_review, to_remap, completed] + properties: + to_map: { type: integer, minimum: 0 } + to_review: { type: integer, minimum: 0 } + to_remap: { type: integer, minimum: 0 } + completed: { type: integer, minimum: 0 } + + ProjectStats: + type: object + required: + - projectId + - totalTasks + - tasksByStatus + - percentMapped + - percentCompleted + - uniqueMappers + - uniqueValidators + properties: + projectId: { type: integer, format: int64 } + totalTasks: { type: integer, minimum: 0 } + tasksByStatus: { $ref: "#/components/schemas/TaskStatusCounts" } + percentMapped: { type: integer, minimum: 0, maximum: 100 } + percentCompleted: { type: integer, minimum: 0, maximum: 100 } + avgTaskDurationMinutes: + type: number + nullable: true + uniqueMappers: { type: integer, minimum: 0 } + uniqueValidators: { type: integer, minimum: 0 } + + UserStatsTotals: + type: object + required: + - tasksMapped + - tasksValidated + - totalMappingMinutes + - totalValidationMinutes + - totalChangesets + - totalAreaSqkm + properties: + tasksMapped: { type: integer, minimum: 0 } + tasksValidated: { type: integer, minimum: 0 } + totalMappingMinutes: { type: integer, minimum: 0 } + totalValidationMinutes: { type: integer, minimum: 0 } + totalChangesets: { type: integer, minimum: 0 } + totalAreaSqkm: { type: number, minimum: 0 } + + UserStatsByProject: + type: object + required: [projectId, projectName, tasksMapped, tasksValidated] + properties: + projectId: { type: integer, format: int64 } + projectName: { type: string } + tasksMapped: { type: integer, minimum: 0 } + tasksValidated: { type: integer, minimum: 0 } + totalChangesets: { type: integer, minimum: 0 } + totalAreaSqkm: { type: number, minimum: 0 } + + UserStats: + type: object + required: [workspaceId, userId, totals, byProject] + properties: + workspaceId: { type: integer, format: int64 } + userId: { type: string, format: uuid } + totals: { $ref: "#/components/schemas/UserStatsTotals" } + byProject: + type: array + items: { $ref: "#/components/schemas/UserStatsByProject" } + + # ---------- Shared ---------- + + Pagination: + type: object + required: [page, pageSize, total] + properties: + page: { type: integer, minimum: 1 } + pageSize: { type: integer, minimum: 1 } + total: { type: integer, minimum: 0 } + + Error: + type: object + required: [detail] + properties: + detail: + oneOf: + - type: string + - type: array + items: { type: object, additionalProperties: true } + description: FastAPI default error shape — string for raised `HTTPException`, structured list for pydantic validation errors. + + responses: + BadRequest: + description: Malformed JSON or schema-level failure. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Unauthorized: + description: Missing or invalid bearer token. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + ForbiddenLeadRequired: + description: Caller is a workspace contributor but does not hold LEAD. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + WorkspaceNotFound: + description: Workspace does not exist, or is outside the caller's tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + ProjectOrWorkspaceNotFound: + description: Workspace or project does not exist, or is outside the caller's tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + TaskOrProjectOrWorkspaceNotFound: + description: Workspace, project, or task does not exist, or is outside the caller's tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + UnprocessableEntity: + description: Well-formed request that violates a business rule. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } diff --git a/pyproject.toml b/pyproject.toml index b4e4b1f..fbb068c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,22 +9,22 @@ dependencies = [ "asyncpg>=0.30.0", "fastapi>=0.115.6", "pydantic-settings>=2.6.1", - "python-dotenv>=1.0.1", + "python-dotenv>=1.2.2", "sqlalchemy>=2.0.36", "uvicorn>=0.32.1", "python-jose[cryptography]>=3.3.0", "bcrypt==4.0.1", "passlib==1.7.4", "pydantic[email]>=2.5.2", - "pytest>=8.0.0", + "pytest>=9.0.3", "pytest-asyncio>=0.23.5", "httpx>=0.27.0", "pytest-cov>=4.1.0", - "black>=24.1.0", + "black>=26.3.1", "isort>=5.13.0", "pre-commit>=4.0.1", "autoflake>=2.3.1", - "python-multipart>=0.0.20", + "python-multipart>=0.0.31", "greenlet>=3.1.1", "geoalchemy2>=0.18.1", "jsonschema>=4.25.1", @@ -33,13 +33,34 @@ dependencies = [ "requests-cache>=1.2.1", "pyjwt", "sqlmodel>=0.0.8", - "cachetools" + "cachetools", + "shapely>=2.1.2", +] + +[project.optional-dependencies] +# Integration tests boot a real PostGIS database via testcontainers (needs a +# running Docker daemon). Install with `uv sync --extra integration` or +# `--all-extras`, then run them with `pytest -m integration`. +integration = [ + "testcontainers[postgres]>=4.0.0", ] [tool.pytest.ini_options] addopts = "-v --cov=api --cov-report=term-missing" testpaths = ["tests"] asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +markers = [ + "integration: tests that require a live PostGIS database (Docker + testcontainers)", +] +filterwarnings = [ + # SQLModel deprecates `session.execute()` in favour of `session.exec()`, + # but many repository queries are raw `text()` SQL where `execute()` is the + # correct call. Silence this one nudge rather than rewrite ~85 call sites; + # every other warning still surfaces. (?s) lets `.*` span the multi-line + # warning body. + "ignore:(?s).*You probably want to use .session.exec:DeprecationWarning", +] [tool.black] line-length = 88 @@ -54,3 +75,7 @@ force_grid_wrap = 0 use_parentheses = true ensure_newline_before_comments = true line_length = 88 +# Declare our packages explicitly. Without this, the top-level alembic/ dir +# makes isort misclassify the installed `alembic` package as first-party. +known_first_party = ["api", "tests"] +known_third_party = ["alembic"] diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..76c6179 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Run the CI checks locally, mirroring .github/workflows/ci.yml. +# +# Runs every check by default; a failure in one step does not stop the others, +# and the script exits non-zero if any step failed. +# +# Flags: +# --fail-fast stop at the first failing step instead of running all +# --integration also run the integration suite (`pytest -m integration`), +# which boots a real PostGIS database via testcontainers and +# therefore needs a running Docker daemon +set -uo pipefail + +cd "$(dirname "$0")/.." + +fail_fast=0 +integration=0 +for arg in "$@"; do + case "${arg}" in + --fail-fast) fail_fast=1 ;; + --integration) integration=1 ;; + *) echo "Unknown option: ${arg}" >&2; exit 2 ;; + esac +done + +failed=() + +run() { + local name="$1"; shift + echo "" + echo "==> ${name}" + if "$@"; then + echo "--- ${name}: OK" + else + echo "--- ${name}: FAILED" + failed+=("${name}") + [[ "${fail_fast}" == 1 ]] && summary_and_exit + fi +} + +summary_and_exit() { + echo "" + if [[ ${#failed[@]} -eq 0 ]]; then + echo "All CI checks passed." + exit 0 + fi + echo "CI checks FAILED: ${failed[*]}" + exit 1 +} + +run "Install the project" uv sync --all-extras +run "Check imports with isort" uv run isort --check-only --diff . +run "Check code formatting (black)" uv run black --check . +run "Type-check with pyright" uvx pyright --pythonpath .venv/bin/python api tests +run "Run tests" uv run pytest tests + +if [[ "${integration}" == 1 ]]; then + run "Run integration tests" uv run pytest tests -m integration +fi + +summary_and_exit diff --git a/scripts/fix-lint.sh b/scripts/fix-lint.sh new file mode 100755 index 0000000..f8319ef --- /dev/null +++ b/scripts/fix-lint.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Auto-fix the formatting issues that scripts/ci.sh checks for. +# +# Runs isort then black in write mode over the repo (the same tools/config CI +# enforces, minus the `--check`). Safe to run repeatedly; it only rewrites files +# that are not already compliant. Does not touch pyright — type errors are not +# auto-fixable. +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==> Sorting imports (isort)" +uv run isort . + +echo "" +echo "==> Formatting code (black)" +uv run black . + +echo "" +echo "Done. Review the changes with 'git diff', then re-run ./scripts/ci.sh." diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..cd90ce5 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,94 @@ +# Tests + +Two layers of tests, both fast and dependency-free (no Postgres, PostGIS, +Docker, or network): + +| Layer | Location | What it exercises | +|-------|----------|-------------------| +| **Unit** | `tests/unit/` | Pure logic and individual classes (e.g. `UserInfo` permission rules, a repository in isolation). | +| **Integration** | `tests/integration/` | Real HTTP requests through the real FastAPI app: routing, auth wiring, repositories, schemas, and serialization. | + +## The mocking boundary: the "data fetcher", not the repository + +Integration tests run the **real** routes and repositories. The only things +swapped out are: + +1. **The database sessions** (`get_task_session`, `get_osm_session`) — replaced + with a `FakeSession` that returns pre-programmed rows instead of running SQL. + This is the "data fetcher" boundary: everything *above* the `AsyncSession` + (repositories, models, routes, Pydantic serialization) runs for real. +2. **`validate_token`** — replaced with a real `UserInfo` built by the factories, + skipping JWT decoding and the TDEI network call. The permission logic + (`isWorkspaceLead`, `isWorkspaceContributor`, …) is still real. +3. **The upstream OSM client** (`api.main._osm_client`, proxy tests only) — + replaced with a streamable mock transport. + +Mocking at the session level (rather than mocking whole repositories) means +the SQLModel object construction, repository logic, route guards, and response +serialization are all genuinely tested. + +## Writing an integration test + +```python +async def test_get_workspace_by_id(client, login, task_session): + login() # authenticate + task_session.queue(fakes.rows(factories.make_workspace(id=7))) + response = await client.get("/api/v1/workspaces/7") + assert response.status_code == 200 +``` + +### Fixtures (`conftest.py`) + +- `client` — async HTTP client bound to the app over an in-process ASGI transport. +- `login(user_info=None)` — set the authenticated user. Call with a + `factories.make_user_info(...)` to control roles/permissions. +- `task_session` / `osm_session` — the two `FakeSession`s. Queue results on them. + +### The call-order contract + +Because the mock is at the session level, **queue results in the order the +repository issues queries**. Each result builder models one DB round-trip: + +| Builder | Models | Returned by | +|---------|--------|-------------| +| `fakes.rows(*entities)` | a SELECT | `scalars().all()`, `scalar_one_or_none()`, `all()` | +| `fakes.empty()` | a SELECT with no rows | drives NotFound paths | +| `fakes.affected(n)` | an UPDATE/DELETE | `result.rowcount` | +| `fakes.mappings(*dicts)` | a raw-SQL result | `result.mappings()` | +| `fakes.scalar(value)` | `session.scalar(...)` | e.g. an `EXISTS` check | + +Routes that touch both DBs (e.g. teams, workspace create/delete) queue results +on **both** `task_session` and `osm_session`. `SET search_path` statements are +recognized and do not consume a queued result. `commit`/`add`/`rollback` calls +are recorded on the session (`session.commits`, `session.added`, …) for assertions. + +The repository docstrings and the existing tests show the query sequence for +each route. + +## Running + +```bash +uv run pytest # full suite with coverage (see pyproject.toml) +uv run pytest --no-cov -q # quick, no coverage +uv run pytest tests/unit # one layer +uv run pytest -k workspaces # by keyword +``` + +## Layout + +``` +tests/ + conftest.py # fixtures: client, login, task_session, osm_session + support/ + fakes.py # FakeSession + FakeResult + result builders + factories.py # make_user_info / make_workspace / make_user / make_team + http.py # streamable mock transport for the OSM proxy + unit/ + test_user_info.py + test_workspace_repository.py + integration/ + test_health.py + test_workspaces.py + test_teams.py + test_proxy.py +``` diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c59f02d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,216 @@ +"""Shared pytest fixtures. + +Integration tests drive the *real* FastAPI app through an ASGI HTTP client, +overriding only three dependencies: + +* ``get_task_session`` / ``get_osm_session`` -> :class:`FakeSession` (the + "data fetcher" boundary; queue simulated rows per test). +* ``validate_token`` -> a real ``UserInfo`` built by the factories (skips + JWT decoding and the TDEI network call, but the permission logic is real). + +Everything above that boundary -- routes, repositories, schemas, Pydantic +serialization -- runs unmodified. +""" + +from collections.abc import Iterator +from typing import Callable +from uuid import UUID, uuid4 + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from api.core.database import get_osm_session, get_task_session +from api.core.security import validate_token +from api.main import app as fastapi_app +from tests.support import factories +from tests.support.fakes import FakeSession + +# Shared with the testcontainers-backed integration suite +# (``tests/integration/conftest.py``), which imports these to seed a real +# workspaces row and to synthesize ``UserInfo`` principals. Kept here so both +# the FakeSession-based and testcontainers-based test layers reference one +# canonical workspace/project-group identity. +SEED_WORKSPACE_ID = 1899 +SEED_PROJECT_GROUP_ID = UUID("00000000-0000-0000-0000-000000001899") + + +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 task_session() -> FakeSession: + """Fake session backing the tasking-manager / workspaces DB.""" + return FakeSession() + + +@pytest.fixture +def osm_session() -> FakeSession: + """Fake session backing the OSM DB.""" + return FakeSession() + + +@pytest.fixture +def app(task_session, osm_session): + """The real app with its DB sessions overridden by fakes.""" + fastapi_app.dependency_overrides[get_task_session] = lambda: task_session + fastapi_app.dependency_overrides[get_osm_session] = lambda: osm_session + yield fastapi_app + fastapi_app.dependency_overrides.clear() + + +@pytest.fixture +def login(app): + """Authenticate the request as a given user. + + Call with a ``UserInfo`` (see ``factories.make_user_info``) to set the + authenticated principal; called with no args it logs in a default user. + Returns the ``UserInfo`` in effect. + """ + + def _login(user_info=None): + user_info = user_info or factories.make_user_info() + app.dependency_overrides[validate_token] = lambda: user_info + return user_info + + return _login + + +@pytest_asyncio.fixture +async def client(app): + """Async HTTP client bound to the app over an in-process ASGI transport.""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as c: + yield c + + +@pytest_asyncio.fixture +async def error_client(app): + """Like ``client`` but turns unhandled exceptions into 500 responses. + + By default httpx's ASGI transport re-raises app exceptions; this fixture + lets tests assert on the 500 the server would actually return in prod. + """ + transport = ASGITransport(app=app, raise_app_exceptions=False) + async with AsyncClient(transport=transport, base_url="http://testserver") as c: + yield c + + +# --------------------------------------------------------------------------- +# Sync auth fixtures for the FakeSession/repository-level unit suite. +# The testcontainers integration layer overrides these async in +# ``tests/integration/conftest.py`` to seed real rows; unit tests use the +# lightweight versions below. +# --------------------------------------------------------------------------- + + +@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 + + +@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..d99bfc2 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,646 @@ +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 ( # pyright: ignore[reportMissingImports] + 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 # pyright: ignore[reportMissingImports] + 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. +# --------------------------------------------------------------------------- + + +async def _bootstrap_osm_database(osm_url: str) -> None: + """Prepare the provisioned OSM database for the osm alembic tree. + + Two things the tree assumes but does not create itself: + + * **PostGIS** — the tasking_* migration requires the extension and raises + if it is missing. The container image ships PostGIS but the extension + must be enabled per-database, and the freshly ``CREATE DATABASE``'d + ``osm_test`` doesn't inherit it. + * **users** — owned by the OSM Rails website, not these alembic trees: + migration ``9221408912dd`` adds a unique constraint to it and the + ``tasking_*`` foreign keys reference ``users.id`` / ``users.auth_uid``, + all assuming it already exists. We stand up a stripped-down version + (just the columns the migrations and ``_insert_user_row`` touch). + """ + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + engine = create_async_engine(osm_url, isolation_level="AUTOCOMMIT") + try: + async with engine.connect() as conn: + await conn.execute(text("CREATE EXTENSION IF NOT EXISTS postgis")) + await conn.execute( + text( + # Columns mirror the subset of the OSM Rails `users` table + # that the tasking code reads or writes: the FK targets + # (id, auth_uid), the seed/display columns, and every column + # the TDEI auto-provisioning INSERT populates + # (see TaskingProjectRepository._provision_users_from_tdei). + "CREATE TABLE IF NOT EXISTS users (" + " id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY," + " auth_uid varchar," + " email varchar," + " display_name varchar," + " auth_provider varchar," + " status varchar," + " pass_crypt varchar," + " data_public boolean," + " email_valid boolean," + " terms_seen boolean," + " creation_time timestamp," + " terms_agreed timestamp," + " tou_agreed timestamp" + ")" + ) + ) + finally: + await engine.dispose() + + +@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. + """ + import asyncio + + task_url, osm_url = _pg_urls + + # PostGIS + the OSM-website-owned `users` table must exist before the osm + # alembic tree upgrades (its migrations require the extension and + # constrain `users.auth_uid`). + asyncio.run(_bootstrap_osm_database(osm_url)) + + 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. +# --------------------------------------------------------------------------- + + +async def _insert_workspace_row(task_url: str) -> None: + """Insert the seed workspace row into the TASK database.""" + from uuid import uuid4 + + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + engine = create_async_engine(task_url, future=True) + try: + 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", + }, + ) + finally: + await engine.dispose() + + +@pytest.fixture(scope="session") +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. Kept synchronous (driving the async insert + via ``asyncio.run``) because a session-scoped *async* fixture would clash + with pytest-asyncio's function-scoped event loop. + """ + import asyncio + + task_url, _osm_url = _migrated_db + asyncio.run(_insert_workspace_row(task_url)) + 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 +def app(request, task_session, osm_session): + """App fixture for everything under ``tests/integration/``. + + This directory hosts two kinds of test that share one conftest: + + * **Container-backed** tests (marked ``integration``) run against a real + PostGIS database; their DB sessions are the real engines wired by the + autouse ``_per_test_db_sessions`` fixture, so we hand back the app + untouched here. + * **FakeSession-backed** tests (unmarked — the CLAUDE.md data-fetcher + model) run the real routes/repositories but fake the ``AsyncSession``. + For those we wire the fakes in exactly like the unit-suite ``app`` + fixture this shadows. + + Keying on the ``integration`` marker keeps the container machinery from + leaking onto the FakeSession tests (which need no Docker). + """ + from api.core.database import get_osm_session, get_task_session + from api.main import app as fastapi_app + + if request.node.get_closest_marker("integration"): + yield fastapi_app + return + + fastapi_app.dependency_overrides[get_task_session] = lambda: task_session + fastapi_app.dependency_overrides[get_osm_session] = lambda: osm_session + yield fastapi_app + fastapi_app.dependency_overrides.clear() + + +@pytest.fixture(autouse=True) +async def _per_test_db_sessions(request): + # Only container-backed (``integration``-marked) tests need real DB + # sessions; for FakeSession tests this fixture is a no-op so they never + # pull in ``_pg_urls`` (which would boot / require the testcontainer). + if not request.node.get_closest_marker("integration"): + yield + return + + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + 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 = request.getfixturevalue("_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 = async_sessionmaker( + bind=task_engine, class_=AsyncSession, expire_on_commit=False + ) + osm_factory = async_sessionmaker( + bind=osm_engine, class_=AsyncSession, expire_on_commit=False + ) + + async def _get_task(): + async with task_factory() as session: + try: + yield session + finally: + await session.close() + + async def _get_osm(): + async with osm_factory() as session: + try: + yield session + finally: + await session.close() + + app.dependency_overrides[get_task_session] = _get_task + app.dependency_overrides[get_osm_session] = _get_osm + + try: + yield + finally: + app.dependency_overrides.pop(get_task_session, None) + app.dependency_overrides.pop(get_osm_session, None) + await task_engine.dispose() + await osm_engine.dispose() + + +# Role fixtures — each inserts a matching `users` row and overrides +# `validate_token`. These shadow the unit-suite counterparts in +# tests/conftest.py for every test under tests/integration/. + + +@pytest.fixture +async def as_lead(_pg_urls, seeded_workspace_id): + """LEAD user persisted in users table + overridden in validate_token.""" + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user + + _, osm_url = _pg_urls + user = _make_user( + role="lead", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + await _insert_user_row(osm_url, user) + _override_token(user) + yield user + _clear_token() + + +@pytest.fixture +async def as_contributor(_pg_urls, seeded_workspace_id): + """CONTRIBUTOR user persisted in users table + overridden in validate_token.""" + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user + + _, osm_url = _pg_urls + user = _make_user( + role="contributor", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + await _insert_user_row(osm_url, user) + _override_token(user) + yield user + _clear_token() + + +@pytest.fixture +async def as_validator(_pg_urls, seeded_workspace_id): + """VALIDATOR user persisted in users table + overridden in validate_token.""" + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user + + _, osm_url = _pg_urls + user = _make_user( + role="validator", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + await _insert_user_row(osm_url, user) + _override_token(user) + yield user + _clear_token() + + +@pytest.fixture +async def as_outsider(_pg_urls, seeded_workspace_id): + """Outsider — no PG membership. + + Inserted into users so role tests don't break, but their workspace + role list is empty so the tenancy gate still 404s. + """ + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user + + _, osm_url = _pg_urls + user = _make_user( + role=None, + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + await _insert_user_row(osm_url, user) + _override_token(user) + yield user + _clear_token() + + +# --------------------------------------------------------------------------- +# Extra-user factory — for tests that need additional users beyond the +# single "active" caller. Inserts the matching `users` row so FKs hold, +# but does NOT swap `validate_token`. Tests that want to act-as the new +# user should pair this with the shared `override_user` fixture. +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def extra_user_factory(_pg_urls, seeded_workspace_id): + """Factory: ``await make_user("contributor")`` returns a fresh UserInfo + persisted in the OSM `users` table. + + The caller's `validate_token` override is left untouched — pair with + the `override_user` fixture from ``tests/conftest.py`` to act as the + returned user. + """ + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user + + _, osm_url = _pg_urls + + async def _make(role: str | None): + user = _make_user( + role=role, + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + await _insert_user_row(osm_url, user) + return user + + return _make + + +# --------------------------------------------------------------------------- +# TDEI stub — no real TDEI backend is available in integration. The default +# stub returns an empty member list, so any role_assignments[].user_id that +# is not already in `users` stays missing and surfaces as 422. +# +# Tests that exercise the auto-provisioning path append to +# `tdei_project_group_users` to simulate TDEI returning specific members. +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def tdei_project_group_users(monkeypatch): + """Stub `fetch_project_group_users` to return a controllable list.""" + members: list = [] + + async def _fake_fetch(project_group_id: str, bearer_token: str): + return list(members) + + import api.core.security + import api.src.tasking.projects.repository as proj_repo + + monkeypatch.setattr(api.core.security, "fetch_project_group_users", _fake_fetch) + # The repository imports the symbol locally inside the helper, but be + # belt-and-braces in case that ever changes: + if hasattr(proj_repo, "fetch_project_group_users"): + monkeypatch.setattr(proj_repo, "fetch_project_group_users", _fake_fetch) + return members + + +# --------------------------------------------------------------------------- +# Per-test cleanup of tasking_* tables (opt-in). +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def reset_tasking(_pg_url: str) -> AsyncIterator[None]: + """Truncate tasking_* tables between tests that opt in. + + Workflow tests inside a class share state by design (class-scoped + project id passed between steps), so this fixture is *opt-in* — + only request it from tests that need a clean slate. + """ + yield + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + engine = create_async_engine(_pg_url, future=True) + async with engine.begin() as conn: + await conn.execute( + text( + "TRUNCATE TABLE " + " tasking_audit_events, tasking_feedback, " + " tasking_task_save_idempotency, tasking_locks, " + " tasking_tasks, tasking_project_roles, tasking_projects " + "RESTART IDENTITY CASCADE" + ) + ) + await engine.dispose() diff --git a/tests/integration/test_audit_flow.py b/tests/integration/test_audit_flow.py new file mode 100644 index 0000000..26a6fdb --- /dev/null +++ b/tests/integration/test_audit_flow.py @@ -0,0 +1,252 @@ +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, contributor): + """Create → AOI → save tasks → activate; returns the project id. + + Caller must already be acting as a LEAD (every step here is LEAD-only). + ``contributor`` is a UserInfo already persisted in the OSM ``users`` table + (via ``extra_user_factory``); allocating it satisfies the activation + pre-check that a project have ≥1 contributor or validator. + """ + r = await client.post( + API.format(wid=workspace_id), + json={ + "name": f"audit-{id(client)}", + "review_required": False, + "role_assignments": [ + {"user_id": str(contributor.user_uuid), "role": "contributor"}, + ], + }, + ) + 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_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, extra_user_factory + ): + """Project create → AOI upload → tasks → activate all appear in audit.""" + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) + + 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, extra_user_factory + ): + """`event_type` query narrows results to one kind.""" + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) + + 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, extra_user_factory + ): + """`actor_user_id` filters to events emitted by that user only.""" + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) + 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, extra_user_factory + ): + """Page size of 1 still returns one row; total reflects the whole set.""" + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) + 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, + seeded_workspace_id, + extra_user_factory, + override_user, + ): + """Lock/unlock on a task surface in /tasks/{n}/audit.""" + # `as_lead` opens the project (lead-only) with a contributor allocated, + # then we switch to that contributor to lock + unlock so we generate + # task events. + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) + override_user(contributor) + + # 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 + # The endpoint scopes by the `task_id` column; the task number is + # echoed in `details` as `taskNumber` (camelCase, as emitted by the + # task repository). Every row should reference task 1. + for row in body["results"]: + assert row["details"].get("taskNumber") == 1 + + async def test_unknown_task_404( + self, client, as_lead, seeded_workspace_id, extra_user_factory + ): + """A bogus task number on a real project returns 404.""" + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) + 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, extra_user_factory + ): + """A soft-deleted project's audit returns 404 unless `include_deleted=true`.""" + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) + + # Project must be closed before delete. + r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/close") + # Some tasks may still be open; tolerate either path. The audit + # endpoint behaviour we care about only needs deleted_at to be + # set, which the delete call will do regardless of status. + r = await client.delete(f"{API.format(wid=seeded_workspace_id)}/{pid}") + if r.status_code != 204: + pytest.skip(f"Could not soft-delete project: {r.status_code} {r.text}") + + # Default = hidden. + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/audit") + assert r.status_code == 404 + + # Explicit opt-in = visible. + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", + params={"include_deleted": True}, + ) + assert r.status_code == 200, r.text + assert r.json()["pagination"]["total"] >= 1 diff --git a/tests/integration/test_health.py b/tests/integration/test_health.py new file mode 100644 index 0000000..f79475e --- /dev/null +++ b/tests/integration/test_health.py @@ -0,0 +1,13 @@ +"""Integration smoke tests for unauthenticated endpoints.""" + + +async def test_health_check(client): + response = await client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +async def test_root_redirects_to_docs(client): + response = await client.get("/", follow_redirects=False) + assert response.status_code == 307 + assert response.headers["location"] == "/docs" diff --git a/tests/integration/test_osm.py b/tests/integration/test_osm.py new file mode 100644 index 0000000..b4862d8 --- /dev/null +++ b/tests/integration/test_osm.py @@ -0,0 +1,117 @@ +"""Integration tests for the /workspaces OSM routes (api/src/osm/routes.py). + +Each test drives a real HTTP request through the real route + repository, +queueing simulated rows on the fake sessions. + +Focus: PUT /{id}/changesets/{cid}/resolve is gated to workspace leads and +validators (403 otherwise). The gate is enforced both in the route and, +defensively, inside ``OSMRepository.resolveChangeset`` -- so a contributor is +rejected before any DB work, and the repository would reject a bad call site +even if the route check were bypassed. +""" + +import pytest + +from api.src.users.schemas import WorkspaceUserRoleType +from tests.support import factories, fakes + +API = "/api/v1/workspaces" + + +def _resolve_url(workspace_id=1, changeset_id=99): + return f"{API}/{workspace_id}/changesets/{changeset_id}/resolve" + + +def _user_with_role(role, workspace_id=1): + return factories.make_user_info(osm_workspace_roles={workspace_id: [role]}) + + +# === PUT /{id}/changesets/{cid}/resolve ==================================== + + +async def test_resolve_changeset_validator_204( + client, login, task_session, osm_session +): + login(_user_with_role(WorkspaceUserRoleType.VALIDATOR)) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) # getById + osm_session.queue(fakes.affected(1), fakes.affected(1)) # DELETE, INSERT + + response = await client.put(_resolve_url()) + + assert response.status_code == 204 + assert osm_session.commits == 1 # resolveChangeset ran to completion + + +async def test_resolve_changeset_lead_204(client, login, task_session, osm_session): + login(_user_with_role(WorkspaceUserRoleType.LEAD)) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.affected(1), fakes.affected(1)) + + response = await client.put(_resolve_url()) + + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_resolve_changeset_poc_204(client, login, task_session, osm_session): + # POC on the owning project group satisfies isWorkspaceLead. + login( + factories.make_user_info( + accessible_workspace_ids={factories.DEFAULT_PG_ID: [1]}, + poc_group_ids=(factories.DEFAULT_PG_ID,), + ) + ) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.affected(1), fakes.affected(1)) + + response = await client.put(_resolve_url()) + + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_resolve_changeset_contributor_403( + client, login, task_session, osm_session +): + # A contributor (PG association, no validator/lead grant) is rejected + # before any DB work -- neither session should be touched. + login( + factories.make_user_info( + accessible_workspace_ids={factories.DEFAULT_PG_ID: [1]} + ) + ) + + response = await client.put(_resolve_url()) + + assert response.status_code == 403 + assert osm_session.commits == 0 + + +async def test_resolve_changeset_no_access_403(client, login): + # A user with no association to the workspace is likewise forbidden. + login() + + response = await client.put(_resolve_url()) + + assert response.status_code == 403 + + +async def test_resolve_changeset_validator_of_other_workspace_403(client, login): + # Validator rights on workspace 2 do not authorize resolving on workspace 1. + login(_user_with_role(WorkspaceUserRoleType.VALIDATOR, workspace_id=2)) + + response = await client.put(_resolve_url(workspace_id=1)) + + assert response.status_code == 403 + + +async def test_resolve_changeset_unexpected_error_500( + error_client, login, task_session, osm_session +): + login(_user_with_role(WorkspaceUserRoleType.VALIDATOR)) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.raises(RuntimeError("db"))) # DELETE blows up + + response = await error_client.put(_resolve_url()) + + assert response.status_code == 500 diff --git a/tests/integration/test_projects_flow.py b/tests/integration/test_projects_flow.py new file mode 100644 index 0000000..088c614 --- /dev/null +++ b/tests/integration/test_projects_flow.py @@ -0,0 +1,805 @@ +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 + + +class TestProjectNameValidation: + async def test_validate_name_false_then_true( + self, client, as_lead, seeded_workspace_id + ): + """validate-name reports false before create and true after create for same workspace.""" + path = f"{API.format(wid=seeded_workspace_id)}/validate-name" + + r = await client.get(path, params={"name": "name-check"}) + assert r.status_code == 200, r.text + assert r.json() == {"exists": False} + + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "name-check"}, + ) + assert r.status_code == 201, r.text + + r = await client.get(path, params={"name": "name-check"}) + assert r.status_code == 200, r.text + assert r.json() == {"exists": True} + + async def test_validate_name_outsider_404( + self, client, as_outsider, seeded_workspace_id + ): + """Outsider receives 404 from tenancy gate on validate-name.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "anything"}, + ) + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Workflow 3b — error mapping (constraint violations → precise HTTP status). +# --------------------------------------------------------------------------- + + +class TestProjectCreateErrors: + async def test_role_assignment_with_unknown_user_returns_422( + self, client, as_lead, seeded_workspace_id + ): + """A uuid that TDEI does not list as a PG member → 422 + missing list.""" + bogus = "ffffffff-ffff-ffff-ffff-ffffffffffff" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={ + "name": "role-fk-error", + "role_assignments": [{"user_id": bogus, "role": "contributor"}], + }, + ) + assert r.status_code == 422, r.text + body = r.json() + # FastAPI nests structured `detail` payloads under the `detail` key. + assert "missing_user_ids" in body["detail"] + assert bogus in body["detail"]["missing_user_ids"] + assert "project group" in body["detail"]["message"].lower() + + async def test_role_assignment_auto_provisions_from_tdei( + self, + client, + as_lead, + seeded_workspace_id, + tdei_project_group_users, + ): + """A uuid that's not in `users` but IS a TDEI PG member is auto-provisioned + the project is created.""" + from api.core.security import TdeiProjectGroupUser + + new_user_uuid = "1abfdb85-54c0-449b-965c-0abfd835d6fa" + tdei_project_group_users.append( + TdeiProjectGroupUser( + auth_uid=new_user_uuid, + email=f"{new_user_uuid}@test.local", + display_name="Auto Provisioned", + ) + ) + + r = await client.post( + API.format(wid=seeded_workspace_id), + json={ + "name": "role-auto-provision", + "role_assignments": [ + {"user_id": new_user_uuid, "role": "validator"}, + ], + }, + ) + assert r.status_code == 201, r.text + pid = r.json()["id"] + + # Confirm the role assignment landed. + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/{new_user_uuid}" + ) + assert r.status_code == 200, r.text + assert r.json()["role"] == "validator" + + async def test_duplicate_project_name_returns_409_with_specific_message( + self, client, as_lead, seeded_workspace_id + ): + """A duplicate name surfaces as 409 with the name-conflict message — NOT the generic constraint hint.""" + name = "duplicate-name-test" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": name}, + ) + assert r.status_code == 201, r.text + + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": name}, + ) + assert r.status_code == 409, r.text + assert "already exists" in r.json()["detail"].lower() + + +# --------------------------------------------------------------------------- +# Workflow 4 — AOI delete / replace clears tasks. +# --------------------------------------------------------------------------- + + +class TestAoiReplaceSemantics: + async def test_aoi_replace_resets_boundary_type( + self, client, as_lead, seeded_workspace_id + ): + """Replacing the AOI clears task_boundary_type (per spec — geometry no longer matches).""" + # Create + first AOI. + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "replace-aoi"}, + ) + pid = r.json()["id"] + r1 = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=SQUARE_POLY, + ) + assert r1.status_code == 200 + + # Replace AOI with a different one. + r2 = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=SQUARE_MULTI, + ) + assert r2.status_code == 200 + assert r2.json()["geometry"]["coordinates"] == SQUARE_MULTI["coordinates"] + + # Boundary type should have been cleared (per spec). + proj = (await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}")).json() + assert proj["task_boundary_type"] is None + + async def test_aoi_delete_round_trip(self, client, as_lead, seeded_workspace_id): + """DELETE /aoi removes the AOI; subsequent GET returns 404.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "delete-aoi"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=SQUARE_POLY, + ) + + r = await client.delete(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") + assert r.status_code == 204 + + # Subsequent GET 404s. + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Workflow 5 — project role management +# - LEAD can list/add/update/remove role assignments +# - workspace-LEAD passes the manage-roles gate even without a project row +# - contributor cannot manage roles (403) +# - 422 mapping for unknown user_id, duplicate (409), missing assignment (404) +# - last-LEAD guard blocks the demote / delete that would orphan the project +# --------------------------------------------------------------------------- + + +class TestProjectRoles: + async def test_list_includes_creator_auto_lead( + self, client, as_lead, seeded_workspace_id + ): + """Project creator is auto-seeded as LEAD and appears in GET /roles.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-list-1"}, + ) + pid = r.json()["id"] + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/roles") + assert r.status_code == 200, r.text + rows = r.json()["results"] + assert len(rows) == 1 + assert rows[0]["role"] == "lead" + assert rows[0]["user_id"] == str(as_lead.user_uuid) + + async def test_add_role_round_trip( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """POST /roles inserts a row; GET reflects it.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-add"}, + ) + pid = r.json()["id"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["user_id"] == str(contrib.user_uuid) + assert body["role"] == "contributor" + + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/roles") + ids = {row["user_id"] for row in r.json()["results"]} + assert str(contrib.user_uuid) in ids + + async def test_add_duplicate_returns_409( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """Re-adding the same user returns 409 with an actionable hint.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-dup"}, + ) + pid = r.json()["id"] + + path = f"{API.format(wid=seeded_workspace_id)}/{pid}/roles" + await client.post( + path, + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + r2 = await client.post( + path, + json={"user_id": str(contrib.user_uuid), "role": "validator"}, + ) + assert r2.status_code == 409, r2.text + assert "patch" in r2.json()["detail"].lower() + + async def test_add_unknown_user_returns_422( + self, client, as_lead, seeded_workspace_id + ): + """An unknown user_id surfaces as 422 + missing_user_ids list.""" + bogus = "ffffffff-ffff-ffff-ffff-ffffffffffff" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-unknown"}, + ) + pid = r.json()["id"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": bogus, "role": "contributor"}, + ) + assert r.status_code == 422, r.text + assert bogus in r.json()["detail"]["missing_user_ids"] + + async def test_update_role_promotes_contributor_to_validator( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """PATCH /roles/{uid} changes the stored role.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-patch"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{contrib.user_uuid}", + json={"role": "validator"}, + ) + assert r.status_code == 200, r.text + assert r.json()["role"] == "validator" + + async def test_update_unknown_user_returns_404( + self, client, as_lead, seeded_workspace_id + ): + """PATCH on a user without a role row returns 404.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-patch-404"}, + ) + pid = r.json()["id"] + absent = "deadbeef-dead-dead-dead-deadbeefdead" + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/{absent}", + json={"role": "validator"}, + ) + assert r.status_code == 404 + + async def test_remove_role_round_trip( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """DELETE /roles/{uid} removes the row; subsequent PATCH 404s.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-delete"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" f"{contrib.user_uuid}" + ) + assert r.status_code == 204 + + # Subsequent PATCH is a 404 — row is gone. + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{contrib.user_uuid}", + json={"role": "validator"}, + ) + assert r.status_code == 404 + + async def test_last_lead_demote_blocked(self, client, as_lead, seeded_workspace_id): + """Cannot demote the only LEAD — projects must always have one.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-last-lead-demote"}, + ) + pid = r.json()["id"] + + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{as_lead.user_uuid}", + json={"role": "contributor"}, + ) + assert r.status_code == 422, r.text + assert "last lead" in r.json()["detail"].lower() + + async def test_last_lead_delete_blocked(self, client, as_lead, seeded_workspace_id): + """Cannot delete the only LEAD — would orphan the project.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-last-lead-delete"}, + ) + pid = r.json()["id"] + + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{as_lead.user_uuid}", + ) + assert r.status_code == 422 + assert "last lead" in r.json()["detail"].lower() + + async def test_demote_lead_works_when_two_leads_exist( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """With two LEADs, demoting one is allowed.""" + lead2 = await extra_user_factory("lead") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-two-leads"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(lead2.user_uuid), "role": "lead"}, + ) + + # Now demote the second lead — first lead is still there. + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" f"{lead2.user_uuid}", + json={"role": "contributor"}, + ) + assert r.status_code == 200, r.text + assert r.json()["role"] == "contributor" + + async def test_get_single_role( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """GET /roles/{uid} returns just that user's row.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-get-single"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" f"{contrib.user_uuid}" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["user_id"] == str(contrib.user_uuid) + assert body["role"] == "contributor" + + async def test_get_single_role_404_when_absent( + self, client, as_lead, seeded_workspace_id + ): + """GET /roles/{uid} 404s when the user has no row on the project.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-get-404"}, + ) + pid = r.json()["id"] + absent = "feedf00d-feed-feed-feed-feedfeedfeed" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/{absent}" + ) + assert r.status_code == 404 + + async def test_put_upsert_inserts_with_201( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """PUT on a fresh user creates the row and returns 201.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-put-create"}, + ) + pid = r.json()["id"] + + r = await client.put( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{contrib.user_uuid}", + json={"role": "validator"}, + ) + assert r.status_code == 201, r.text + assert r.json()["role"] == "validator" + + async def test_put_upsert_updates_with_200( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """PUT on an existing user updates the role and returns 200.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-put-update"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + + r = await client.put( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{contrib.user_uuid}", + json={"role": "validator"}, + ) + assert r.status_code == 200, r.text + assert r.json()["role"] == "validator" + + async def test_put_unknown_user_returns_422( + self, client, as_lead, seeded_workspace_id + ): + """PUT for a uuid with no `users` row returns 422 + missing list.""" + bogus = "ffffffff-ffff-ffff-ffff-ffffffffffff" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-put-unknown"}, + ) + pid = r.json()["id"] + + r = await client.put( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/{bogus}", + json={"role": "contributor"}, + ) + assert r.status_code == 422, r.text + assert bogus in r.json()["detail"]["missing_user_ids"] + + async def test_put_last_lead_demote_blocked( + self, client, as_lead, seeded_workspace_id + ): + """PUT cannot demote the only LEAD any more than PATCH can.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-put-last-lead"}, + ) + pid = r.json()["id"] + + r = await client.put( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{as_lead.user_uuid}", + json={"role": "contributor"}, + ) + assert r.status_code == 422 + assert "last lead" in r.json()["detail"].lower() + + async def test_self_project_roles_falls_back_to_workspace_role( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + ): + """`/me/.../roles` returns override where present, workspace role elsewhere.""" + # Project A with no explicit override — caller will get workspace role. + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "self-roles-A"}, + ) + pid_a = r.json()["id"] + # Project B where the contributor gets an explicit validator role. + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "self-roles-B"}, + ) + pid_b = r.json()["id"] + contributor = await extra_user_factory("contributor") + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid_b}/roles", + json={"user_id": str(contributor.user_uuid), "role": "validator"}, + ) + + # Switch to the contributor and call /me/.../roles. + override_user(contributor) + r = await client.get( + f"/api/v1/me/workspaces/{seeded_workspace_id}/tasking/projects/roles" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["workspace_role"] == "contributor" + by_pid = {p["project_id"]: p for p in body["projects"]} + # Project B has the explicit validator override. + assert by_pid[pid_b]["role"] == "validator" + # Project A falls back to workspace-level contributor. + assert by_pid[pid_a]["role"] == "contributor" + + async def test_contributor_cannot_manage_roles( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + ): + """A workspace contributor with no project-LEAD role is denied 403.""" + # Create project as LEAD, then act-as a contributor. + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-contrib-denied"}, + ) + pid = r.json()["id"] + + contributor = await extra_user_factory("contributor") + override_user(contributor) + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={ + "user_id": str(contributor.user_uuid), + "role": "contributor", + }, + ) + assert r.status_code == 403 diff --git a/tests/integration/test_proxy.py b/tests/integration/test_proxy.py new file mode 100644 index 0000000..6cd818d --- /dev/null +++ b/tests/integration/test_proxy.py @@ -0,0 +1,190 @@ +"""Integration tests for the OSM proxy (catch-all + capabilities) routes. + +Covers the @test comments in api/main.py: +- STRIP_REQUEST_HEADERS are not forwarded upstream +- HOP_BY_HOP_HEADERS are not forwarded back to the client +- /api/capabilities.json is proxied without auth +- 4xx/5xx upstream responses are logged to Sentry and the status is preserved +- TENANT_BYPASSES allow specific paths/methods without an X-Workspace header +- only the decorator's methods are proxied (others -> 405) +- X-Workspace not in the user's accessible workspaces -> 403 +- missing X-Workspace and no bypass -> 400 +- Host / X-Real-IP / X-Forwarded-* are set correctly upstream +- the response is streamed back unmodified with its status and headers +""" + +import httpx +import pytest + +import api.main +from tests.support import factories +from tests.support.http import StreamingMockTransport + + +@pytest.fixture +def mock_osm(monkeypatch): + """Default upstream returning 200 text/xml; records the forwarded request.""" + transport = StreamingMockTransport( + lambda req: (200, {"content-type": "text/xml"}, b"") + ) + monkeypatch.setattr( + api.main, + "_osm_client", + httpx.AsyncClient(transport=transport, base_url="http://osm-web"), + ) + return transport + + +def install_osm(monkeypatch, handler): + transport = StreamingMockTransport(handler) + monkeypatch.setattr( + api.main, + "_osm_client", + httpx.AsyncClient(transport=transport, base_url="http://osm-web"), + ) + return transport + + +# --- capabilities ---------------------------------------------------------- + + +async def test_capabilities_proxies_without_auth(client, mock_osm): + response = await client.get("/api/capabilities.json") + assert response.status_code == 200 + assert b"osm version" in response.content + assert mock_osm.last_request.url.path == "/api/capabilities.json" + + +# --- auth / tenant gating -------------------------------------------------- + + +async def test_missing_workspace_header_without_bypass_returns_400( + client, login, mock_osm +): + login(factories.make_user_info()) + response = await client.get("/api/0.6/map") + assert response.status_code == 400 + + +async def test_workspace_header_without_access_returns_403(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={})) + response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) + assert response.status_code == 403 + + +async def test_non_integer_workspace_header_returns_400(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + response = await client.get("/api/0.6/map", headers={"X-Workspace": "abc"}) + assert response.status_code == 400 + + +async def test_contributor_request_is_proxied(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) + assert response.status_code == 200 + assert b"osm version" in response.content + + +async def test_tenant_bypass_allows_workspace_put_without_header( + client, login, mock_osm +): + # PUT /api/0.6/workspaces/{id} is in TENANT_BYPASSES -> no X-Workspace needed. + login(factories.make_user_info()) + response = await client.put("/api/0.6/workspaces/123") + assert response.status_code == 200 + assert mock_osm.last_request is not None + + +async def test_tenant_bypass_does_not_apply_to_wrong_method(client, login, mock_osm): + # The bypass for /workspaces/{id} is PUT/DELETE only; GET still needs a header. + login(factories.make_user_info()) + response = await client.get("/api/0.6/workspaces/123") + assert response.status_code == 400 + + +async def test_disallowed_method_returns_405(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + # TRACE is not in the @app.api_route methods list. + response = await client.request( + "TRACE", "/api/0.6/map", headers={"X-Workspace": "1"} + ) + assert response.status_code == 405 + + +# --- header handling ------------------------------------------------------- + + +async def test_spoofed_request_headers_are_stripped(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + + await client.get( + "/api/0.6/map", + headers={ + "X-Workspace": "1", + "Host": "evil.example", + "X-Forwarded-For": "9.9.9.9", + "X-Real-IP": "9.9.9.9", + }, + ) + + fwd = mock_osm.last_request.headers + # Host is rewritten to the upstream host, not the spoofed value. + assert fwd["host"] == "osm-web" + # X-Forwarded-* / X-Real-IP are set by the proxy, not passed through. + assert fwd["x-forwarded-for"] != "9.9.9.9" + assert fwd["x-real-ip"] != "9.9.9.9" + assert "x-forwarded-proto" in fwd + assert "x-forwarded-host" in fwd + + +async def test_hop_by_hop_response_headers_are_stripped(client, login, monkeypatch): + install_osm( + monkeypatch, + lambda req: ( + 200, + {"content-type": "text/xml", "keep-alive": "timeout=5", "x-custom": "v"}, + b"", + ), + ) + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + + response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) + + assert response.status_code == 200 + # Non-hop-by-hop headers pass through; hop-by-hop ones are dropped. + assert response.headers.get("x-custom") == "v" + assert "keep-alive" not in response.headers + + +# --- upstream status + body fidelity --------------------------------------- + + +async def test_upstream_error_is_logged_and_status_preserved( + client, login, monkeypatch +): + install_osm(monkeypatch, lambda req: (503, {"content-type": "text/plain"}, b"down")) + captured = [] + monkeypatch.setattr( + api.main.sentry_sdk, "capture_message", lambda msg: captured.append(msg) + ) + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + + response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) + + assert response.status_code == 503 + assert captured, "expected a Sentry capture_message for the 5xx upstream response" + assert "503" in captured[0] + + +async def test_response_body_is_streamed_unmodified(client, login, monkeypatch): + body = b"\n \n" + install_osm( + monkeypatch, lambda req: (200, {"content-type": "application/xml"}, body) + ) + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + + response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) + + assert response.status_code == 200 + assert response.content == body + assert response.headers["content-type"] == "application/xml" diff --git a/tests/integration/test_tasks_flow.py b/tests/integration/test_tasks_flow.py new file mode 100644 index 0000000..7b63ba8 --- /dev/null +++ b/tests/integration/test_tasks_flow.py @@ -0,0 +1,909 @@ +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.integration + + +API = "/api/v1/workspaces/{wid}/tasking/projects" + + +# AOI that comfortably contains all task polygons below. +AOI_UNIT_SQUARE = { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], +} + +# Small (~1 km²) task polygons that fit inside the AOI and stay below the +# default grid-size warning threshold (5000 m → 25 km²). +TASK_A = { + "type": "Polygon", + "coordinates": [ + [[0.00, 0.00], [0.01, 0.00], [0.01, 0.01], [0.00, 0.01], [0.00, 0.00]] + ], +} +TASK_B = { + "type": "Polygon", + "coordinates": [ + [[0.02, 0.02], [0.03, 0.02], [0.03, 0.03], [0.02, 0.03], [0.02, 0.02]] + ], +} +TASK_C = { + "type": "Polygon", + "coordinates": [ + [[0.04, 0.04], [0.05, 0.04], [0.05, 0.05], [0.04, 0.05], [0.04, 0.04]] + ], +} + +# A "fat" polygon (1°×1° ≈ 12 000 km²) — triggers the grid-size warning. +TASK_FAT = AOI_UNIT_SQUARE + + +def _fc(*polys: dict) -> dict: + return { + "type": "FeatureCollection", + "features": [{"type": "Feature", "geometry": p} for p in polys], + } + + +# --------------------------------------------------------------------------- +# Common setup helper — used by most test classes to land a project in the +# ``open`` state with N tasks saved and a contributor + validator allocated. +# --------------------------------------------------------------------------- + + +async def _create_open_project( + client, + workspace_id: int, + *, + contributor_uuid, + validator_uuid, + task_polygons: list[dict], + review_required: bool = True, + name_suffix: str = "", +) -> int: + """Create -> AOI -> save tasks -> activate. Returns the project id. + + Caller must already be acting as a LEAD (the route is LEAD-only). + The two role UUIDs satisfy the activation pre-check (≥1 contributor + or validator). Both users must exist in the OSM ``users`` table. + """ + name = f"flow-{id(task_polygons)}{name_suffix}" + r = await client.post( + API.format(wid=workspace_id), + json={ + "name": name, + "review_required": review_required, + "role_assignments": [ + {"user_id": str(contributor_uuid), "role": "contributor"}, + {"user_id": str(validator_uuid), "role": "validator"}, + ], + }, + ) + assert r.status_code == 201, r.text + pid = r.json()["id"] + + r = await client.post( + f"{API.format(wid=workspace_id)}/{pid}/aoi", + json=AOI_UNIT_SQUARE, + ) + assert r.status_code == 200, r.text + + r = await client.post( + f"{API.format(wid=workspace_id)}/{pid}/tasks/save", + json={"source": "import", "feature_collection": _fc(*task_polygons)}, + ) + assert r.status_code == 201, r.text + + r = await client.post(f"{API.format(wid=workspace_id)}/{pid}/activate") + assert r.status_code == 200, r.text + assert r.json()["status"] == "open" + return pid + + +# --------------------------------------------------------------------------- +# Workflow 0 — Server-side grid generation. +# --------------------------------------------------------------------------- + + +class TestGridGeneration: + """LEAD: upload AOI, generate a grid, post the grid back through /tasks/save.""" + + async def test_grid_then_save_round_trip( + self, client, as_lead, seeded_workspace_id, reset_tasking + ): + """Grid generates clipped cells over the AOI; same payload commits via /tasks/save.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "grid-flow"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=AOI_UNIT_SQUARE, + ) + + # 1 km² cells over a 1° × 1° AOI ≈ 110 × 110 grid → ~12 000 cells. + # Use 25 km cells (~5x5 grid) so the test stays fast. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid?cell_size_meters=25000" + ) + assert r.status_code == 200, r.text + fc = r.json() + assert fc["type"] == "FeatureCollection" + assert len(fc["features"]) > 0 + # Every cell is a Polygon and inside the unit-square AOI bounds. + for feat in fc["features"]: + assert feat["geometry"]["type"] == "Polygon" + for ring in feat["geometry"]["coordinates"]: + for lon, lat in ring: + assert 0 <= lon <= 1 and 0 <= lat <= 1 + + # Round-trip: hand the same payload to /tasks/save with source=grid. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/save", + json={"source": "grid", "feature_collection": fc}, + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["task_count"] == len(fc["features"]) + assert body["task_boundary_type"] == "grid" + + async def test_grid_blocked_without_aoi( + self, client, as_lead, seeded_workspace_id, reset_tasking + ): + """Project AOI must be set before /tasks/grid will produce cells.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "grid-no-aoi"}, + ) + pid = r.json()["id"] + + r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid") + assert r.status_code == 422, r.text + assert "aoi" in r.json()["detail"].lower() + + async def test_grid_blocked_outside_draft( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + reset_tasking, + ): + """/tasks/grid is rejected once the project leaves draft.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + pid = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-grid-state", + ) + + r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid") + assert r.status_code == 422, r.text + assert "draft" in r.json()["detail"].lower() + + async def test_grid_multipolygon_straddling_cell_splits( + self, client, as_lead, seeded_workspace_id, reset_tasking + ): + """A cell that straddles two disjoint AOI lobes is split into one Polygon per lobe. + + AOI is two 0.1°×0.1° lobes separated by a 0.1° east-west gap + (total east-west extent 0.3° ≈ 33 km at the equator). With a + 50 km cell (~0.45°), the entire AOI fits inside a single grid + cell whose intersection with the AOI is a MultiPolygon of two + pieces — the endpoint must surface those as two separate + Polygon features (one per lobe), not a single MultiPolygon. + """ + two_lobe_aoi = { + "type": "MultiPolygon", + "coordinates": [ + # Lobe A — west. + [[[0.0, 0.0], [0.1, 0.0], [0.1, 0.1], [0.0, 0.1], [0.0, 0.0]]], + # Lobe B — same latitude band, east of the gap. + [[[0.2, 0.0], [0.3, 0.0], [0.3, 0.1], [0.2, 0.1], [0.2, 0.0]]], + ], + } + + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "grid-multipolygon"}, + ) + pid = r.json()["id"] + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=two_lobe_aoi, + ) + assert r.status_code == 200 + + # 50 km cell ≈ 0.45° — large enough to envelop both lobes + # (0.3° east-west extent) in a single straddling cell, forcing + # the MultiPolygon split path. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid?cell_size_meters=50000" + ) + assert r.status_code == 200, r.text + fc = r.json() + + assert fc["type"] == "FeatureCollection" + # The straddling cell produces exactly one Polygon per lobe. + assert len(fc["features"]) == 2, [f["geometry"] for f in fc["features"]] + for feat in fc["features"]: + assert feat["geometry"]["type"] == "Polygon", ( + "straddling cell was not split — got " f"{feat['geometry']['type']}" + ) + + # The two output polygons should align with the two lobes — + # check each lies entirely within one lobe's x-range. + lobe_a_xs, lobe_b_xs = [], [] + for feat in fc["features"]: + xs = [pt[0] for ring in feat["geometry"]["coordinates"] for pt in ring] + if max(xs) <= 0.11: + lobe_a_xs.append(xs) + elif min(xs) >= 0.19: + lobe_b_xs.append(xs) + assert len(lobe_a_xs) == 1 and len(lobe_b_xs) == 1, ( + "expected one polygon per lobe, got " + f"{len(lobe_a_xs)} in lobe A, {len(lobe_b_xs)} in lobe B" + ) + + # Round-trip: the split features must persist cleanly via /tasks/save. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/save", + json={"source": "grid", "feature_collection": fc}, + ) + assert r.status_code == 201, r.text + assert r.json()["task_count"] == 2 + + async def test_grid_contributor_forbidden( + self, + client, + as_contributor, + seeded_workspace_id, + reset_tasking, + ): + """/tasks/grid is LEAD-only — contributors get 403 (no project needed for the gate).""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/999999/tasks/grid" + ) + assert r.status_code == 403, r.text + + +# --------------------------------------------------------------------------- +# Workflow 1 — Validate + Save round-trip. +# --------------------------------------------------------------------------- + + +class TestValidateAndSave: + """LEAD: upload AOI, validate, save, list, get a task.""" + + project_id: int | None = None + + async def test_01_create_draft_with_aoi(self, client, as_lead, seeded_workspace_id): + """Create a draft project and upload the project AOI.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "validate-save"}, + ) + assert r.status_code == 201, r.text + TestValidateAndSave.project_id = r.json()["id"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/aoi", + json=AOI_UNIT_SQUARE, + ) + assert r.status_code == 200, r.text + + async def test_02_validate_inside_aoi(self, client, as_lead, seeded_workspace_id): + """Two in-AOI polygons validate cleanly with no warnings.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/validate", + json=_fc(TASK_A, TASK_B), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["valid"] is True + assert body["warnings"] == [] + assert body["source"] == "import" + + async def test_03_validate_polygon_outside_aoi_rejected( + self, client, as_lead, seeded_workspace_id + ): + """A polygon outside the project AOI is rejected with 422.""" + outside = { + "type": "Polygon", + "coordinates": [[[5, 5], [5.1, 5], [5.1, 5.1], [5, 5.1], [5, 5]]], + } + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/validate", + json=_fc(outside), + ) + assert r.status_code == 422, r.text + + async def test_04_validate_oversize_warns( + self, client, as_lead, seeded_workspace_id + ): + """A polygon larger than the grid-size threshold emits a warning but stays valid.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/validate", + json=_fc(TASK_FAT), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["valid"] is True + assert len(body["warnings"]) == 1 + assert body["warnings"][0]["issue"] == "polygon_exceeds_grid_size" + assert body["warnings"][0]["task_index"] == 0 + + async def test_05_save_persists_two_tasks( + self, client, as_lead, seeded_workspace_id + ): + """Save round-trips: returns task count, sequential numbering, sets boundary type.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/save", + json={"source": "import", "feature_collection": _fc(TASK_A, TASK_B)}, + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["task_count"] == 2 + assert body["task_boundary_type"] == "import" + assert [t["task_number"] for t in body["tasks"]] == [1, 2] + # First task starts in to_map with no lock and no last_mapper. + assert body["tasks"][0]["status"] == "to_map" + assert body["tasks"][0]["lock"] is None + assert body["tasks"][0]["last_mapper"] is None + + async def test_06_double_save_rejected(self, client, as_lead, seeded_workspace_id): + """A second save into a project that already has tasks 409s.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/save", + json={"source": "import", "feature_collection": _fc(TASK_A)}, + ) + assert r.status_code == 409, r.text + + async def test_07_list_tasks_returns_geometry( + self, client, as_lead, seeded_workspace_id + ): + """GET /tasks paginates and always includes geometry.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["pagination"]["total"] == 2 + assert len(body["tasks"]) == 2 + assert body["tasks"][0]["geometry"]["type"] == "Polygon" + + async def test_08_get_single_task(self, client, as_lead, seeded_workspace_id): + """GET /tasks/{n} returns one task with geometry + metadata.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["task_number"] == 1 + assert body["status"] == "to_map" + + async def test_09_aoi_replace_wipes_tasks( + self, client, as_lead, seeded_workspace_id + ): + """Re-uploading the AOI hard-deletes existing tasks (matches spec).""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/aoi", + json=AOI_UNIT_SQUARE, + ) + assert r.status_code == 200 + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks" + ) + assert r.status_code == 200 + assert r.json()["pagination"]["total"] == 0 + + +# --------------------------------------------------------------------------- +# Workflow 2 — Idempotent save. +# --------------------------------------------------------------------------- + + +class TestSaveIdempotency: + """Idempotency-Key header: replay vs. key-reuse-with-different-body.""" + + async def test_idempotent_save_lifecycle( + self, client, as_lead, seeded_workspace_id, reset_tasking + ): + """Same key + body → 200 replayed; same key + different body → 409.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "idem"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=AOI_UNIT_SQUARE, + ) + + body_a = {"source": "import", "feature_collection": _fc(TASK_A)} + body_b = {"source": "import", "feature_collection": _fc(TASK_B)} + key = "idem-key-001" + + r1 = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/save", + json=body_a, + headers={"Idempotency-Key": key}, + ) + assert r1.status_code == 201 + first_tasks = r1.json()["tasks"] + + # Replay: same key + same body returns 200 + replayed=true + same payload. + r2 = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/save", + json=body_a, + headers={"Idempotency-Key": key}, + ) + assert r2.status_code == 200, r2.text + assert r2.json()["replayed"] is True + assert r2.json()["tasks"] == first_tasks + + # Same key with a different body → 409. + r3 = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/save", + json=body_b, + headers={"Idempotency-Key": key}, + ) + assert r3.status_code == 409, r3.text + + +# --------------------------------------------------------------------------- +# Workflow 3 — Lock acquire / release / extend / force-release / one-per-user. +# --------------------------------------------------------------------------- + + +class TestLockLifecycle: + """Lock acquire, extend, release; one-active-lock-per-user-per-project.""" + + project_id: int | None = None + contributor = None + validator = None + + async def test_01_setup_open_project( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """Set up a project with 3 tasks + contributor + validator, then activate.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + TestLockLifecycle.contributor = contributor + TestLockLifecycle.validator = validator + + TestLockLifecycle.project_id = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A, TASK_B, TASK_C], + name_suffix="-lock", + ) + + async def test_02_contributor_locks_task_1( + self, client, override_user, seeded_workspace_id + ): + """Contributor acquires the lock on task 1 — task now reports the lock.""" + override_user(self.contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["lock"] is not None + assert body["lock"]["user_id"] == str(self.contributor.user_uuid) # type: ignore[union-attr] + + async def test_03_contributor_cannot_lock_second_task( + self, client, override_user, seeded_workspace_id + ): + """One-active-lock-per-user-per-project: locking task 2 returns 409 with existing-lock summary.""" + override_user(self.contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/2/lock" + ) + assert r.status_code == 409, r.text + detail = r.json()["detail"] + assert detail["existing_lock"]["task_number"] == 1 + + async def test_04_another_contributor_cannot_lock_task_1( + self, + client, + override_user, + seeded_workspace_id, + extra_user_factory, + ): + """A different contributor cannot lock a task that is already locked (409).""" + other = await extra_user_factory("contributor") + override_user(other) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 409, r.text + + async def test_05_extend_slides_expiry( + self, client, override_user, seeded_workspace_id + ): + """The lock holder can extend; expires_at moves forward.""" + override_user(self.contributor) + before = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1" + ) + expires_before = before.json()["lock"]["expires_at"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/extend" + ) + assert r.status_code == 200, r.text + assert r.json()["lock"]["expires_at"] > expires_before + + async def test_06_release_own_lock( + self, client, override_user, seeded_workspace_id + ): + """Caller releases their own lock — 204, lock gone on the task.""" + override_user(self.contributor) + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 204 + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1" + ) + assert r.json()["lock"] is None + + async def test_07_force_release_requires_lead( + self, + client, + override_user, + seeded_workspace_id, + ): + """force=true is LEAD-only; contributor gets 403 even for someone else's lock.""" + # Contributor re-locks task 1 first. + override_user(self.contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 200 + + # Validator tries to force-release (not a workspace LEAD) → 403. + override_user(self.validator) + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock?force=true" + ) + assert r.status_code == 403, r.text + + async def test_08_lead_force_release_succeeds( + self, client, as_lead, seeded_workspace_id + ): + """LEAD force-releases the contributor's lock; release_reason='lead_release'.""" + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock?force=true" + ) + assert r.status_code == 204 + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1" + ) + assert r.json()["lock"] is None + + async def test_09_unlock_without_active_lock_is_409( + self, client, override_user, seeded_workspace_id + ): + """Releasing an unlocked task returns 409.""" + override_user(self.contributor) + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 409, r.text + + +# --------------------------------------------------------------------------- +# Workflow 4 — Submit "Done?" flow through review. +# --------------------------------------------------------------------------- + + +class TestSubmitReviewFlow: + """to_map -> contributor submit -> to_review -> validator submit -> completed.""" + + project_id: int | None = None + contributor = None + validator = None + + async def test_01_setup_open_project( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """Set up an open project with one task + a contributor and validator.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + TestSubmitReviewFlow.contributor = contributor + TestSubmitReviewFlow.validator = validator + TestSubmitReviewFlow.project_id = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-submit", + ) + + async def test_02_contributor_lock_and_submit_done( + self, client, override_user, seeded_workspace_id + ): + """Contributor locks task 1 then submits done=true → status becomes to_review, lock auto-released.""" + override_user(self.contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 200 + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/submit", + json={"done": True}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "to_review" + assert body["lock"] is None + assert body["last_mapper"]["user_id"] == str(self.contributor.user_uuid) # type: ignore[union-attr] + + async def test_03_contributor_cannot_lock_for_review( + self, client, override_user, seeded_workspace_id + ): + """to_review tasks reject contributor-role lock attempts (validator/lead only).""" + override_user(self.contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 403, r.text + + async def test_04_validator_locks_to_review( + self, client, override_user, seeded_workspace_id + ): + """Validator can lock a to_review task they did not last map.""" + override_user(self.validator) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 200, r.text + + async def test_05_validator_submit_done_no_feedback_completes( + self, client, override_user, seeded_workspace_id + ): + """Validator submit done=true + no feedback → status=completed.""" + override_user(self.validator) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/submit", + json={"done": True}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "completed" + assert body["lock"] is None + + +# --------------------------------------------------------------------------- +# Workflow 5 — Submit done=false slides the lock; status unchanged. +# --------------------------------------------------------------------------- + + +class TestSubmitDoneFalseSlides: + async def test_submit_done_false_slides_expiry( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + reset_tasking, + ): + """done=false: state unchanged, lock expires_at slides to NOW + lock_timeout_hours.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + pid = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-slide", + ) + + override_user(contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + assert r.status_code == 200 + expiry_before = r.json()["lock"]["expires_at"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", + json={"done": False}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "to_map" # unchanged + assert body["lock"] is not None # still locked + # New expiry is at-or-after submitted_at + lock_timeout, so > original. + assert body["lock"]["expires_at"] >= expiry_before + + +# --------------------------------------------------------------------------- +# Workflow 6 — Validator-feedback remap loop. +# --------------------------------------------------------------------------- + + +class TestRemapFlow: + async def test_remap_loop( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + reset_tasking, + ): + """to_review + feedback → to_remap; feedback row is persisted.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + pid = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-remap", + ) + + # Contributor maps → to_review. + override_user(contributor) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", + json={"done": True}, + ) + assert r.json()["status"] == "to_review" + + # Validator validates with feedback → to_remap. + override_user(validator) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", + json={ + "done": True, + "feedback": { + "reason_category": "incomplete_mapping", + "notes": "Please finish the missing footways on the north side.", + }, + }, + ) + assert r.status_code == 200, r.text + assert r.json()["status"] == "to_remap" + assert r.json()["lock"] is None + + # Contributor re-locks the remapped task (allowed on to_remap). + override_user(contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + assert r.status_code == 200, r.text + + +# --------------------------------------------------------------------------- +# Workflow 7 — Self-validation guard. +# --------------------------------------------------------------------------- + + +class TestSelfValidationGuard: + async def test_validator_cannot_validate_own_last_mapping( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + reset_tasking, + ): + """A validator who is also the task's last_mapper cannot lock to_review.""" + validator = await extra_user_factory("validator") + # Need a second worker to satisfy the activation pre-check; the + # validator is doing double-duty (validator + mapper). + contributor = await extra_user_factory("contributor") + pid = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-self", + ) + + # Validator maps the task themselves (validators can also lock to_map). + override_user(validator) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", + json={"done": True}, + ) + + # Now they try to validate their own work → 403. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + assert r.status_code == 403, r.text + + +# --------------------------------------------------------------------------- +# Workflow 8 — Per-task reset by LEAD. +# --------------------------------------------------------------------------- + + +class TestTaskReset: + async def test_reset_releases_lock_and_resets_status( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + reset_tasking, + ): + """LEAD reset on a locked to_review task: lock cleared, status back to to_map.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + pid = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-rst", + ) + + # Contributor maps → to_review. + override_user(contributor) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", + json={"done": True}, + ) + # Validator picks it up. + override_user(validator) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") + + # Switch back to a LEAD token to invoke /reset. The integration + # `as_lead` fixture already inserted a lead users row, so the + # helper here just builds a UserInfo to bind to the override. + from api.core.security import validate_token + from api.main import app + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user + + lead = _make_user( + role="lead", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + app.dependency_overrides[validate_token] = lambda: lead + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/reset" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "to_map" + assert body["lock"] is None + assert body["last_mapper"] is None diff --git a/tests/integration/test_teams.py b/tests/integration/test_teams.py new file mode 100644 index 0000000..41ae522 --- /dev/null +++ b/tests/integration/test_teams.py @@ -0,0 +1,357 @@ +"""Integration tests for the /workspaces/{id}/teams routes. + +Covers the @test comments in api/src/teams/routes.py across all eight +endpoints. Team routes touch both DBs: the access guard / workspace lookup +hits the task DB (``workspace_repo.getById``) and team/user operations hit +the OSM DB. Queue results on the matching fake session in call order. + +Note on access errors: for endpoints with no explicit lead check, access is +enforced by ``getById``, which raises 404 (NotFound) when the workspace is +missing or inaccessible -- so "not a member" surfaces as 404, not 403, there. +""" + +import pytest + +from api.src.users.schemas import WorkspaceUserRoleType +from tests.support import factories, fakes + + +def base(workspace_id=1): + return f"/api/v1/workspaces/{workspace_id}/teams" + + +@pytest.fixture +def lead(): + return factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]} + ) + + +def member(): + # A non-lead user (default factory grants no lead/validator role). + return factories.make_user_info() + + +def ws_ok(task_session): + """Queue a successful workspace access guard on the task DB.""" + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + + +# === GET "" list teams ===================================================== + + +async def test_list_teams_returns_items(client, login, task_session, osm_session): + login(member()) + ws_ok(task_session) + osm_session.queue( + fakes.rows( + factories.make_team(id=1, name="Alpha", users=[factories.make_user()]), + factories.make_team(id=2, name="Beta", users=[]), + ) + ) + + response = await client.get(base()) + + assert response.status_code == 200 + body = response.json() + assert body[0] == {"id": 1, "name": "Alpha", "member_count": 1} + assert body[1]["member_count"] == 0 + + +async def test_list_teams_inaccessible_workspace_404(client, login, task_session): + login(member()) + task_session.queue(fakes.empty()) # getById guard -> NotFound + response = await client.get(base(42)) + assert response.status_code == 404 + + +async def test_list_teams_unexpected_error_500( + error_client, login, task_session, osm_session +): + login(member()) + ws_ok(task_session) + osm_session.queue(fakes.raises(RuntimeError("boom"))) + response = await error_client.get(base()) + assert response.status_code == 500 + + +# === POST "" create team =================================================== + + +async def test_create_team_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.post(base(), json={"name": "New"}) + assert response.status_code == 403 + + +async def test_create_team_as_lead(client, login, lead, task_session, osm_session): + login(lead) + ws_ok(task_session) # getById guard + # create -> add + commit + refresh assigns the id + response = await client.post(base(), json={"name": "New Team"}) + assert response.status_code == 201 + assert isinstance(response.json(), int) + assert osm_session.commits == 1 + + +async def test_create_team_workspace_missing_404(client, login, lead, task_session): + login(lead) + task_session.queue(fakes.empty()) + response = await client.post(base(), json={"name": "New"}) + assert response.status_code == 404 + + +async def test_create_team_rejects_blank_name(client, login, lead): + login(lead) + response = await client.post(base(), json={"name": ""}) + assert response.status_code == 422 + + +# === GET "/{team_id}" get one team ========================================= + + +async def test_get_team_returns_item(client, login, task_session, osm_session): + login(member()) + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(factories.make_team(id=3, name="Gamma", users=[])), # get_item + ) + + response = await client.get(f"{base()}/3") + + assert response.status_code == 200 + assert response.json()["id"] == 3 + + +async def test_get_team_not_in_workspace_404(client, login, task_session, osm_session): + login(member()) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) # assert_team_in_workspace -> False + response = await client.get(f"{base()}/999") + assert response.status_code == 404 + + +async def test_get_team_workspace_missing_404(client, login, task_session): + login(member()) + task_session.queue(fakes.empty()) + response = await client.get(f"{base()}/3") + assert response.status_code == 404 + + +# === PUT "/{team_id}" update team ========================================== + + +async def test_update_team_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.put(f"{base()}/1", json={"name": "Renamed"}) + assert response.status_code == 403 + + +async def test_update_team_as_lead(client, login, lead, task_session, osm_session): + login(lead) + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(factories.make_team(id=1, name="Old")), # update -> get(id) + ) + + response = await client.put(f"{base()}/1", json={"name": "Renamed"}) + + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_update_team_not_in_workspace_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.put(f"{base()}/1", json={"name": "Renamed"}) + assert response.status_code == 404 + + +# === DELETE "/{team_id}" delete team ======================================= + + +async def test_delete_team_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.delete(f"{base()}/1") + assert response.status_code == 403 + + +async def test_delete_team_as_lead(client, login, lead, task_session, osm_session): + login(lead) + ws_ok(task_session) + osm_session.queue(fakes.scalar(True)) # assert_team_in_workspace; delete follows + response = await client.delete(f"{base()}/1") + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_delete_team_not_in_workspace_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.delete(f"{base()}/1") + assert response.status_code == 404 + + +# === GET "/{team_id}/members" list members ================================= + + +async def test_get_members_returns_users(client, login, task_session, osm_session): + login(member()) + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(factories.make_user(id=1, display_name="Mem")), # get_members + ) + + response = await client.get(f"{base()}/1/members") + + assert response.status_code == 200 + assert response.json()[0]["display_name"] == "Mem" + + +async def test_get_members_team_not_in_workspace_404( + client, login, task_session, osm_session +): + login(member()) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.get(f"{base()}/1/members") + assert response.status_code == 404 + + +# === POST "/{team_id}/members" join team =================================== + + +async def test_join_team_adds_current_user(client, login, task_session, osm_session): + login(member()) + joining = factories.make_user(id=7, display_name="Joiner") + joining_again = factories.make_user(id=7, display_name="Joiner") + joining_again.teams = [] # not yet on the team + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(joining), # get_current_user (scalar_one) + fakes.rows(joining_again), # add_member: load user w/ teams + fakes.rows(factories.make_team(id=1, name="T")), # add_member: get(team) + ) + + response = await client.post(f"{base()}/1/members") + + assert response.status_code == 200 + assert response.json()["id"] == 7 + assert osm_session.commits == 1 + + +async def test_join_team_not_in_workspace_404(client, login, task_session, osm_session): + login(member()) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.post(f"{base()}/1/members") + assert response.status_code == 404 + + +# === PUT "/{team_id}/members/{user_id}" add member ========================= + + +async def test_add_member_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.put(f"{base()}/1/members/5") + assert response.status_code == 403 + + +async def test_add_member_as_lead(client, login, lead, task_session, osm_session): + login(lead) + target = factories.make_user(id=5) + target.teams = [] + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(target), # add_member: load user + fakes.rows(factories.make_team(id=1, name="T")), # add_member: get(team) + ) + + response = await client.put(f"{base()}/1/members/5") + + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_add_member_missing_user_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.empty(), # add_member: user not found + ) + response = await client.put(f"{base()}/1/members/5") + assert response.status_code == 404 + + +async def test_add_member_team_not_in_workspace_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.put(f"{base()}/1/members/5") + assert response.status_code == 404 + + +# === DELETE "/{team_id}/members/{user_id}" remove member =================== + + +async def test_remove_member_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.delete(f"{base()}/1/members/5") + assert response.status_code == 403 + + +async def test_remove_member_as_lead(client, login, lead, task_session, osm_session): + login(lead) + team = factories.make_team(id=1, name="T") + target = factories.make_user(id=5) + target.teams = [team] # currently a member + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(target), # remove_member: load user + fakes.rows(team), # remove_member: get(team) + ) + + response = await client.delete(f"{base()}/1/members/5") + + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_remove_member_missing_user_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.empty(), # remove_member: user not found + ) + response = await client.delete(f"{base()}/1/members/5") + assert response.status_code == 404 + + +async def test_remove_member_team_not_in_workspace_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.delete(f"{base()}/1/members/5") + assert response.status_code == 404 diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py new file mode 100644 index 0000000..ed083cb --- /dev/null +++ b/tests/integration/test_users.py @@ -0,0 +1,186 @@ +"""Integration tests for the /workspaces/{id}/users routes. + +Covers the @test comments in api/src/users/routes.py: +- listing members requires contributor-or-above (403 otherwise) +- assign/remove role require workspace lead (403 otherwise) +- workspace-missing -> 404, user-missing -> 404 +- unexpected errors -> 500 +- role can be set to lead/validator and unset back to contributor +- POC / contributor cannot be assigned directly (422) +- the affected user's cache is evicted after a change +""" + +from uuid import UUID + +import pytest + +import api.src.users.routes as users_routes +from api.src.users.schemas import WorkspaceUserRoleType +from tests.support import factories, fakes + +TARGET_USER = "33333333-3333-3333-3333-333333333333" + + +def url(workspace_id=1): + return f"/api/v1/workspaces/{workspace_id}/users" + + +@pytest.fixture +def lead(): + return factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]} + ) + + +@pytest.fixture +def evictions(monkeypatch): + """Record evict_user_from_cache(...) calls made by the routes.""" + calls = [] + monkeypatch.setattr(users_routes, "evict_user_from_cache", calls.append) + return calls + + +# --- GET members ----------------------------------------------------------- + + +async def test_list_members_requires_membership(client, login): + login(factories.make_user_info(accessible_workspace_ids={})) + response = await client.get(url()) + assert response.status_code == 403 + + +async def test_list_members_returns_privileged_users(client, login, osm_session): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + user = factories.make_user(id=5, auth_uid=TARGET_USER, display_name="Lead Lou") + # repo joins User + role -> rows of (user, role) tuples + osm_session.queue(fakes.rows((user, "lead"))) + + response = await client.get(url()) + + assert response.status_code == 200 + body = response.json() + assert body == [ + {"id": 5, "auth_uid": TARGET_USER, "display_name": "Lead Lou", "role": "lead"} + ] + + +async def test_list_members_unexpected_error_returns_500( + error_client, login, osm_session +): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + osm_session.queue(fakes.raises(RuntimeError("db exploded"))) + + response = await error_client.get(url()) + assert response.status_code == 500 + + +# --- PUT assign role ------------------------------------------------------- + + +async def test_assign_role_requires_lead(client, login): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + response = await client.put(f"{url()}/{TARGET_USER}/role", json={"role": "lead"}) + assert response.status_code == 403 + + +async def test_assign_role_workspace_missing_returns_404( + client, login, lead, task_session +): + login(lead) + task_session.queue(fakes.empty()) # getById finds nothing + response = await client.put(f"{url()}/{TARGET_USER}/role", json={"role": "lead"}) + assert response.status_code == 404 + + +async def test_assign_role_user_missing_returns_404( + client, login, lead, task_session, osm_session +): + login(lead) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.scalar(None)) # user has never signed in + response = await client.put(f"{url()}/{TARGET_USER}/role", json={"role": "lead"}) + assert response.status_code == 404 + + +async def test_assign_lead_role_succeeds_and_evicts( + client, login, lead, task_session, osm_session, evictions +): + login(lead) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.scalar(1)) # user exists + + response = await client.put(f"{url()}/{TARGET_USER}/role", json={"role": "lead"}) + + assert response.status_code == 204 + assert osm_session.commits == 1 + assert evictions == [UUID(TARGET_USER)] + + +async def test_assign_validator_role_succeeds( + client, login, lead, task_session, osm_session, evictions +): + login(lead) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.scalar(1)) + + response = await client.put( + f"{url()}/{TARGET_USER}/role", json={"role": "validator"} + ) + assert response.status_code == 204 + + +async def test_assign_contributor_role_rejected(client, login, lead): + login(lead) + # CONTRIBUTOR is implicit and cannot be assigned directly -> 422. + response = await client.put( + f"{url()}/{TARGET_USER}/role", json={"role": "contributor"} + ) + assert response.status_code == 422 + + +async def test_assign_poc_role_rejected(client, login, lead): + login(lead) + # "poc" is a TDEI role, not a workspace role -> validation error. + response = await client.put(f"{url()}/{TARGET_USER}/role", json={"role": "poc"}) + assert response.status_code == 422 + + +# --- DELETE remove role ---------------------------------------------------- + + +async def test_remove_role_requires_lead(client, login): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + response = await client.delete(f"{url()}/{TARGET_USER}") + assert response.status_code == 403 + + +async def test_remove_role_unsets_to_contributor_and_evicts( + client, login, lead, task_session, osm_session, evictions +): + login(lead) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.affected(1)) # one role row removed + + response = await client.delete(f"{url()}/{TARGET_USER}") + + assert response.status_code == 204 + assert evictions == [UUID(TARGET_USER)] + + +async def test_remove_role_when_not_assigned_returns_404( + client, login, lead, task_session, osm_session +): + login(lead) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.affected(0)) # nothing to delete + response = await client.delete(f"{url()}/{TARGET_USER}") + assert response.status_code == 404 + + +async def test_remove_role_workspace_missing_returns_404( + client, login, lead, task_session +): + login(lead) + task_session.queue(fakes.empty()) + response = await client.delete(f"{url()}/{TARGET_USER}") + assert response.status_code == 404 diff --git a/tests/integration/test_workspaces.py b/tests/integration/test_workspaces.py new file mode 100644 index 0000000..fbd30cb --- /dev/null +++ b/tests/integration/test_workspaces.py @@ -0,0 +1,494 @@ +"""Integration tests for the /workspaces routes. + +Covers the @test comments in api/src/workspaces/routes.py across all +endpoints (list, get, bbox, create, update, delete, quest get/settings, +imagery get/settings). Each test drives a real HTTP request through the +real route + repository code, queueing simulated rows on the fake sessions. + +Note: read endpoints (get, bbox, quest, imagery) gate access via +``getById``, which raises 404 when the workspace is missing or inaccessible +-- so "no access" surfaces as 404 there rather than 403. The mutating +endpoints additionally require ``isWorkspaceLead`` and return 403 otherwise. +""" + +from datetime import datetime +from uuid import UUID + +import pytest + +import api.src.workspaces.routes as ws_routes +from api.src.users.schemas import WorkspaceUserRoleType +from api.src.workspaces.schemas import QuestDefinitionType, WorkspaceLongQuest +from tests.support import factories, fakes + +API = "/api/v1/workspaces" + + +@pytest.fixture +def lead(): + return factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]} + ) + + +@pytest.fixture +def no_schema_validation(monkeypatch): + """Make schema validation a no-op (avoid network in update endpoints).""" + + async def ok(_definition): + return None + + monkeypatch.setattr(ws_routes, "validate_quest_definition_schema", ok) + monkeypatch.setattr(ws_routes, "validate_imagery_definition_schema", ok) + + +@pytest.fixture +def evictions(monkeypatch): + calls = [] + monkeypatch.setattr(ws_routes, "evict_user_from_cache", calls.append) + return calls + + +# === GET /mine ============================================================= + + +async def test_list_my_workspaces(client, login, task_session): + login() + task_session.queue( + fakes.rows( + factories.make_workspace(id=1, title="One"), + factories.make_workspace(id=2, title="Two"), + ) + ) + + response = await client.get(f"{API}/mine") + + assert response.status_code == 200 + body = response.json() + assert [w["id"] for w in body] == [1, 2] + assert body[0]["role"] == "contributor" + + +async def test_list_my_workspaces_empty(client, login, task_session): + login() + task_session.queue(fakes.rows()) + response = await client.get(f"{API}/mine") + assert response.status_code == 200 + assert response.json() == [] + + +async def test_list_my_workspaces_unexpected_error_500( + error_client, login, task_session +): + login() + task_session.queue(fakes.raises(RuntimeError("db"))) + response = await error_client.get(f"{API}/mine") + assert response.status_code == 500 + + +async def test_list_matches_get_by_id(client, login, task_session): + # The same workspace serialized via /mine and via /{id} agree on shared fields. + login( + factories.make_user_info(osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]}) + ) + task_session.queue( + fakes.rows(factories.make_workspace(id=1, title="Shared")), + fakes.rows(factories.make_workspace(id=1, title="Shared")), + ) + + listed = (await client.get(f"{API}/mine")).json()[0] + fetched = (await client.get(f"{API}/1")).json() + + shared = ["id", "title", "type", "tdeiProjectGroupId", "role"] + assert {k: listed[k] for k in shared} == {k: fetched[k] for k in shared} + + +# === GET /{id} ============================================================= + + +async def test_get_workspace_by_id(client, login, task_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=7, title="Lucky"))) + response = await client.get(f"{API}/7") + assert response.status_code == 200 + assert response.json()["title"] == "Lucky" + + +async def test_get_workspace_missing_404(client, login, task_session): + login() + task_session.queue(fakes.empty()) + response = await client.get(f"{API}/404") + assert response.status_code == 404 + + +async def test_get_workspace_non_integer_id_422(client, login): + login() + response = await client.get(f"{API}/not-a-number") + assert response.status_code == 422 + + +async def test_get_workspace_unexpected_error_500(error_client, login, task_session): + login() + task_session.queue(fakes.raises(RuntimeError("db"))) + response = await error_client.get(f"{API}/7") + assert response.status_code == 500 + + +# === GET /{id}/bbox ======================================================== + + +async def test_get_bbox_returns_values(client, login, task_session, osm_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue( + fakes.mappings({"max_lat": 1.5, "max_lon": 2.5, "min_lat": 0.5, "min_lon": 1.5}) + ) + + response = await client.get(f"{API}/1/bbox") + + assert response.status_code == 200 + assert response.json()["max_lat"] == 1.5 + + +async def test_get_bbox_workspace_missing_404(client, login, task_session): + login() + task_session.queue(fakes.empty()) + response = await client.get(f"{API}/1/bbox") + assert response.status_code == 404 + + +async def test_get_bbox_no_nodes_404(client, login, task_session, osm_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.mappings()) # no rows -> bbox None -> NotFound + response = await client.get(f"{API}/1/bbox") + assert response.status_code == 404 + + +# === POST "" create ======================================================== + + +async def test_create_workspace(client, login, task_session, osm_session, evictions): + login(factories.make_user_info(project_group_ids=[factories.DEFAULT_PG_ID])) + osm_session.queue(fakes.scalar(1)) # assign_member_role: user exists + + response = await client.post( + f"{API}", + json={ + "type": "osw", + "title": "Fresh", + "tdeiProjectGroupId": factories.DEFAULT_PG_ID, + }, + ) + + assert response.status_code == 201 + assert response.json()["workspaceId"] is not None + assert task_session.commits == 1 # workspace insert + assert osm_session.commits == 1 # role insert + assert evictions == [UUID(factories.DEFAULT_USER_ID)] # creator's cache evicted + + +async def test_create_in_unauthorized_group_403(client, login): + # User belongs to DEFAULT_PG_ID only; creating in another group is forbidden. + login(factories.make_user_info(project_group_ids=[factories.DEFAULT_PG_ID])) + response = await client.post( + f"{API}", + json={ + "type": "osw", + "title": "Nope", + "tdeiProjectGroupId": "99999999-9999-9999-9999-999999999999", + }, + ) + assert response.status_code == 403 + + +async def test_create_invalid_body_422(client, login): + login() + # Missing required 'title' and 'tdeiProjectGroupId'. + response = await client.post(f"{API}", json={"type": "osw"}) + assert response.status_code == 422 + + +# === PATCH /{id} update ==================================================== + + +async def test_update_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.patch(f"{API}/1", json={"title": "X"}) + assert response.status_code == 403 + + +async def test_update_empty_patch_400(client, login, lead): + login(lead) + response = await client.patch(f"{API}/1", json={}) + assert response.status_code == 400 + + +async def test_update_success(client, login, lead, task_session): + login(lead) + task_session.queue( + fakes.affected(1), # UPDATE + fakes.rows(factories.make_workspace(id=1, title="Renamed")), # getById + ) + response = await client.patch(f"{API}/1", json={"title": "Renamed"}) + assert response.status_code == 204 + + +async def test_update_missing_workspace_404(client, login, lead, task_session): + login(lead) + task_session.queue(fakes.affected(0)) # UPDATE matched nothing -> NotFound + response = await client.patch(f"{API}/1", json={"title": "X"}) + assert response.status_code == 404 + + +async def test_update_invalid_body_422(client, login, lead): + login(lead) + response = await client.patch(f"{API}/1", json={"externalAppAccess": 999}) + assert response.status_code == 422 + + +# === DELETE /{id} ========================================================== + + +async def test_delete_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.delete(f"{API}/1") + assert response.status_code == 403 + + +async def test_delete_success_no_members( + client, login, lead, task_session, osm_session, evictions +): + login(lead) + osm_session.queue(fakes.rows()) # get_privileged_workspace_members: none + task_session.queue(fakes.affected(1)) # delete + + response = await client.delete(f"{API}/1") + + assert response.status_code == 204 + assert task_session.commits == 1 + assert evictions == [] # no members to evict + + +async def test_delete_evicts_member_caches( + client, login, lead, task_session, osm_session, evictions +): + login(lead) + member = factories.make_user(id=9, auth_uid=factories.DEFAULT_USER_ID) + osm_session.queue(fakes.rows((member, "lead"))) # privileged members + task_session.queue(fakes.affected(1)) + + response = await client.delete(f"{API}/1") + + assert response.status_code == 204 + assert evictions == [UUID(factories.DEFAULT_USER_ID)] + + +async def test_delete_missing_workspace_404( + client, login, lead, task_session, osm_session +): + login(lead) + osm_session.queue(fakes.rows()) # no members + task_session.queue(fakes.affected(0)) # delete matched nothing -> NotFound + response = await client.delete(f"{API}/1") + assert response.status_code == 404 + + +# === GET /{id}/quests/long ================================================= + + +async def test_get_long_quest_def_none_returns_204(client, login, task_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) # no quest def + response = await client.get(f"{API}/1/quests/long") + assert response.status_code == 204 + + +async def test_get_long_quest_def_json(client, login, task_session): + login() + ws = factories.make_workspace(id=1) + ws.longFormQuestDef = WorkspaceLongQuest( + workspace_id=1, + type=QuestDefinitionType.JSON, + definition='{"quest": true}', + modifiedAt=datetime(2026, 1, 1), + modifiedBy=UUID(factories.DEFAULT_USER_ID), + modifiedByName="x", + ) + task_session.queue(fakes.rows(ws)) + + response = await client.get(f"{API}/1/quests/long") + + assert response.status_code == 200 + assert response.json() == {"quest": True} + + +async def test_get_long_quest_def_workspace_missing_404(client, login, task_session): + login() + task_session.queue(fakes.empty()) + response = await client.get(f"{API}/1/quests/long") + assert response.status_code == 404 + + +# === GET /{id}/quests/long/settings ======================================== + + +async def test_get_long_quest_settings_default_when_unset(client, login, task_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + response = await client.get(f"{API}/1/quests/long/settings") + assert response.status_code == 200 + body = response.json() + assert body["type"] == "NONE" + assert body["workspace_id"] == 1 + + +async def test_get_long_quest_settings_from_def(client, login, task_session): + login() + ws = factories.make_workspace(id=1) + ws.longFormQuestDef = WorkspaceLongQuest( + workspace_id=1, + type=QuestDefinitionType.URL, + url="https://q.example", + modifiedAt=datetime(2026, 1, 1), + modifiedBy=UUID(factories.DEFAULT_USER_ID), + modifiedByName="Editor", + ) + task_session.queue(fakes.rows(ws)) + + response = await client.get(f"{API}/1/quests/long/settings") + + assert response.status_code == 200 + body = response.json() + assert body["type"] == "URL" + assert body["url"] == "https://q.example" + + +async def test_get_long_quest_settings_missing_404(client, login, task_session): + login() + task_session.queue(fakes.empty()) + response = await client.get(f"{API}/1/quests/long/settings") + assert response.status_code == 404 + + +# === PATCH /{id}/quests/long/settings ====================================== + + +async def test_update_quest_settings_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.patch( + f"{API}/1/quests/long/settings", json={"type": "NONE"} + ) + assert response.status_code == 403 + + +async def test_update_quest_settings_none_success(client, login, lead, task_session): + login(lead) + task_session.queue(fakes.affected(1)) # save_longform_quest UPDATE + response = await client.patch( + f"{API}/1/quests/long/settings", json={"type": "NONE"} + ) + assert response.status_code == 204 + + +async def test_update_quest_settings_json_validates_schema( + client, login, lead, task_session, no_schema_validation +): + login(lead) + task_session.queue(fakes.affected(1)) + response = await client.patch( + f"{API}/1/quests/long/settings", + json={"type": "JSON", "definition": '{"a": 1}'}, + ) + assert response.status_code == 204 + + +async def test_update_quest_settings_invalid_schema_400( + client, login, lead, monkeypatch +): + from fastapi import HTTPException + + async def reject(_definition): + raise HTTPException(status_code=400, detail="bad schema") + + monkeypatch.setattr(ws_routes, "validate_quest_definition_schema", reject) + login(lead) + + response = await client.patch( + f"{API}/1/quests/long/settings", + json={"type": "JSON", "definition": '{"a": 1}'}, + ) + assert response.status_code == 400 + + +async def test_update_quest_settings_json_without_definition_422(client, login, lead): + login(lead) + # QuestSettingsPatch validator rejects JSON type with no definition. + response = await client.patch( + f"{API}/1/quests/long/settings", json={"type": "JSON"} + ) + assert response.status_code == 422 + + +# === GET /{id}/imagery/settings ============================================ + + +async def test_get_imagery_settings_default_when_unset(client, login, task_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + response = await client.get(f"{API}/1/imagery/settings") + assert response.status_code == 200 + assert response.json()["definition"] == [] + + +async def test_get_imagery_settings_missing_404(client, login, task_session): + login() + task_session.queue(fakes.empty()) + response = await client.get(f"{API}/1/imagery/settings") + assert response.status_code == 404 + + +# === PATCH /{id}/imagery/settings ========================================== + + +async def test_update_imagery_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.patch(f"{API}/1/imagery/settings", json={"definition": []}) + assert response.status_code == 403 + + +async def test_update_imagery_success( + client, login, lead, task_session, no_schema_validation +): + login(lead) + task_session.queue(fakes.affected(1)) # save_imagery_def UPDATE + response = await client.patch( + f"{API}/1/imagery/settings", + json={"definition": [{"name": "layer", "url": "https://x"}]}, + ) + assert response.status_code == 204 + + +async def test_update_imagery_invalid_schema_400(client, login, lead, monkeypatch): + from fastapi import HTTPException + + async def reject(_definition): + raise HTTPException(status_code=400, detail="bad imagery") + + monkeypatch.setattr(ws_routes, "validate_imagery_definition_schema", reject) + login(lead) + + response = await client.patch( + f"{API}/1/imagery/settings", json={"definition": [{"bad": True}]} + ) + assert response.status_code == 400 + + +async def test_update_imagery_empty_skips_validation_success( + client, login, lead, task_session +): + # Empty definition is falsy -> validation skipped, save proceeds. + login(lead) + task_session.queue(fakes.affected(1)) + response = await client.patch(f"{API}/1/imagery/settings", json={"definition": []}) + assert response.status_code == 204 diff --git a/tests/support/__init__.py b/tests/support/__init__.py new file mode 100644 index 0000000..1d2d1d0 --- /dev/null +++ b/tests/support/__init__.py @@ -0,0 +1,4 @@ +"""Shared test support: fake DB sessions and object factories. + +See ``tests/README.md`` for the testing philosophy and call-order contract. +""" diff --git a/tests/support/factories.py b/tests/support/factories.py new file mode 100644 index 0000000..9cdd402 --- /dev/null +++ b/tests/support/factories.py @@ -0,0 +1,106 @@ +"""Factories for the domain objects tests need. + +These build real ``UserInfo`` / SQLModel instances (not mocks) so the +permission logic and serialization under test run for real. Defaults are +deterministic so tests can assert on concrete values. +""" + +from datetime import datetime +from uuid import UUID + +from api.core.security import TdeiProjectGroupRole, UserInfo, UserInfoPGMembership +from api.src.teams.schemas import WorkspaceTeam +from api.src.users.schemas import User +from api.src.workspaces.schemas import Workspace, WorkspaceType + +# Stable identifiers reused across tests. +DEFAULT_PG_ID = "11111111-1111-1111-1111-111111111111" +DEFAULT_USER_ID = "22222222-2222-2222-2222-222222222222" + + +def make_user_info( + *, + user_id: str = DEFAULT_USER_ID, + user_name: str = "Test User", + project_group_ids: list[str] | None = None, + accessible_workspace_ids: dict[str, list[int]] | None = None, + osm_workspace_roles: dict[int, list] | None = None, + poc_group_ids: tuple[str, ...] = (), +) -> UserInfo: + """Build a ``UserInfo`` the way ``validate_token`` would have. + + ``project_group_ids`` -- TDEI project groups the user belongs to. + ``accessible_workspace_ids`` -- pg_id -> workspace ids (from the task DB). + ``osm_workspace_roles`` -- workspace_id -> roles (from the OSM DB). + ``poc_group_ids`` -- subset of groups where the user is a POC + (grants lead rights on owned workspaces). + """ + info = UserInfo() + info.credentials = "test-token" + info.user_uuid = UUID(user_id) + info.user_name = user_name + + if project_group_ids is None: + project_group_ids = [DEFAULT_PG_ID] + + info.projectGroups = [ + UserInfoPGMembership( + project_group_name=f"PG {pg_id}", + project_group_id=pg_id, + tdeiRoles=( + [TdeiProjectGroupRole.POINT_OF_CONTACT] + if pg_id in poc_group_ids + else [TdeiProjectGroupRole.MEMBER] + ), + ) + for pg_id in project_group_ids + ] + info.accessibleWorkspaceIds = accessible_workspace_ids or {} + info.osmWorkspaceRoles = osm_workspace_roles or {} + return info + + +def make_workspace( + *, + id: int | None = 1, + title: str = "Test Workspace", + type: WorkspaceType = WorkspaceType.OSW, + tdei_project_group_id: str = DEFAULT_PG_ID, + created_by: str = DEFAULT_USER_ID, + created_by_name: str = "Test User", + **extra, +) -> Workspace: + return Workspace( + id=id, + title=title, + type=type, + tdeiProjectGroupId=UUID(tdei_project_group_id), + createdBy=UUID(created_by), + createdByName=created_by_name, + createdAt=datetime(2026, 1, 1), + **extra, + ) + + +def make_user( + *, + id: int = 1, + auth_uid: str = DEFAULT_USER_ID, + email: str = "user@example.com", + display_name: str = "User One", +) -> User: + return User(id=id, auth_uid=auth_uid, email=email, display_name=display_name) + + +def make_team( + *, + id: int = 1, + name: str = "Team Alpha", + workspace_id: int = 1, + users: list[User] | None = None, +) -> WorkspaceTeam: + team = WorkspaceTeam(id=id, name=name, workspace_id=workspace_id) + # ``WorkspaceTeamItem.from_team`` reads ``len(team.users)``; set it + # explicitly since the relationship isn't loaded from a real DB here. + team.users = users or [] + return team diff --git a/tests/support/fakes.py b/tests/support/fakes.py new file mode 100644 index 0000000..61fcdd0 --- /dev/null +++ b/tests/support/fakes.py @@ -0,0 +1,197 @@ +"""Fake async DB session for integration tests. + +The application talks to its databases exclusively through SQLAlchemy / +SQLModel ``AsyncSession`` objects (the "data fetcher"). Repositories, +routes, schemas and serialization all sit *above* that boundary. + +``FakeSession`` stands in for a real session: instead of running SQL it +returns pre-programmed ``FakeResult`` objects from a FIFO queue. This lets +a test drive a real HTTP request through the real repository code while +feeding it simulated rows -- no Postgres, PostGIS or Docker required. + +Because the boundary is the session, a test must queue results in the +ORDER the repository issues queries. Each repository method documents its +query sequence; the integration tests show the common patterns. The most +common helpers are :func:`rows` (a SELECT result) and :func:`affected` +(an UPDATE/DELETE rowcount). +""" + +from collections import deque +from itertools import count + +from sqlalchemy.exc import NoResultFound + + +class _Scalars: + """Mimics the object returned by ``result.scalars()``.""" + + def __init__(self, rows): + self._rows = list(rows) + + def all(self): + return list(self._rows) + + def first(self): + return self._rows[0] if self._rows else None + + def __iter__(self): + return iter(self._rows) + + +class _Mappings: + """Mimics the object returned by ``result.mappings()`` (raw-SQL dict rows).""" + + def __init__(self, mappings): + self._mappings = list(mappings) + + def all(self): + return list(self._mappings) + + def first(self): + return self._mappings[0] if self._mappings else None + + def __iter__(self): + return iter(self._mappings) + + +class FakeResult: + """A canned result returned by :meth:`FakeSession.execute` / ``exec``. + + ``rows`` -- ORM entities for ``scalars()`` / ``scalar_one_or_none()``. + ``mappings`` -- dict rows for ``mappings()`` (used by raw-SQL queries). + ``rowcount`` -- value returned by ``result.rowcount`` (UPDATE/DELETE). + ``value`` -- value returned by ``session.scalar(...)``. + """ + + def __init__(self, rows=None, mappings=None, rowcount=1, value=None): + self._rows = list(rows) if rows is not None else [] + self._mappings = list(mappings) if mappings is not None else [] + self.rowcount = rowcount + self.value = value + + def scalars(self): + return _Scalars(self._rows) + + def scalar_one_or_none(self): + return self._rows[0] if self._rows else None + + def scalar_one(self): + if len(self._rows) != 1: + raise NoResultFound("FakeResult.scalar_one() expected exactly one row") + return self._rows[0] + + def mappings(self): + return _Mappings(self._mappings) + + def all(self): + # Used by queries that select multiple columns (rows are tuples). + return list(self._rows) + + def first(self): + return self._rows[0] if self._rows else None + + +class FakeSession: + """Drop-in async stand-in for a SQLModel ``AsyncSession``. + + Queue results with :meth:`queue` (or pass them to the constructor); each + ``execute`` / ``exec`` call pops the next one. ``commit`` / ``add`` / + ``rollback`` / ``refresh`` are recorded so tests can assert on writes. + """ + + def __init__(self, *responses): + self._responses = deque(responses) + self.added = [] + self.commits = 0 + self.rollbacks = 0 + self.closed = False + self._id_seq = count(1) + + def queue(self, *responses): + """Append results to the response queue. Returns self for chaining.""" + self._responses.extend(responses) + return self + + def _next(self): + return self._responses.popleft() if self._responses else FakeResult(rows=[]) + + @staticmethod + def _is_session_setup(statement): + # Raw ``SET search_path ...`` statements (used by the OSM bbox query) + # carry no result and should not consume a queued response. + try: + return str(statement).strip().upper().startswith("SET ") + except Exception: + return False + + @staticmethod + def _raise_if_exc(item): + # A queued exception simulates a DB-layer failure (drives 500 paths). + if isinstance(item, BaseException): + raise item + return item + + async def execute(self, statement, *args, **kwargs): + if self._is_session_setup(statement): + return FakeResult(rows=[]) + return self._raise_if_exc(self._next()) + + async def exec(self, statement, *args, **kwargs): + return self._raise_if_exc(self._next()) + + async def scalar(self, statement, *args, **kwargs): + result = self._raise_if_exc(self._next()) + return result.value if isinstance(result, FakeResult) else result + + def add(self, obj): + self.added.append(obj) + + async def commit(self): + self.commits += 1 + + async def rollback(self): + self.rollbacks += 1 + + async def refresh(self, obj, *args, **kwargs): + # Simulate the DB assigning an autoincrement primary key on insert. + if getattr(obj, "id", "missing") is None: + obj.id = next(self._id_seq) + + async def close(self): + self.closed = True + + +# --- result builders ------------------------------------------------------- + + +def rows(*entities, rowcount=None): + """A SELECT result yielding ``entities`` for ``scalars()`` / ``scalar_one*``.""" + return FakeResult( + rows=list(entities), + rowcount=len(entities) if rowcount is None else rowcount, + ) + + +def empty(): + """A SELECT result with no rows (drives NotFound paths).""" + return FakeResult(rows=[], rowcount=0) + + +def affected(n): + """An UPDATE/DELETE result reporting ``n`` affected rows.""" + return FakeResult(rows=[], rowcount=n) + + +def mappings(*dict_rows): + """A raw-SQL result yielding dict rows for ``result.mappings()``.""" + return FakeResult(mappings=list(dict_rows)) + + +def scalar(value): + """A result for ``session.scalar(...)`` (e.g. an EXISTS check).""" + return FakeResult(value=value) + + +def raises(exc): + """Queue an exception so the next DB call raises it (drives 500 paths).""" + return exc diff --git a/tests/support/http.py b/tests/support/http.py new file mode 100644 index 0000000..71cff70 --- /dev/null +++ b/tests/support/http.py @@ -0,0 +1,46 @@ +"""HTTP test doubles for the OSM proxy. + +The proxy streams upstream responses with ``response.aiter_raw()``. The +stock ``httpx.MockTransport`` eagerly buffers the body, so re-streaming it +raises ``StreamConsumed``. This transport returns a genuinely streamable +response and records the forwarded request for assertions. +""" + +import httpx + + +class StreamingMockTransport(httpx.AsyncBaseTransport): + """An httpx transport that returns streamable canned responses. + + ``handler(request) -> (status_code, headers_dict, body_bytes)``. + The most recent request is available as ``.last_request``. + """ + + def __init__(self, handler): + self._handler = handler + self.last_request: httpx.Request | None = None + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + # Read the request body so the upstream call completes cleanly. + await request.aread() + self.last_request = request + status_code, headers, body = self._handler(request) + + async def stream(): + yield body + + return httpx.Response(status_code, headers=headers, content=stream()) + + +def osm_mock_client(handler=None, *, base_url: str = "http://osm-web"): + """Build an ``AsyncClient`` standing in for the upstream OSM service. + + Returns ``(client, transport)`` so tests can inspect ``transport.last_request``. + """ + + def default_handler(_request): + return 200, {"content-type": "text/xml"}, b"" + + transport = StreamingMockTransport(handler or default_handler) + client = httpx.AsyncClient(transport=transport, base_url=base_url) + return client, transport diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index bc2f395..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,11 +0,0 @@ -from fastapi.testclient import TestClient - -from api.main import app - -client = TestClient(app) - - -def test_health_check(): - response = client.get("/health") - assert response.status_code == 200 - assert response.json() == {"status": "ok"} 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..e9fc3ad --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from typing import Any +from uuid import UUID, uuid4 + +import pytest + +# --------------------------------------------------------------------------- +# Fake workspace repository — tenancy gate without a DB. +# --------------------------------------------------------------------------- + + +class FakeWorkspaceRepo: + """Mirrors ``WorkspaceRepository.getById`` semantics. + + Returns a stub workspace if the caller's accessibleWorkspaceIds + contain the requested id; raises ``NotFoundException`` otherwise. + """ + + async def getById(self, current_user, workspace_id: int): + from api.core.exceptions import NotFoundException + + all_ids = { + wid for ids in current_user.accessibleWorkspaceIds.values() for wid in ids + } + if workspace_id not in all_ids: + raise NotFoundException(f"Workspace {workspace_id} not found") + # Echo the project-group id the user is a member of (any one + # works for unit tests — only the project-create route reads it). + pg_id = ( + next(iter(current_user.accessibleWorkspaceIds.keys())) + if current_user.accessibleWorkspaceIds + else "00000000-0000-0000-0000-000000000000" + ) + return SimpleNamespace(id=workspace_id, tdeiProjectGroupId=pg_id) + + +# --------------------------------------------------------------------------- +# Fake project repository — in-memory storage of project rows. +# --------------------------------------------------------------------------- + + +class FakeProjectRepo: + """In-memory stand-in for ``TaskingProjectRepository``. + + Stores projects in a dict keyed by id; returns ``ProjectResponse`` / + ``ProjectListResponse`` exactly as the real repo would. Does NOT + exercise PostGIS, ``ON CONFLICT``, soft-delete predicates, or + cross-table joins — those are integration-suite territory. + """ + + def __init__(self) -> None: + self._projects: dict[int, dict[str, Any]] = {} + self._aois: dict[int, dict[str, Any]] = {} + self._next_id = 1 + + # ---- helpers -------------------------------------------------------- + + def _response(self, p: dict[str, Any]): + from api.src.tasking.projects.dtos import ProjectResponse + + return ProjectResponse( + id=p["id"], + workspace_id=p["workspace_id"], + name=p["name"], + instructions=p.get("instructions"), + status=p["status"], + review_required=p["review_required"], + lock_timeout_hours=p["lock_timeout_hours"], + task_boundary_type=p.get("task_boundary_type"), + has_aoi=p["id"] in self._aois, + task_count=p.get("task_count", 0), + created_by=p["created_by"], + created_by_name=p.get("created_by_name"), + created_at=p["created_at"], + updated_at=p["updated_at"], + ) + + # ---- create / list / get / patch / delete -------------------------- + + async def create( + self, + workspace_id: int, + current_user, + body, + tdei_project_group_id: str | None = None, + ): + from api.core.exceptions import AlreadyExistsException + + if any( + p["workspace_id"] == workspace_id and p["name"] == body.name + for p in self._projects.values() + ): + raise AlreadyExistsException( + "A project with this name already exists in the workspace" + ) + pid = self._next_id + self._next_id += 1 + now = datetime.now() + self._projects[pid] = { + "id": pid, + "workspace_id": workspace_id, + "name": body.name, + "instructions": body.instructions, + "status": "draft", + "review_required": body.review_required, + "lock_timeout_hours": body.lock_timeout_hours, + "task_boundary_type": None, + "created_by": current_user.user_uuid, + "created_by_name": current_user.user_name, + "created_at": now, + "updated_at": now, + "deleted_at": None, + } + if body.aoi is not None: + self._aois[pid] = {"upcast_to": "MultiPolygon"} + return self._response(self._projects[pid]) + + async def list_projects( + self, + workspace_id: int, + *, + status_filter=None, + text_search=None, + page: int = 1, + page_size: int = 20, + order_by: str = "created_at", + order_dir: str = "DESC", + ): + from api.src.tasking.projects.dtos import ( + Pagination, + ProjectListItem, + ProjectListResponse, + ) + + rows = [ + p + for p in self._projects.values() + if p["workspace_id"] == workspace_id and p["deleted_at"] is None + ] + if status_filter is not None: + rows = [p for p in rows if p["status"] == status_filter] + if text_search: + t = text_search.lower() + rows = [p for p in rows if t in p["name"].lower()] + + total = len(rows) + offset = (page - 1) * page_size + items = [ + ProjectListItem( + id=p["id"], + name=p["name"], + status=p["status"], + task_count=0, + percent_completed=0, + created_by=p["created_by"], + created_by_name=p.get("created_by_name"), + created_at=p["created_at"], + updated_at=p["updated_at"], + ) + for p in rows[offset : offset + page_size] + ] + return ProjectListResponse( + results=items, + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + async def get(self, workspace_id: int, project_id: int): + from api.core.exceptions import NotFoundException + + p = self._projects.get(project_id) + if p is None or p["workspace_id"] != workspace_id or p["deleted_at"]: + raise NotFoundException(f"Project {project_id} not found") + return self._response(p) + + async def project_name_exists(self, workspace_id: int, name: str) -> bool: + return any( + p["workspace_id"] == workspace_id + and p["name"] == name + and p["deleted_at"] is None + for p in self._projects.values() + ) + + async def patch(self, workspace_id, project_id, body, current_user): + p_resp = await self.get(workspace_id, project_id) + p = self._projects[project_id] + if body.name is not None: + p["name"] = body.name.strip() + if body.instructions is not None: + p["instructions"] = body.instructions + if body.lock_timeout_hours is not None: + p["lock_timeout_hours"] = body.lock_timeout_hours + if body.review_required is not None: + p["review_required"] = body.review_required + p["updated_at"] = datetime.now() + return self._response(p) + + async def soft_delete(self, workspace_id, project_id, current_user): + await self.get(workspace_id, project_id) + self._projects[project_id]["deleted_at"] = datetime.now() + + async def activate(self, workspace_id, project_id, current_user): + from fastapi import HTTPException, status + + # Mirrors the real repo's "needs tasks" rule. + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project must have at least one task", + ) + + async def close(self, workspace_id, project_id, current_user): + from fastapi import HTTPException, status + + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Only open projects can be closed", + ) + + async def reset(self, workspace_id, project_id, current_user): + await self.get(workspace_id, project_id) + return self._response(self._projects[project_id]) + + # ---- AOI ------------------------------------------------------------ + + async def get_aoi(self, workspace_id, project_id): + from api.core.exceptions import NotFoundException + from api.src.tasking.projects.dtos import AoiFeature + from api.src.tasking.projects.schemas import _MultiPolygon + + await self.get(workspace_id, project_id) + if project_id not in self._aois: + raise NotFoundException("AOI is not set on this project") + return AoiFeature( + type="Feature", + geometry=_MultiPolygon( + type="MultiPolygon", + coordinates=[[[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]], + ), + properties={}, + ) + + async def upload_aoi(self, workspace_id, project_id, aoi, current_user): + from api.src.tasking.projects.dtos import AoiFeature + from api.src.tasking.projects.schemas import _MultiPolygon + + await self.get(workspace_id, project_id) + self._aois[project_id] = {"raw": aoi} + return AoiFeature( + type="Feature", + geometry=_MultiPolygon( + type="MultiPolygon", + coordinates=[[[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]], + ), + properties={}, + ) + + async def delete_aoi(self, workspace_id, project_id, current_user): + from api.core.exceptions import NotFoundException + + await self.get(workspace_id, project_id) + if project_id not in self._aois: + raise NotFoundException("AOI is not set on this project") + del self._aois[project_id] + + +# --------------------------------------------------------------------------- +# Dependency override fixtures. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_project_repo(): + """Swap ``get_project_repo`` with an in-memory FakeProjectRepo.""" + from api.main import app + from api.src.tasking.projects.routes import get_project_repo + + repo = FakeProjectRepo() + app.dependency_overrides[get_project_repo] = lambda: repo + yield repo + app.dependency_overrides.pop(get_project_repo, None) + + +@pytest.fixture +def fake_workspace_repo(): + """Swap ``get_workspace_repo`` with the in-memory FakeWorkspaceRepo.""" + from api.main import app + from api.src.tasking.projects.routes import get_workspace_repo + + repo = FakeWorkspaceRepo() + app.dependency_overrides[get_workspace_repo] = lambda: repo + yield repo + app.dependency_overrides.pop(get_workspace_repo, None) + + +# Convenience: most route tests need both. One fixture, request once. +@pytest.fixture +def fake_repos(fake_project_repo, fake_workspace_repo): + return SimpleNamespace( + project=fake_project_repo, + workspace=fake_workspace_repo, + ) diff --git a/tests/unit/test_aoi_normalisation.py b/tests/unit/test_aoi_normalisation.py new file mode 100644 index 0000000..8e84a21 --- /dev/null +++ b/tests/unit/test_aoi_normalisation.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException +from shapely.geometry import MultiPolygon as ShapelyMultiPolygon + +from api.src.tasking.projects.repository import _aoi_to_shapely +from api.src.tasking.projects.schemas import ( + _Feature, + _FeatureCollection, + _MultiPolygon, + _Polygon, +) + +SQUARE: list[list[list[float]]] = [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] +TWO_SQUARES: list[list[list[list[float]]]] = [ + [[[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_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..cc96fa0 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,96 @@ +"""Tests for api/core/config.py Settings. + +Covers the @test comments on the Settings class: +- env vars of the same name override the members +- unset env vars fall back to declared defaults +- strings / numbers / empty strings / URLs all load as the defaults exemplify +""" + +from api.core.config import Settings + + +def test_defaults_loaded_when_env_unset(): + # _env_file=None ignores any local .env so we observe the declared defaults. + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + + assert s.PROJECT_NAME == "Workspaces API" + assert s.CORS_ORIGINS == "" + assert s.DEBUG is False + assert s.SENTRY_DSN == "" + assert s.WS_OSM_HOST == "http://osm-web" + assert s.TDEI_OIDC_REALM == "tdei" + assert s.TASK_DATABASE_URL.startswith("postgresql+asyncpg://") + assert s.OSM_DATABASE_URL.startswith("postgresql+asyncpg://") + + +def test_env_vars_override_members(monkeypatch): + monkeypatch.setenv("PROJECT_NAME", "Custom Name") + monkeypatch.setenv("DEBUG", "true") + monkeypatch.setenv("WS_OSM_HOST", "http://osm.example") + monkeypatch.setenv("SENTRY_DSN", "https://sentry.example/123") + + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + + assert s.PROJECT_NAME == "Custom Name" + assert s.DEBUG is True + assert s.WS_OSM_HOST == "http://osm.example" + assert s.SENTRY_DSN == "https://sentry.example/123" + + +def test_cors_origins_parsed_from_json_env(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", "https://a.example,https://b.example") + + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + + assert s.CORS_ORIGINS == "https://a.example,https://b.example" + + +def test_cors_origins_list_comma_separated(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", "https://a.example, https://b.example") + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["https://a.example", "https://b.example"] + + +def test_cors_origins_list_json_array(monkeypatch): + # The deployment stack supplies a JSON array; it must parse, not become one + # malformed bracketed origin. + monkeypatch.setenv("CORS_ORIGINS", '["https://a.example","https://b.example"]') + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["https://a.example", "https://b.example"] + + +def test_cors_origins_list_single_json_array(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", '["https://only.example"]') + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["https://only.example"] + + +def test_cors_origins_list_empty_and_wildcard(monkeypatch): + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == [] + + monkeypatch.setenv("CORS_ORIGINS", "*") + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["*"] + + +def test_cors_origins_list_malformed_json_falls_back_to_comma(monkeypatch): + # A value that starts with "[" but isn't valid JSON should not crash; fall + # back to comma-splitting rather than raising. + monkeypatch.setenv("CORS_ORIGINS", "[not json") + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["[not json"] + + +def test_value_types_and_formats(): + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + + assert isinstance(s.PROJECT_NAME, str) + assert isinstance(s.CORS_ORIGINS, str) + assert isinstance(s.DEBUG, bool) + # empty-string default is preserved (not coerced to None): + assert s.SENTRY_DSN == "" + # URL-shaped defaults load verbatim: + assert s.TDEI_BACKEND_URL.startswith("https://") + assert s.LONGFORM_SCHEMA_URL.startswith("https://") + assert s.IMAGERY_SCHEMA_URL.startswith("https://") diff --git a/tests/unit/test_dtos_validation.py b/tests/unit/test_dtos_validation.py new file mode 100644 index 0000000..6e206ca --- /dev/null +++ b/tests/unit/test_dtos_validation.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from api.src.tasking.projects.dtos import ( + ProjectCreateRequest, + ProjectRoleAssignment, + ProjectUpdateRequest, +) + +# --------------------------------------------------------------------------- +# ProjectCreateRequest +# --------------------------------------------------------------------------- + + +class TestProjectCreateRequest: + def test_minimal_body_accepted(self): + """A body with just `name` populates defaults (review_required=True, lock_timeout=8h).""" + m = ProjectCreateRequest(name="hello") + assert m.name == "hello" + assert m.review_required is True + assert m.lock_timeout_hours == 8 + assert m.aoi is None + assert m.role_assignments == [] + + def test_name_blank_rejected(self): + """Whitespace-only project names are rejected with a clear 'blank' error.""" + with pytest.raises(ValidationError) as exc: + ProjectCreateRequest(name=" ") + assert "blank" in str(exc.value).lower() + + def test_name_too_long_rejected(self): + """Project names longer than 255 characters are rejected.""" + with pytest.raises(ValidationError): + ProjectCreateRequest(name="x" * 256) + + @pytest.mark.parametrize("hours", [0, -1, 721]) + def test_lock_timeout_out_of_range_rejected(self, hours): + """lock_timeout_hours must be in [1, 720]; out-of-range values are rejected.""" + with pytest.raises(ValidationError): + ProjectCreateRequest(name="ok", lock_timeout_hours=hours) + + @pytest.mark.parametrize("hours", [1, 8, 720]) + def test_lock_timeout_in_range_accepted(self, hours): + """lock_timeout_hours boundary values (1, default, 720) are accepted.""" + m = ProjectCreateRequest(name="ok", lock_timeout_hours=hours) + assert m.lock_timeout_hours == hours + + def test_instructions_too_long_rejected(self): + """Instructions over 10,000 characters are rejected.""" + with pytest.raises(ValidationError): + ProjectCreateRequest(name="ok", instructions="x" * 10_001) + + +# --------------------------------------------------------------------------- +# ProjectUpdateRequest +# --------------------------------------------------------------------------- + + +class TestProjectUpdateRequest: + def test_all_fields_optional(self): + """An empty PATCH body is valid — every field is optional.""" + m = ProjectUpdateRequest() + assert m.name is None + assert m.instructions is None + assert m.lock_timeout_hours is None + assert m.review_required is None + + def test_partial_update(self): + """Only specified fields are populated; the rest stay None.""" + m = ProjectUpdateRequest(name="x") + assert m.name == "x" + assert m.review_required is None + + +# --------------------------------------------------------------------------- +# ProjectRoleAssignment +# --------------------------------------------------------------------------- + + +class TestProjectRoleAssignment: + def test_valid_roles(self): + """Each of the three role strings ('lead', 'validator', 'contributor') is accepted.""" + from uuid import uuid4 + + for role in ("lead", "validator", "contributor"): + m = ProjectRoleAssignment(user_id=uuid4(), role=role) + assert m.role == role + + def test_invalid_role_rejected(self): + """Unknown role strings (e.g. 'admin') are rejected by the Literal.""" + from uuid import uuid4 + + with pytest.raises(ValidationError): + ProjectRoleAssignment(user_id=uuid4(), role="admin") # type: ignore[arg-type] diff --git a/tests/unit/test_json_schema.py b/tests/unit/test_json_schema.py new file mode 100644 index 0000000..3b83f87 --- /dev/null +++ b/tests/unit/test_json_schema.py @@ -0,0 +1,137 @@ +"""Tests for api/core/json_schema.py. + +Covers the @test comments: +- payloads are validated against the fetched JSON schema +- malformed / non-object payloads return a 400 (not a generic Exception) +- network failures map to a proper 502/504 and never produce a false-positive + (i.e. a fetch failure must raise, never silently "pass" validation) +""" + +from types import SimpleNamespace + +import httpx +import pytest +from fastapi import HTTPException + +import api.core.json_schema as js + +# --- validate_quest_definition_schema ------------------------------------- + + +async def test_quest_valid_definition_passes(monkeypatch): + async def fake_schema(): + return {"type": "object", "required": ["x"]} + + monkeypatch.setattr(js, "_fetch_longform_schema", fake_schema) + + # Should not raise. + await js.validate_quest_definition_schema('{"x": 1}') + + +async def test_quest_schema_violation_returns_400(monkeypatch): + async def fake_schema(): + return {"type": "object", "required": ["x"]} + + monkeypatch.setattr(js, "_fetch_longform_schema", fake_schema) + + with pytest.raises(HTTPException) as exc: + await js.validate_quest_definition_schema('{"y": 1}') + assert exc.value.status_code == 400 + + +async def test_quest_malformed_json_returns_400(): + with pytest.raises(HTTPException) as exc: + await js.validate_quest_definition_schema("{not valid json") + assert exc.value.status_code == 400 + + +async def test_quest_non_object_returns_400(): + with pytest.raises(HTTPException) as exc: + await js.validate_quest_definition_schema("[1, 2, 3]") + assert exc.value.status_code == 400 + + +async def test_quest_fetch_timeout_maps_to_504(monkeypatch): + async def boom(): + raise httpx.TimeoutException("slow") + + monkeypatch.setattr(js, "_fetch_longform_schema", boom) + + with pytest.raises(HTTPException) as exc: + await js.validate_quest_definition_schema('{"x": 1}') + assert exc.value.status_code == 504 + + +async def test_quest_fetch_connect_error_maps_to_502(monkeypatch): + async def boom(): + raise httpx.ConnectError("down") + + monkeypatch.setattr(js, "_fetch_longform_schema", boom) + + with pytest.raises(HTTPException) as exc: + await js.validate_quest_definition_schema('{"x": 1}') + assert exc.value.status_code == 502 + + +# --- validate_imagery_definition_schema ----------------------------------- + + +async def test_imagery_valid_passes(monkeypatch): + async def fake_schema(): + return {"type": "array", "items": {"type": "string"}} + + monkeypatch.setattr(js, "_fetch_imagery_schema", fake_schema) + + await js.validate_imagery_definition_schema(["a", "b"]) + + +async def test_imagery_violation_returns_400(monkeypatch): + async def fake_schema(): + return {"type": "array", "items": {"type": "string"}} + + monkeypatch.setattr(js, "_fetch_imagery_schema", fake_schema) + + with pytest.raises(HTTPException) as exc: + await js.validate_imagery_definition_schema([1, 2]) + assert exc.value.status_code == 400 + + +async def test_imagery_fetch_error_maps_to_502(monkeypatch): + async def boom(): + raise httpx.ConnectError("down") + + monkeypatch.setattr(js, "_fetch_imagery_schema", boom) + + with pytest.raises(HTTPException) as exc: + await js.validate_imagery_definition_schema([]) + assert exc.value.status_code == 502 + + +# --- client + caching ------------------------------------------------------ + + +def test_require_http_client_raises_503_when_uninitialized(monkeypatch): + monkeypatch.setattr(js, "_http_client", None) + with pytest.raises(HTTPException) as exc: + js._require_http_client() + assert exc.value.status_code == 503 + + +async def test_fetch_longform_schema_caches_result(monkeypatch): + monkeypatch.setattr(js, "_longform_schema", None) + calls = {"n": 0} + + class FakeClient: + async def get(self, url): + calls["n"] += 1 + return SimpleNamespace( + raise_for_status=lambda: None, json=lambda: {"fetched": True} + ) + + monkeypatch.setattr(js, "_http_client", FakeClient()) + + first = await js._fetch_longform_schema() + second = await js._fetch_longform_schema() + + assert first == second == {"fetched": True} + assert calls["n"] == 1 # second call served from cache diff --git a/tests/unit/test_jwt.py b/tests/unit/test_jwt.py new file mode 100644 index 0000000..2db88b3 --- /dev/null +++ b/tests/unit/test_jwt.py @@ -0,0 +1,71 @@ +"""Tests for api/core/jwt.py token validation. + +The @test comments are boilerplate; the module's real job is to validate a +JWT's RS256 signature against the JWKS and decode its payload. We cover: +- a properly-signed token validates and its payload is returned +- a malformed token raises (rather than returning a payload) +- a token signed by the wrong key raises (no false-positive validation) +- JWKS/network failures propagate as a typed error (not a bare Exception) +""" + +from types import SimpleNamespace + +import jwt as pyjwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from jwt.exceptions import PyJWKClientError, PyJWTError + +import api.core.jwt as jwtmod + + +@pytest.fixture +def rsa_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def _fake_jwks(monkeypatch, *, public_key=None, exc=None): + class FakeJWKSClient: + def get_signing_key_from_jwt(self, token): + if exc is not None: + raise exc + return SimpleNamespace(key=public_key) + + monkeypatch.setattr(jwtmod, "_get_jwks_client", lambda: FakeJWKSClient()) + + +def test_valid_token_decodes_payload(monkeypatch, rsa_key): + token = pyjwt.encode( + {"sub": "user-abc", "jti": "j1", "preferred_username": "alice"}, + rsa_key, + algorithm="RS256", + ) + _fake_jwks(monkeypatch, public_key=rsa_key.public_key()) + + payload = jwtmod.validate_and_decode_token(token) + + assert payload["sub"] == "user-abc" + assert payload["preferred_username"] == "alice" + + +def test_malformed_token_raises(monkeypatch, rsa_key): + _fake_jwks(monkeypatch, public_key=rsa_key.public_key()) + + with pytest.raises(PyJWTError): + jwtmod.validate_and_decode_token("not-a-real-jwt") + + +def test_token_signed_with_wrong_key_raises(monkeypatch, rsa_key): + other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + token = pyjwt.encode({"sub": "user-abc"}, other_key, algorithm="RS256") + # JWKS returns the *wrong* public key -> signature check must fail. + _fake_jwks(monkeypatch, public_key=rsa_key.public_key()) + + with pytest.raises(PyJWTError): + jwtmod.validate_and_decode_token(token) + + +def test_jwks_network_error_propagates_typed(monkeypatch): + _fake_jwks(monkeypatch, exc=PyJWKClientError("could not fetch JWKS")) + + with pytest.raises(PyJWKClientError): + jwtmod.validate_and_decode_token("a.b.c") diff --git a/tests/unit/test_project_routes.py b/tests/unit/test_project_routes.py new file mode 100644 index 0000000..792103a --- /dev/null +++ b/tests/unit/test_project_routes.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +import pytest + +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 + + @pytest.mark.parametrize("bad_value", ["not-an-object", 123, True, ["x"]]) + async def test_create_rejects_non_object_custom_imagery_422( + self, client, as_lead, seeded_workspace_id, fake_repos, bad_value + ): + """custom_imagery must be a JSON object; scalar/array values are rejected (422).""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "Pilot", "custom_imagery": bad_value}, + ) + 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" + + @pytest.mark.parametrize("bad_value", ["not-an-object", 123, False, ["x"]]) + async def test_patch_rejects_non_object_custom_imagery_422( + self, client, as_lead, seeded_workspace_id, fake_repos, bad_value + ): + """PATCH rejects custom_imagery when it is not a JSON object (422).""" + 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={"custom_imagery": bad_value}, + ) + assert r.status_code == 422 + + 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 + + +class TestProjectNameValidation: + async def test_validate_name_returns_false_when_missing( + self, client, as_contributor, seeded_workspace_id, fake_repos + ): + """Validation returns exists=false when no active project uses the name.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "new-project"}, + ) + assert r.status_code == 200 + assert r.json() == {"exists": False} + + async def test_validate_name_returns_true_when_exists( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """Validation returns exists=true when an active project with same name exists.""" + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "pilot-check"}, + ) + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "pilot-check"}, + ) + assert r.status_code == 200 + assert r.json() == {"exists": True} + + async def test_validate_name_blank_rejected_422( + self, client, as_contributor, seeded_workspace_id, fake_repos + ): + """Whitespace-only name is rejected with 422.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": " "}, + ) + assert r.status_code == 422 + + async def test_validate_name_outsider_404( + self, client, as_outsider, seeded_workspace_id, fake_repos + ): + """Tenancy gate hides workspace existence for outsiders.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "pilot-check"}, + ) + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# 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_security.py b/tests/unit/test_security.py new file mode 100644 index 0000000..1c6ae61 --- /dev/null +++ b/tests/unit/test_security.py @@ -0,0 +1,334 @@ +"""Tests for api/core/security.py. + +Covers the @test comments on the module / UserInfo / validate_token: +- the permission structure matches CLAUDE.md +- UserInfo methods return correct values for given PG/workspace roles +- attributes are populated correctly from JWT + TDEI + DB data +- network failures are handled gracefully (typed HTTP errors, no false success) +- the user-info cache works and evicts on token rotation / explicit eviction +""" + +from typing import cast +from uuid import UUID + +import httpx +import pytest +from fastapi import HTTPException +from fastapi.security import HTTPAuthorizationCredentials +from sqlmodel.ext.asyncio.session import AsyncSession + +import api.core.security as sec +from api.src.users.schemas import WorkspaceUserRoleType +from tests.support import factories, fakes + +USER_ID = "22222222-2222-2222-2222-222222222222" + + +@pytest.fixture(autouse=True) +def clear_cache(): + sec._user_info_cache.clear() + yield + sec._user_info_cache.clear() + + +def _creds(token="tok"): + return HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) + + +class _FakeResp: + def __init__(self, status_code=200, data=None, raises=False): + self.status_code = status_code + self._data = data + self._raises = raises + + def json(self): + if self._raises: + raise ValueError("bad json") + return self._data + + +class _FakeTdeiClient: + def __init__(self, resp=None, exc=None): + self._resp = resp + self._exc = exc + + async def get(self, *args, **kwargs): + if self._exc is not None: + raise self._exc + return self._resp + + +# --- CLAUDE.md permission structure --------------------------------------- + + +def test_permission_structure_matches_claude_md(): + # POC ("Project Group Admin") -> lead on workspaces the group owns. + poc = factories.make_user_info( + project_group_ids=["pg"], + poc_group_ids=("pg",), + accessible_workspace_ids={"pg": [1]}, + ) + assert poc.isWorkspaceLead(1) is True + + # Lead granted via Workspaces setting (an OSM-DB role). + lead = factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]} + ) + assert lead.effective_role(1) == WorkspaceUserRoleType.LEAD + + # Validator granted via Workspaces setting. + validator = factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.VALIDATOR]} + ) + assert validator.effective_role(1) == WorkspaceUserRoleType.VALIDATOR + + # Contributor implied by project-group membership. + contributor = factories.make_user_info(accessible_workspace_ids={"pg": [1]}) + assert contributor.isWorkspaceContributor(1) is True + assert contributor.effective_role(1) == WorkspaceUserRoleType.CONTRIBUTOR + + +def test_effective_role_precedence_lead_over_validator(): + user = factories.make_user_info( + osm_workspace_roles={ + 1: [WorkspaceUserRoleType.VALIDATOR, WorkspaceUserRoleType.LEAD] + } + ) + assert user.effective_role(1) == WorkspaceUserRoleType.LEAD + + +def test_tdei_role_enum_values(): + assert sec.TdeiProjectGroupRole.POINT_OF_CONTACT == "poc" + assert sec.TdeiProjectGroupRole.MEMBER == "member" + + +# --- validate_token: success + attribute population ------------------------ + + +async def _run_validate(monkeypatch, *, payload, tdei, task, osm): + monkeypatch.setattr(sec, "validate_and_decode_token", lambda _t: payload) + monkeypatch.setattr(sec, "_tdei_client", tdei) + return await sec.validate_token( + _creds(), + cast(AsyncSession, osm), + cast(AsyncSession, task), + ) + + +async def test_validate_token_populates_attributes(monkeypatch): + pg_id = "pg-1" + tdei = _FakeTdeiClient( + _FakeResp( + 200, + [ + { + "tdei_project_group_id": pg_id, + "project_group_name": "PG One", + "roles": ["poc"], + } + ], + ) + ) + task = fakes.FakeSession(fakes.mappings({"tdeiProjectGroupId": pg_id, "id": 5})) + osm = fakes.FakeSession(fakes.mappings({"workspace_id": 5, "role": "lead"})) + + info = await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j1", "preferred_username": "alice"}, + tdei=tdei, + task=task, + osm=osm, + ) + + assert info.user_uuid == UUID(USER_ID) + assert info.user_name == "alice" + assert info.getProjectGroupIds() == [pg_id] + assert info.accessibleWorkspaceIds == {pg_id: [5]} + assert info.osmWorkspaceRoles == {5: ["lead"]} + assert info.isWorkspaceLead(5) is True + + +# --- validate_token: graceful failure handling ----------------------------- + + +async def test_malformed_token_returns_401(monkeypatch): + def boom(_t): + raise ValueError("bad token") + + monkeypatch.setattr(sec, "validate_and_decode_token", boom) + with pytest.raises(HTTPException) as exc: + await sec.validate_token( + _creds(), + cast(AsyncSession, fakes.FakeSession()), + cast(AsyncSession, fakes.FakeSession()), + ) + assert exc.value.status_code == 401 + + +async def test_missing_sub_returns_401(monkeypatch): + monkeypatch.setattr(sec, "validate_and_decode_token", lambda _t: {"jti": "j"}) + with pytest.raises(HTTPException) as exc: + await sec.validate_token( + _creds(), + cast(AsyncSession, fakes.FakeSession()), + cast(AsyncSession, fakes.FakeSession()), + ) + assert exc.value.status_code == 401 + + +async def test_invalid_uuid_sub_returns_401(monkeypatch): + monkeypatch.setattr( + sec, "validate_and_decode_token", lambda _t: {"sub": "not-a-uuid"} + ) + with pytest.raises(HTTPException) as exc: + await sec.validate_token( + _creds(), + cast(AsyncSession, fakes.FakeSession()), + cast(AsyncSession, fakes.FakeSession()), + ) + assert exc.value.status_code == 401 + + +async def test_tdei_network_error_returns_502(monkeypatch): + tdei = _FakeTdeiClient(exc=httpx.ConnectError("down")) + with pytest.raises(HTTPException) as exc: + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j"}, + tdei=tdei, + task=fakes.FakeSession(), + osm=fakes.FakeSession(), + ) + assert exc.value.status_code == 502 + + +async def test_tdei_non_200_returns_401(monkeypatch): + tdei = _FakeTdeiClient(_FakeResp(403, None)) + with pytest.raises(HTTPException) as exc: + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j"}, + tdei=tdei, + task=fakes.FakeSession(), + osm=fakes.FakeSession(), + ) + assert exc.value.status_code == 401 + + +async def test_tdei_bad_json_returns_401(monkeypatch): + tdei = _FakeTdeiClient(_FakeResp(200, raises=True)) + with pytest.raises(HTTPException) as exc: + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j"}, + tdei=tdei, + task=fakes.FakeSession(), + osm=fakes.FakeSession(), + ) + assert exc.value.status_code == 401 + + +async def test_uninitialized_tdei_client_returns_503(monkeypatch): + with pytest.raises(HTTPException) as exc: + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j"}, + tdei=None, + task=fakes.FakeSession(), + osm=fakes.FakeSession(), + ) + assert exc.value.status_code == 503 + + +# --- caching --------------------------------------------------------------- + + +async def test_cache_hit_skips_refetch(monkeypatch): + pg_id = "pg-1" + + def fresh_tdei(): + return _FakeTdeiClient( + _FakeResp( + 200, + [ + { + "tdei_project_group_id": pg_id, + "project_group_name": "PG", + "roles": ["member"], + } + ], + ) + ) + + # First call populates the cache. + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j1"}, + tdei=fresh_tdei(), + task=fakes.FakeSession(fakes.mappings()), + osm=fakes.FakeSession(fakes.mappings()), + ) + + # Second call with the same jti must NOT hit TDEI again: a raising client + # proves the cached entry is returned without a refetch. + monkeypatch.setattr( + sec, "_tdei_client", _FakeTdeiClient(exc=httpx.ConnectError("x")) + ) + monkeypatch.setattr( + sec, "validate_and_decode_token", lambda _t: {"sub": USER_ID, "jti": "j1"} + ) + info = await sec.validate_token( + _creds(), + cast(AsyncSession, fakes.FakeSession()), + cast(AsyncSession, fakes.FakeSession()), + ) + assert info.user_uuid == UUID(USER_ID) + + +async def test_cache_evicts_on_token_rotation(monkeypatch): + pg_id = "pg-1" + + def tdei_with_role(role): + return _FakeTdeiClient( + _FakeResp( + 200, + [ + { + "tdei_project_group_id": pg_id, + "project_group_name": "PG", + "roles": [role], + } + ], + ) + ) + + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j1"}, + tdei=tdei_with_role("member"), + task=fakes.FakeSession(fakes.mappings()), + osm=fakes.FakeSession(fakes.mappings()), + ) + + # New jti -> stale entry evicted, fresh fetch performed. + info = await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j2"}, + tdei=tdei_with_role("poc"), + task=fakes.FakeSession(fakes.mappings()), + osm=fakes.FakeSession(fakes.mappings()), + ) + assert sec.TdeiProjectGroupRole.POINT_OF_CONTACT in info.projectGroups[0].tdeiRoles + + +def test_evict_user_from_cache_removes_entry(): + uid = UUID(USER_ID) + sentinel = factories.make_user_info(user_id=USER_ID) + sec._user_info_cache[uid] = sentinel + assert uid in sec._user_info_cache + + sec.evict_user_from_cache(uid) + assert uid not in sec._user_info_cache + + # Evicting an absent key is a no-op (no KeyError). + sec.evict_user_from_cache(uid) diff --git a/tests/unit/test_teams_schemas.py b/tests/unit/test_teams_schemas.py new file mode 100644 index 0000000..7a02421 --- /dev/null +++ b/tests/unit/test_teams_schemas.py @@ -0,0 +1,69 @@ +"""Schema tests for api/src/teams/schemas.py. + +Covers the @test comments on the table models: +- table name / columns match the DB schema +- foreign keys and relationships are correctly defined +- values serialize/deserialize without loss +""" + +import pytest +from pydantic import ValidationError + +from api.src.teams.schemas import ( + WorkspaceTeam, + WorkspaceTeamCreate, + WorkspaceTeamItem, + WorkspaceTeamUser, +) +from tests.support import factories + + +def _table(model): + # __table__ is added by SQLAlchemy at runtime; getattr keeps type-checkers happy. + return getattr(model, "__table__") + + +def _fk_targets(model): + return {fk.target_fullname for fk in _table(model).foreign_keys} + + +def test_team_user_link_table_schema(): + table = _table(WorkspaceTeamUser) + assert table.name == "team_user" + assert set(table.columns.keys()) == {"team_id", "user_id"} + assert {c.name for c in table.primary_key.columns} == {"team_id", "user_id"} + assert _fk_targets(WorkspaceTeamUser) == {"teams.id", "users.id"} + + +def test_team_table_schema(): + table = _table(WorkspaceTeam) + assert table.name == "teams" + assert {"id", "name", "workspace_id"} <= set(table.columns.keys()) + assert [c.name for c in table.primary_key.columns] == ["id"] + # workspace_id is indexed for lookups by workspace. + assert table.columns["workspace_id"].index is True + + +def test_team_has_users_relationship(): + # The many-to-many to User goes through the team_user link model. + rel = WorkspaceTeam.__sqlmodel_relationships__ + assert "users" in rel + + +def test_team_item_from_team_round_trip(): + user = factories.make_user(id=1) + team = factories.make_team(id=7, name="Alpha", users=[user]) + + item = WorkspaceTeamItem.from_team(team) + + assert item.id == 7 + assert item.name == "Alpha" + assert item.member_count == 1 + # Serializes cleanly to a dict for API responses. + assert item.model_dump() == {"id": 7, "name": "Alpha", "member_count": 1} + + +def test_team_create_requires_nonempty_name(): + assert WorkspaceTeamCreate(name="ok").name == "ok" + with pytest.raises(ValidationError): + WorkspaceTeamCreate(name="") diff --git a/tests/unit/test_token_bridge.py b/tests/unit/test_token_bridge.py new file mode 100644 index 0000000..20e338f --- /dev/null +++ b/tests/unit/test_token_bridge.py @@ -0,0 +1,219 @@ +"""Tests for the OSM token bridge in ``api/core/security.py``. + +``_bridge_token_to_osm`` mirrors a validated TDEI token into the OSM database's +``oauth_access_tokens`` table so osm-rails (doorkeeper) and cgimap authenticate +it through their standard OAuth2 path. It auto-provisions everything it needs: + +- a dedicated *system* ``users`` row that owns the doorkeeper application, +- the doorkeeper ``oauth_applications`` row (idempotent by the client uid), +- the caller's ``users`` row (owns the token), +- the plaintext ``oauth_access_tokens`` row (expires_in from the JWT exp). + +We cover: the no-op when disabled, the full provisioning chain when enabled, +the wiring of ids between rows, best-effort failure handling, and revocation. +""" + +import time +from typing import cast +from uuid import uuid4 + +from sqlmodel.ext.asyncio.session import AsyncSession + +import api.core.security as security +from api.core.config import settings + + +class RecordingSession: + """Async session stand-in that records (sql, params) and answers the two id + lookups: ``users`` (system vs caller, by auth_uid) and ``oauth_applications``.""" + + def __init__(self, system_user_id=1, caller_user_id=42, app_id=7): + self.calls: list[tuple[str, dict]] = [] + self.commits = 0 + self.rollbacks = 0 + self._system_user_id = system_user_id + self._caller_user_id = caller_user_id + self._app_id = app_id + + async def execute(self, statement, params=None): + params = params or {} + sql = str(statement) + self.calls.append((sql, params)) + + class _R: + def __init__(self, rows): + self._rows = rows + + def first(self): + return self._rows[0] if self._rows else None + + if "SELECT id FROM users" in sql: + if params.get("auth_uid") == settings.WS_OSM_SYSTEM_USER_AUTH_UID: + return _R([(self._system_user_id,)]) + return _R([(self._caller_user_id,)]) + if "SELECT id FROM oauth_applications" in sql: + return _R([(self._app_id,)]) + return _R([]) + + async def commit(self): + self.commits += 1 + + async def rollback(self): + self.rollbacks += 1 + + def sql_for(self, needle): + return [c for c in self.calls if needle in c[0]] + + +async def test_bridge_is_noop_when_disabled(monkeypatch): + """With the bridge disabled the bridge touches no DB.""" + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", False) + session = RecordingSession() + + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uuid4(), + user_name="alice", + email="alice@example.com", + token="tok", + exp=None, + ) + + assert session.calls == [] + assert session.commits == 0 + + +async def test_bridge_provisions_app_users_and_token(monkeypatch): + """Enabled: system user -> application -> caller user -> mirrored token, + with the ids wired between rows.""" + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", True) + monkeypatch.setattr(settings, "WS_OSM_OAUTH_CLIENT_UID", "workspaces-backend") + monkeypatch.setattr(settings, "WS_OSM_OAUTH_SCOPES", "write_api read_prefs") + uid = uuid4() + exp = int(time.time()) + 3600 + session = RecordingSession(system_user_id=1, caller_user_id=42, app_id=7) + + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uid, + user_name="alice", + email="alice@example.com", + token="jwt-token", + exp=exp, + ) + + # System user provisioned (owns the app) and caller user provisioned. + user_inserts = session.sql_for("INSERT INTO users") + inserted_auth_uids = {c[1]["auth_uid"] for c in user_inserts} + assert settings.WS_OSM_SYSTEM_USER_AUTH_UID in inserted_auth_uids + assert str(uid) in inserted_auth_uids + + # Application created idempotently by uid, owned by the system user (id 1). + app_inserts = session.sql_for("INSERT INTO oauth_applications") + assert app_inserts and "ON CONFLICT (uid)" in app_inserts[0][0] + assert app_inserts[0][1]["uid"] == "workspaces-backend" + assert app_inserts[0][1]["owner_id"] == 1 + assert app_inserts[0][1]["scopes"] == "write_api read_prefs" + + # Token mirrored: app id from the lookup, resource_owner = caller (42), + # self-healing upsert, exp-derived TTL. + token_inserts = session.sql_for("oauth_access_tokens") + assert token_inserts + sql, params = token_inserts[0] + assert "DO UPDATE" in sql and "revoked_at = NULL" in sql + assert params["app_id"] == 7 + assert params["user_id"] == 42 + assert params["token"] == "jwt-token" + assert params["scopes"] == "write_api read_prefs" + assert 3590 <= params["expires_in"] <= 3600 + + assert session.commits == 1 + assert session.rollbacks == 0 + + +async def test_bridge_synthesises_email_when_absent(monkeypatch): + """A caller without an email claim still provisions (email is UNIQUE/NOT NULL).""" + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", True) + uid = uuid4() + session = RecordingSession() + + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uid, + user_name="alice", + email=None, + token="jwt-token", + exp=None, + ) + + caller_insert = next( + c for c in session.sql_for("INSERT INTO users") if c[1]["auth_uid"] == str(uid) + ) + assert caller_insert[1]["email"] == f"{uid}@tdei.invalid" + + params = session.sql_for("oauth_access_tokens")[0][1] + assert params["expires_in"] is None + + +async def test_bridge_is_best_effort_on_db_error(monkeypatch): + """A DB failure must not propagate out of validation; it rolls back.""" + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", True) + + class BoomSession(RecordingSession): + async def execute(self, statement, params=None): + raise RuntimeError("db down") + + session = BoomSession() + + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uuid4(), + user_name="alice", + email=None, + token="tok", + exp=None, + ) + + assert session.rollbacks == 1 + assert session.commits == 0 + + +async def test_revoke_is_noop_when_disabled(monkeypatch): + """With the bridge off, revoking a rotated token touches no DB.""" + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", False) + session = RecordingSession() + + await security._revoke_osm_token(cast(AsyncSession, session), "old-token") + + assert session.calls == [] + assert session.commits == 0 + + +async def test_revoke_marks_only_the_superseded_token(monkeypatch): + """Revocation flips revoked_at for exactly the given token, if not already.""" + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", True) + session = RecordingSession() + + await security._revoke_osm_token(cast(AsyncSession, session), "old-token") + + updates = session.sql_for("UPDATE oauth_access_tokens") + assert updates and "revoked_at" in updates[0][0] + assert "WHERE token = :token AND revoked_at IS NULL" in updates[0][0] + assert updates[0][1]["token"] == "old-token" + assert session.commits == 1 + + +async def test_revoke_is_best_effort_on_db_error(monkeypatch): + """A DB failure during revocation must not propagate; it rolls back.""" + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", True) + + class BoomSession(RecordingSession): + async def execute(self, statement, params=None): + raise RuntimeError("db down") + + session = BoomSession() + + await security._revoke_osm_token(cast(AsyncSession, session), "tok") + + assert session.rollbacks == 1 + assert session.commits == 0 diff --git a/tests/unit/test_user_info.py b/tests/unit/test_user_info.py new file mode 100644 index 0000000..3547344 --- /dev/null +++ b/tests/unit/test_user_info.py @@ -0,0 +1,54 @@ +"""Unit tests for UserInfo permission logic (pure, no app or DB).""" + +from api.core.security import WorkspaceUserRoleType +from tests.support import factories + + +def test_get_project_group_ids(): + user = factories.make_user_info(project_group_ids=["pg-a", "pg-b"]) + assert user.getProjectGroupIds() == ["pg-a", "pg-b"] + + +def test_is_workspace_lead_via_osm_role(): + user = factories.make_user_info( + osm_workspace_roles={5: [WorkspaceUserRoleType.LEAD]} + ) + assert user.isWorkspaceLead(5) is True + assert user.isWorkspaceLead(6) is False + + +def test_is_workspace_lead_via_poc_group(): + # A point-of-contact in a group that owns workspace 9 is a lead there. + user = factories.make_user_info( + project_group_ids=["pg-owner"], + poc_group_ids=("pg-owner",), + accessible_workspace_ids={"pg-owner": [9]}, + ) + assert user.isWorkspaceLead(9) is True + assert user.isWorkspaceLead(10) is False + + +def test_poc_without_workspace_ownership_is_not_lead(): + user = factories.make_user_info( + project_group_ids=["pg-owner"], + poc_group_ids=("pg-owner",), + accessible_workspace_ids={"pg-owner": []}, + ) + assert user.isWorkspaceLead(9) is False + + +def test_is_workspace_validator(): + user = factories.make_user_info( + osm_workspace_roles={3: [WorkspaceUserRoleType.VALIDATOR]} + ) + assert user.isWorkspaceValidator(3) is True + assert user.isWorkspaceValidator(4) is False + + +def test_is_workspace_contributor(): + user = factories.make_user_info( + accessible_workspace_ids={"pg-a": [1, 2], "pg-b": [3]} + ) + assert user.isWorkspaceContributor(2) is True + assert user.isWorkspaceContributor(3) is True + assert user.isWorkspaceContributor(99) is False diff --git a/tests/unit/test_user_info_gates.py b/tests/unit/test_user_info_gates.py new file mode 100644 index 0000000..32fcfa2 --- /dev/null +++ b/tests/unit/test_user_info_gates.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from uuid import UUID + +from api.core.security import TdeiProjectGroupRole, UserInfo, UserInfoPGMembership +from api.src.users.schemas import WorkspaceUserRoleType + +PG = "00000000-0000-0000-0000-000000000001" + + +def _user(*, osm_roles=None, pg_roles=None, accessible=None): + u = UserInfo() + u.credentials = "x" + u.user_uuid = UUID("11111111-1111-1111-1111-111111111111") + u.user_name = "test" + u.osmWorkspaceRoles = osm_roles or {} + u.projectGroups = ( + [ + UserInfoPGMembership( + project_group_name="PG", + project_group_id=PG, + tdeiRoles=pg_roles or [TdeiProjectGroupRole.MEMBER], + ) + ] + if pg_roles is not None or accessible is not None + else [] + ) + u.accessibleWorkspaceIds = accessible or {} + return u + + +class TestIsWorkspaceLead: + def test_explicit_lead_role(self): + """User with WorkspaceUserRoleType.LEAD on the workspace is a lead.""" + u = _user(osm_roles={42: [WorkspaceUserRoleType.LEAD]}) + assert u.isWorkspaceLead(42) is True + + def test_poc_in_owning_pg_grants_lead(self): + """TDEI POINT_OF_CONTACT in the project group that owns the workspace implies lead.""" + u = _user( + pg_roles=[TdeiProjectGroupRole.POINT_OF_CONTACT], + accessible={PG: [42]}, + ) + assert u.isWorkspaceLead(42) is True + + def test_member_only_is_not_lead(self): + """A plain TDEI MEMBER (no POC, no explicit LEAD) is NOT a lead.""" + u = _user( + pg_roles=[TdeiProjectGroupRole.MEMBER], + accessible={PG: [42]}, + ) + assert u.isWorkspaceLead(42) is False + + def test_no_membership_is_not_lead(self): + """A user with no project-group membership at all is NOT a lead.""" + assert _user().isWorkspaceLead(42) is False + + +class TestIsWorkspaceValidator: + def test_explicit_validator_role(self): + """User with WorkspaceUserRoleType.VALIDATOR on the workspace is a validator.""" + u = _user(osm_roles={42: [WorkspaceUserRoleType.VALIDATOR]}) + assert u.isWorkspaceValidator(42) is True + + def test_lead_is_not_implicit_validator(self): + """LEAD does NOT implicitly grant VALIDATOR — distinct roles by design.""" + u = _user(osm_roles={42: [WorkspaceUserRoleType.LEAD]}) + assert u.isWorkspaceValidator(42) is False + + +class TestIsWorkspaceContributor: + def test_any_pg_membership_is_contributor(self): + """Any project-group membership that owns the workspace makes the user a contributor.""" + u = _user( + pg_roles=[TdeiProjectGroupRole.MEMBER], + accessible={PG: [42]}, + ) + assert u.isWorkspaceContributor(42) is True + + def test_outsider_is_not_contributor(self): + """An outsider (no PG membership) is NOT a contributor.""" + assert _user().isWorkspaceContributor(42) is False diff --git a/tests/unit/test_users_schemas.py b/tests/unit/test_users_schemas.py new file mode 100644 index 0000000..2db9f84 --- /dev/null +++ b/tests/unit/test_users_schemas.py @@ -0,0 +1,78 @@ +"""Schema tests for api/src/users/schemas.py. + +Covers the @test comments on the User and WorkspaceUserRole table models: +- table name / columns match the DB schema (and the Alembic migration) +- foreign keys and relationships are correctly defined +- role enum values serialize/deserialize without loss +""" + +import pytest +from pydantic import ValidationError + +from api.src.users.schemas import ( + SetRoleRequest, + User, + WorkspaceUserRole, + WorkspaceUserRoleType, +) + + +def _table(model): + # __table__ is added by SQLAlchemy at runtime; getattr keeps type-checkers happy. + return getattr(model, "__table__") + + +def _fk_targets(model): + return {fk.target_fullname for fk in _table(model).foreign_keys} + + +def test_user_table_schema(): + table = _table(User) + assert table.name == "users" + assert {"id", "auth_uid", "email", "display_name"} <= set(table.columns.keys()) + assert [c.name for c in table.primary_key.columns] == ["id"] + # auth_uid and email are unique + indexed. + assert table.columns["auth_uid"].unique is True + assert table.columns["email"].unique is True + + +def test_user_has_teams_relationship(): + assert "teams" in User.__sqlmodel_relationships__ + + +def test_workspace_user_role_table_matches_alembic(): + # Mirrors alembic_osm migration 9221408912dd: + # user_workspace_roles(user_auth_uid, workspace_id, role enum), + # PK(user_auth_uid, workspace_id), FK user_auth_uid -> users.auth_uid + table = _table(WorkspaceUserRole) + assert table.name == "user_workspace_roles" + assert set(table.columns.keys()) == {"user_auth_uid", "workspace_id", "role"} + assert {c.name for c in table.primary_key.columns} == { + "user_auth_uid", + "workspace_id", + } + assert _fk_targets(WorkspaceUserRole) == {"users.auth_uid"} + + +def test_role_enum_values(): + assert WorkspaceUserRoleType.LEAD == "lead" + assert WorkspaceUserRoleType.VALIDATOR == "validator" + assert WorkspaceUserRoleType.CONTRIBUTOR == "contributor" + + +def test_set_role_request_rejects_contributor(): + # CONTRIBUTOR is implicit and may not be assigned directly. + assert SetRoleRequest(role=WorkspaceUserRoleType.LEAD).role == ( + WorkspaceUserRoleType.LEAD + ) + with pytest.raises(ValidationError): + SetRoleRequest(role=WorkspaceUserRoleType.CONTRIBUTOR) + + +def test_user_serialization_round_trip(): + user = User(id=3, auth_uid="abc", email="u@example.com", display_name="U") + dumped = user.model_dump() + assert dumped["id"] == 3 + assert dumped["auth_uid"] == "abc" + assert dumped["email"] == "u@example.com" + assert dumped["display_name"] == "U" diff --git a/tests/unit/test_workspace_repository.py b/tests/unit/test_workspace_repository.py new file mode 100644 index 0000000..84fc9e4 --- /dev/null +++ b/tests/unit/test_workspace_repository.py @@ -0,0 +1,94 @@ +"""Unit tests for WorkspaceRepository against a fake session. + +These exercise the repository in isolation (no HTTP layer) to show how the +data-fetcher boundary is mocked: queue the rows the DB "would" return, then +assert on the repository's behavior and writes. +""" + +from typing import cast +from uuid import UUID + +import pytest +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import ForbiddenException, NotFoundException +from api.src.workspaces.repository import WorkspaceRepository +from api.src.workspaces.schemas import WorkspaceCreate, WorkspaceType +from tests.support import factories, fakes + + +def _repo(session: fakes.FakeSession) -> WorkspaceRepository: + # FakeSession implements the AsyncSession surface the repository uses. + return WorkspaceRepository(cast(AsyncSession, session)) + + +@pytest.fixture +def user(): + return factories.make_user_info() + + +async def test_get_by_id_returns_workspace(user): + workspace = factories.make_workspace(id=1, title="Mapping WS") + session = fakes.FakeSession(fakes.rows(workspace)) + + result = await _repo(session).getById(user, 1) + + assert result.id == 1 + assert result.title == "Mapping WS" + + +async def test_get_by_id_missing_raises_not_found(user): + session = fakes.FakeSession(fakes.empty()) + + with pytest.raises(NotFoundException): + await _repo(session).getById(user, 404) + + +async def test_get_all_returns_list(user): + session = fakes.FakeSession( + fakes.rows(factories.make_workspace(id=1), factories.make_workspace(id=2)) + ) + + result = await _repo(session).getAll(user) + + assert [w.id for w in result] == [1, 2] + + +async def test_create_commits_and_assigns_id(user): + session = fakes.FakeSession() + + workspace = await _repo(session).create( + user, + WorkspaceCreate( + type=WorkspaceType.OSW, + title="Brand New", + tdeiProjectGroupId=UUID(factories.DEFAULT_PG_ID), + ), + ) + + assert session.commits == 1 + assert workspace in session.added + assert workspace.id is not None # assigned by refresh() + assert workspace.createdByName == "Test User" + + +async def test_create_in_unauthorized_group_raises(user): + session = fakes.FakeSession() + + with pytest.raises(ForbiddenException): + await _repo(session).create( + user, + WorkspaceCreate( + type=WorkspaceType.OSW, + title="Forbidden", + tdeiProjectGroupId=UUID("99999999-9999-9999-9999-999999999999"), + ), + ) + assert session.commits == 0 + + +async def test_delete_missing_raises_not_found(user): + session = fakes.FakeSession(fakes.affected(0)) + + with pytest.raises(NotFoundException): + await _repo(session).delete(user, 1) diff --git a/tests/unit/test_workspaces_schemas.py b/tests/unit/test_workspaces_schemas.py new file mode 100644 index 0000000..15f6266 --- /dev/null +++ b/tests/unit/test_workspaces_schemas.py @@ -0,0 +1,202 @@ +"""Schema tests for api/src/workspaces/schemas.py. + +Covers the @test comments on the table models and WorkspaceResponse: +- table names / columns / PKs / FKs match the DB schema +- relationships are correctly defined +- enum TypeDecorators serialize to the DB and back without loss +- UUID / datetime values round-trip without precision loss +- WorkspaceResponse.from_workspace serializes for API responses incl. role +""" + +from datetime import datetime +from uuid import UUID + +import pytest +from pydantic import ValidationError +from sqlalchemy.engine.default import DefaultDialect + +from api.src.users.schemas import WorkspaceUserRoleType +from api.src.workspaces.schemas import ( + ExternalAppsDefinitionType, + IntEnumType, + QuestDefinitionType, + QuestDefinitionTypeName, + QuestSettingsPatch, + StrEnumType, + Workspace, + WorkspaceImagery, + WorkspaceLongQuest, + WorkspaceResponse, + WorkspaceType, +) +from tests.support import factories + +_DIALECT = DefaultDialect() + + +def _table(model): + # __table__ is added by SQLAlchemy at runtime; getattr keeps type-checkers happy. + return getattr(model, "__table__") + + +def _fk_targets(model): + return {fk.target_fullname for fk in _table(model).foreign_keys} + + +# --- table schemas --------------------------------------------------------- + + +def test_workspace_table_schema(): + table = _table(Workspace) + assert table.name == "workspaces" + expected = { + "id", + "type", + "title", + "description", + "tdeiProjectGroupId", + "tdeiRecordId", + "tdeiServiceId", + "tdeiMetadata", + "createdAt", + "createdBy", + "createdByName", + "geometry", + "externalAppAccess", + "kartaViewToken", + } + assert expected <= set(table.columns.keys()) + assert [c.name for c in table.primary_key.columns] == ["id"] + + +def test_workspace_relationships_defined(): + rels = Workspace.__sqlmodel_relationships__ + assert "longFormQuestDef" in rels + assert "imageryListDef" in rels + + +def test_long_quest_table_schema(): + table = _table(WorkspaceLongQuest) + assert table.name == "workspaces_long_quests" + assert {"workspace_id", "type", "definition", "url", "modifiedAt"} <= set( + table.columns.keys() + ) + assert [c.name for c in table.primary_key.columns] == ["workspace_id"] + assert _fk_targets(WorkspaceLongQuest) == {"workspaces.id"} + + +def test_imagery_table_schema(): + table = _table(WorkspaceImagery) + assert table.name == "workspaces_imagery" + assert {"workspace_id", "definition", "modifiedAt"} <= set(table.columns.keys()) + assert [c.name for c in table.primary_key.columns] == ["workspace_id"] + assert _fk_targets(WorkspaceImagery) == {"workspaces.id"} + + +# --- enum TypeDecorator round-trips (Python <-> DB) ------------------------ + + +def test_int_enum_type_round_trip(): + deco = IntEnumType(QuestDefinitionType) + # bind: enum -> int + assert deco.process_bind_param(QuestDefinitionType.JSON, _DIALECT) == 1 + # result: int -> enum + assert deco.process_result_value(1, _DIALECT) == QuestDefinitionType.JSON + # None passes through both directions + assert deco.process_bind_param(None, _DIALECT) is None + assert deco.process_result_value(None, _DIALECT) is None + + +def test_str_enum_type_round_trip(): + deco = StrEnumType(WorkspaceType) + assert deco.process_bind_param(WorkspaceType.OSW, _DIALECT) == "osw" + assert deco.process_result_value("osw", _DIALECT) == WorkspaceType.OSW + assert deco.process_bind_param(None, _DIALECT) is None + assert deco.process_result_value(None, _DIALECT) is None + + +def test_external_apps_enum_round_trip(): + deco = IntEnumType(ExternalAppsDefinitionType) + assert deco.process_bind_param(ExternalAppsDefinitionType.PUBLIC, _DIALECT) == 1 + assert ( + deco.process_result_value(2, _DIALECT) + == ExternalAppsDefinitionType.PROJECT_GROUP + ) + + +# --- value preservation ---------------------------------------------------- + + +def test_workspace_preserves_uuid_and_datetime(): + pg = UUID("11111111-1111-1111-1111-111111111111") + created = datetime(2026, 1, 2, 3, 4, 5) + ws = Workspace( + id=1, + type=WorkspaceType.OSW, + title="T", + tdeiProjectGroupId=pg, + createdBy=pg, + createdByName="N", + createdAt=created, + ) + assert ws.tdeiProjectGroupId == pg + assert ws.createdAt == created # no truncation + assert ws.type == WorkspaceType.OSW + + +# --- WorkspaceResponse serialization -------------------------------------- + + +def test_workspace_response_includes_effective_role(): + user = factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]} + ) + ws = factories.make_workspace(id=1, title="Mappy") + + resp = WorkspaceResponse.from_workspace(ws, user) + + assert resp.id == 1 + assert resp.title == "Mappy" + assert resp.role == WorkspaceUserRoleType.LEAD + assert resp.type == WorkspaceType.OSW + + +def test_workspace_response_passes_through_defs(): + user = factories.make_user_info() + ws = factories.make_workspace(id=2) + + resp = WorkspaceResponse.from_workspace( + ws, user, imagery_list_def=[{"a": 1}], long_form_quest_def={"q": 2} + ) + + assert resp.imageryListDef == [{"a": 1}] + assert resp.longFormQuestDef == {"q": 2} + assert resp.role == WorkspaceUserRoleType.CONTRIBUTOR + + +# --- QuestSettingsPatch validation ---------------------------------------- + + +def test_quest_settings_json_requires_object_definition(): + ok = QuestSettingsPatch(type=QuestDefinitionTypeName.JSON, definition='{"a": 1}') + assert ok.type == QuestDefinitionTypeName.JSON + + with pytest.raises(ValidationError): + QuestSettingsPatch(type=QuestDefinitionTypeName.JSON, definition=None) + with pytest.raises(ValidationError): + QuestSettingsPatch(type=QuestDefinitionTypeName.JSON, definition="not-json") + + +def test_quest_settings_url_requires_url(): + ok = QuestSettingsPatch(type=QuestDefinitionTypeName.URL, url="https://x") + assert ok.url == "https://x" + with pytest.raises(ValidationError): + QuestSettingsPatch(type=QuestDefinitionTypeName.URL, url=None) + + +def test_quest_settings_none_rejects_payload(): + assert QuestSettingsPatch(type=QuestDefinitionTypeName.NONE).type == ( + QuestDefinitionTypeName.NONE + ) + with pytest.raises(ValidationError): + QuestSettingsPatch(type=QuestDefinitionTypeName.NONE, definition='{"a":1}') diff --git a/uv.lock b/uv.lock index bbd35b2..c8e01b6 100644 --- a/uv.lock +++ b/uv.lock @@ -4,16 +4,25 @@ requires-python = ">=3.12" [[package]] name = "alembic" -version = "1.14.0" +version = "1.18.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/1e/8cb8900ba1b6360431e46fb7a89922916d3a1b017a8908a7c0499cc7e5f6/alembic-1.14.0.tar.gz", hash = "sha256:b00892b53b3642d0b8dbedba234dbf1924b69be83a9a769d5a624b01094e304b", size = 1916172, upload-time = "2024-11-04T18:44:22.066Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/06/8b505aea3d77021b18dcbd8133aa1418f1a1e37e432a465b14c46b2c0eaa/alembic-1.14.0-py3-none-any.whl", hash = "sha256:99bd884ca390466db5e27ffccff1d179ec5c05c965cfefc0607e69f9e411cb25", size = 233482, upload-time = "2024-11-04T18:44:24.335Z" }, + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] @@ -41,26 +50,42 @@ wheels = [ [[package]] name = "asyncpg" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746, upload-time = "2024-10-20T00:30:41.127Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/64/9d3e887bb7b01535fdbc45fbd5f0a8447539833b97ee69ecdbb7a79d0cb4/asyncpg-0.30.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c902a60b52e506d38d7e80e0dd5399f657220f24635fee368117b8b5fce1142e", size = 673162, upload-time = "2024-10-20T00:29:41.88Z" }, - { url = "https://files.pythonhosted.org/packages/6e/eb/8b236663f06984f212a087b3e849731f917ab80f84450e943900e8ca4052/asyncpg-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aca1548e43bbb9f0f627a04666fedaca23db0a31a84136ad1f868cb15deb6e3a", size = 637025, upload-time = "2024-10-20T00:29:43.352Z" }, - { url = "https://files.pythonhosted.org/packages/cc/57/2dc240bb263d58786cfaa60920779af6e8d32da63ab9ffc09f8312bd7a14/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3", size = 3496243, upload-time = "2024-10-20T00:29:44.922Z" }, - { url = "https://files.pythonhosted.org/packages/f4/40/0ae9d061d278b10713ea9021ef6b703ec44698fe32178715a501ac696c6b/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737", size = 3575059, upload-time = "2024-10-20T00:29:46.891Z" }, - { url = "https://files.pythonhosted.org/packages/c3/75/d6b895a35a2c6506952247640178e5f768eeb28b2e20299b6a6f1d743ba0/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5712350388d0cd0615caec629ad53c81e506b1abaaf8d14c93f54b35e3595a", size = 3473596, upload-time = "2024-10-20T00:29:49.201Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e7/3693392d3e168ab0aebb2d361431375bd22ffc7b4a586a0fc060d519fae7/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db9891e2d76e6f425746c5d2da01921e9a16b5a71a1c905b13f30e12a257c4af", size = 3641632, upload-time = "2024-10-20T00:29:50.768Z" }, - { url = "https://files.pythonhosted.org/packages/32/ea/15670cea95745bba3f0352341db55f506a820b21c619ee66b7d12ea7867d/asyncpg-0.30.0-cp312-cp312-win32.whl", hash = "sha256:68d71a1be3d83d0570049cd1654a9bdfe506e794ecc98ad0873304a9f35e411e", size = 560186, upload-time = "2024-10-20T00:29:52.394Z" }, - { url = "https://files.pythonhosted.org/packages/7e/6b/fe1fad5cee79ca5f5c27aed7bd95baee529c1bf8a387435c8ba4fe53d5c1/asyncpg-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:9a0292c6af5c500523949155ec17b7fe01a00ace33b68a476d6b5059f9630305", size = 621064, upload-time = "2024-10-20T00:29:53.757Z" }, - { url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373, upload-time = "2024-10-20T00:29:55.165Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745, upload-time = "2024-10-20T00:29:57.14Z" }, - { url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103, upload-time = "2024-10-20T00:29:58.499Z" }, - { url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471, upload-time = "2024-10-20T00:30:00.354Z" }, - { url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253, upload-time = "2024-10-20T00:30:02.794Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720, upload-time = "2024-10-20T00:30:04.501Z" }, - { url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404, upload-time = "2024-10-20T00:30:06.537Z" }, - { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623, upload-time = "2024-10-20T00:30:09.024Z" }, +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] [[package]] @@ -74,14 +99,14 @@ wheels = [ [[package]] name = "autoflake" -version = "2.3.1" +version = "2.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyflakes" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/cb/486f912d6171bc5748c311a2984a301f4e2d054833a1da78485866c71522/autoflake-2.3.1.tar.gz", hash = "sha256:c98b75dc5b0a86459c4f01a1d32ac7eb4338ec4317a4469515ff1e687ecd909e", size = 27642, upload-time = "2024-03-13T03:41:28.977Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/0b/70c277eef225133763bf05c02c88df182e57d5c5c0730d3998958096a82e/autoflake-2.3.3.tar.gz", hash = "sha256:c24809541e23999f7a7b0d2faadf15deb0bc04cdde49728a2fd943a0c8055504", size = 16515, upload-time = "2026-02-20T05:01:43.448Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/ee/3fd29bf416eb4f1c5579cf12bf393ae954099258abd7bde03c4f9716ef6b/autoflake-2.3.1-py3-none-any.whl", hash = "sha256:3ae7495db9084b7b32818b4140e6dc4fc280b712fb414f5b8fe57b0a8e85a840", size = 32483, upload-time = "2024-03-13T03:41:26.969Z" }, + { url = "https://files.pythonhosted.org/packages/da/21/26f1680ec3a598ea31768f9ebcd427e42986d077a005416094b580635532/autoflake-2.3.3-py3-none-any.whl", hash = "sha256:a51a3412aff16135ee5b3ec25922459fef10c1f23ce6d6c4977188df859e8b53", size = 17715, upload-time = "2026-02-20T05:01:42.137Z" }, ] [[package]] @@ -105,7 +130,7 @@ wheels = [ [[package]] name = "black" -version = "24.10.0" +version = "26.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -113,18 +138,26 @@ dependencies = [ { name = "packaging" }, { name = "pathspec" }, { name = "platformdirs" }, + { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/0d/cc2fb42b8c50d80143221515dd7e4766995bd07c56c9a3ed30baf080b6dc/black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", size = 645813, upload-time = "2024-10-07T19:20:50.361Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/04/bf74c71f592bcd761610bbf67e23e6a3cff824780761f536512437f1e655/black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3", size = 1644256, upload-time = "2024-10-07T19:27:53.355Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ea/a77bab4cf1887f4b2e0bce5516ea0b3ff7d04ba96af21d65024629afedb6/black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65", size = 1448534, upload-time = "2024-10-07T19:26:44.953Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3e/443ef8bc1fbda78e61f79157f303893f3fddf19ca3c8989b163eb3469a12/black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f", size = 1761892, upload-time = "2024-10-07T19:24:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/52/93/eac95ff229049a6901bc84fec6908a5124b8a0b7c26ea766b3b8a5debd22/black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8", size = 1434796, upload-time = "2024-10-07T19:25:06.239Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a0/a993f58d4ecfba035e61fca4e9f64a2ecae838fc9f33ab798c62173ed75c/black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981", size = 1643986, upload-time = "2024-10-07T19:28:50.684Z" }, - { url = "https://files.pythonhosted.org/packages/37/d5/602d0ef5dfcace3fb4f79c436762f130abd9ee8d950fa2abdbf8bbc555e0/black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b", size = 1448085, upload-time = "2024-10-07T19:28:12.093Z" }, - { url = "https://files.pythonhosted.org/packages/47/6d/a3a239e938960df1a662b93d6230d4f3e9b4a22982d060fc38c42f45a56b/black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2", size = 1760928, upload-time = "2024-10-07T19:24:15.233Z" }, - { url = "https://files.pythonhosted.org/packages/dd/cf/af018e13b0eddfb434df4d9cd1b2b7892bab119f7a20123e93f6910982e8/black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b", size = 1436875, upload-time = "2024-10-07T19:24:42.762Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a7/4b27c50537ebca8bec139b872861f9d2bf501c5ec51fcf897cb924d9e264/black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", size = 206898, upload-time = "2024-10-07T19:20:48.317Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] [[package]] @@ -160,35 +193,87 @@ wheels = [ [[package]] name = "cffi" -version = "1.17.1" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, - { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, - { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, - { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, ] [[package]] @@ -318,33 +403,55 @@ wheels = [ [[package]] name = "cryptography" -version = "44.0.0" +version = "48.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/4c/45dfa6829acffa344e3967d6006ee4ae8be57af746ae2eba1c431949b32c/cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02", size = 710657, upload-time = "2024-11-27T18:07:10.168Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/09/8cc67f9b84730ad330b3b72cf867150744bf07ff113cda21a15a1c6d2c7c/cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123", size = 6541833, upload-time = "2024-11-27T18:05:55.475Z" }, - { url = "https://files.pythonhosted.org/packages/7e/5b/3759e30a103144e29632e7cb72aec28cedc79e514b2ea8896bb17163c19b/cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092", size = 3922710, upload-time = "2024-11-27T18:05:58.621Z" }, - { url = "https://files.pythonhosted.org/packages/5f/58/3b14bf39f1a0cfd679e753e8647ada56cddbf5acebffe7db90e184c76168/cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f", size = 4137546, upload-time = "2024-11-27T18:06:01.062Z" }, - { url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420, upload-time = "2024-11-27T18:06:03.487Z" }, - { url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498, upload-time = "2024-11-27T18:06:05.763Z" }, - { url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569, upload-time = "2024-11-27T18:06:07.489Z" }, - { url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721, upload-time = "2024-11-27T18:06:11.57Z" }, - { url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915, upload-time = "2024-11-27T18:06:13.515Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925, upload-time = "2024-11-27T18:06:16.019Z" }, - { url = "https://files.pythonhosted.org/packages/64/b1/50d7739254d2002acae64eed4fc43b24ac0cc44bf0a0d388d1ca06ec5bb1/cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd", size = 3202055, upload-time = "2024-11-27T18:06:19.113Z" }, - { url = "https://files.pythonhosted.org/packages/11/18/61e52a3d28fc1514a43b0ac291177acd1b4de00e9301aaf7ef867076ff8a/cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591", size = 6542801, upload-time = "2024-11-27T18:06:21.431Z" }, - { url = "https://files.pythonhosted.org/packages/1a/07/5f165b6c65696ef75601b781a280fc3b33f1e0cd6aa5a92d9fb96c410e97/cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7", size = 3922613, upload-time = "2024-11-27T18:06:24.314Z" }, - { url = "https://files.pythonhosted.org/packages/28/34/6b3ac1d80fc174812486561cf25194338151780f27e438526f9c64e16869/cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc", size = 4137925, upload-time = "2024-11-27T18:06:27.079Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417, upload-time = "2024-11-27T18:06:28.959Z" }, - { url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160, upload-time = "2024-11-27T18:06:30.866Z" }, - { url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331, upload-time = "2024-11-27T18:06:33.432Z" }, - { url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372, upload-time = "2024-11-27T18:06:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657, upload-time = "2024-11-27T18:06:41.045Z" }, - { url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672, upload-time = "2024-11-27T18:06:43.566Z" }, - { url = "https://files.pythonhosted.org/packages/97/9b/443270b9210f13f6ef240eff73fd32e02d381e7103969dc66ce8e89ee901/cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede", size = 3202071, upload-time = "2024-11-27T18:06:45.586Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, ] [[package]] @@ -365,6 +472,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + [[package]] name = "ecdsa" version = "0.19.0" @@ -392,93 +513,129 @@ wheels = [ [[package]] name = "fastapi" -version = "0.115.6" +version = "0.139.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/72/d83b98cd106541e8f5e5bfab8ef2974ab45a62e8a6c5b5e6940f26d2ed4b/fastapi-0.115.6.tar.gz", hash = "sha256:9ec46f7addc14ea472958a96aae5b5de65f39721a46aaf5705c480d9a8b76654", size = 301336, upload-time = "2024-12-03T22:46:01.629Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/b3/7e4df40e585df024fac2f80d1a2d579c854ac37109675db2b0cc22c0bb9e/fastapi-0.115.6-py3-none-any.whl", hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305", size = 94843, upload-time = "2024-12-03T22:45:59.368Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, ] [[package]] name = "filelock" -version = "3.16.1" +version = "3.20.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037, upload-time = "2024-09-17T19:02:01.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163, upload-time = "2024-09-17T19:02:00.268Z" }, + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, ] [[package]] name = "geoalchemy2" -version = "0.18.1" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/df/f6d689120a15a2287794e16696c3bdb4cf2e53038255d288b61a4d59e1fa/geoalchemy2-0.18.1.tar.gz", hash = "sha256:4bdc7daf659e36f6456e2f2c3bcce222b879584921a4f50a803ab05fa2bb3124", size = 239302, upload-time = "2025-11-18T15:12:05.296Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/74/6cb1ef591bf47d28f41aa770f2f3a91c0a570aee0a4083bed7f8c533d8df/geoalchemy2-0.20.0.tar.gz", hash = "sha256:450f427f4bc3cf2d5ddee0af3763aed0f3eea2384e7c9a99798d8f1508279322", size = 280805, upload-time = "2026-05-12T14:50:26.132Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/25/b3d6fc757d8d909e0e666ec6fbf1b7914e9ad18d6e1b08994cd9d2e63330/geoalchemy2-0.18.1-py3-none-any.whl", hash = "sha256:a49d9559bf7acbb69129a01c6e1861657c15db420886ad0a09b1871fb0ff4bdb", size = 81261, upload-time = "2025-11-18T15:12:03.985Z" }, + { url = "https://files.pythonhosted.org/packages/4e/08/b66ad4239f592e05202e25925c08cdd04cc14c3994000ec70ec61fea202c/geoalchemy2-0.20.0-py3-none-any.whl", hash = "sha256:1489a1d106519542a79c97cd0b4c537d80462c353610ebc2429cf2c43daac717", size = 96467, upload-time = "2026-05-12T14:50:24.998Z" }, ] [[package]] name = "greenlet" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022, upload-time = "2024-09-20T18:21:04.506Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/ec/bad1ac26764d26aa1353216fcbfa4670050f66d445448aafa227f8b16e80/greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", size = 274260, upload-time = "2024-09-20T17:08:07.301Z" }, - { url = "https://files.pythonhosted.org/packages/66/d4/c8c04958870f482459ab5956c2942c4ec35cac7fe245527f1039837c17a9/greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", size = 649064, upload-time = "2024-09-20T17:36:47.628Z" }, - { url = "https://files.pythonhosted.org/packages/51/41/467b12a8c7c1303d20abcca145db2be4e6cd50a951fa30af48b6ec607581/greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", size = 663420, upload-time = "2024-09-20T17:39:21.258Z" }, - { url = "https://files.pythonhosted.org/packages/27/8f/2a93cd9b1e7107d5c7b3b7816eeadcac2ebcaf6d6513df9abaf0334777f6/greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", size = 658035, upload-time = "2024-09-20T17:44:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/57/5c/7c6f50cb12be092e1dccb2599be5a942c3416dbcfb76efcf54b3f8be4d8d/greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", size = 660105, upload-time = "2024-09-20T17:08:42.048Z" }, - { url = "https://files.pythonhosted.org/packages/f1/66/033e58a50fd9ec9df00a8671c74f1f3a320564c6415a4ed82a1c651654ba/greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", size = 613077, upload-time = "2024-09-20T17:08:33.707Z" }, - { url = "https://files.pythonhosted.org/packages/19/c5/36384a06f748044d06bdd8776e231fadf92fc896bd12cb1c9f5a1bda9578/greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", size = 1135975, upload-time = "2024-09-20T17:44:15.989Z" }, - { url = "https://files.pythonhosted.org/packages/38/f9/c0a0eb61bdf808d23266ecf1d63309f0e1471f284300ce6dac0ae1231881/greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", size = 1163955, upload-time = "2024-09-20T17:09:25.539Z" }, - { url = "https://files.pythonhosted.org/packages/43/21/a5d9df1d21514883333fc86584c07c2b49ba7c602e670b174bd73cfc9c7f/greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", size = 299655, upload-time = "2024-09-20T17:21:22.427Z" }, - { url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990, upload-time = "2024-09-20T17:08:26.312Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175, upload-time = "2024-09-20T17:36:48.983Z" }, - { url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425, upload-time = "2024-09-20T17:39:22.705Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f9/9c82d6b2b04aa37e38e74f0c429aece5eeb02bab6e3b98e7db89b23d94c6/greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", size = 657736, upload-time = "2024-09-20T17:44:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/d9/42/b87bc2a81e3a62c3de2b0d550bf91a86939442b7ff85abb94eec3fc0e6aa/greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", size = 660347, upload-time = "2024-09-20T17:08:45.56Z" }, - { url = "https://files.pythonhosted.org/packages/37/fa/71599c3fd06336cdc3eac52e6871cfebab4d9d70674a9a9e7a482c318e99/greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", size = 615583, upload-time = "2024-09-20T17:08:36.85Z" }, - { url = "https://files.pythonhosted.org/packages/4e/96/e9ef85de031703ee7a4483489b40cf307f93c1824a02e903106f2ea315fe/greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", size = 1133039, upload-time = "2024-09-20T17:44:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/87/76/b2b6362accd69f2d1889db61a18c94bc743e961e3cab344c2effaa4b4a25/greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", size = 1160716, upload-time = "2024-09-20T17:09:27.112Z" }, - { url = "https://files.pythonhosted.org/packages/1f/1b/54336d876186920e185066d8c3024ad55f21d7cc3683c856127ddb7b13ce/greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", size = 299490, upload-time = "2024-09-20T17:17:09.501Z" }, - { url = "https://files.pythonhosted.org/packages/5f/17/bea55bf36990e1638a2af5ba10c1640273ef20f627962cf97107f1e5d637/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", size = 643731, upload-time = "2024-09-20T17:36:50.376Z" }, - { url = "https://files.pythonhosted.org/packages/78/d2/aa3d2157f9ab742a08e0fd8f77d4699f37c22adfbfeb0c610a186b5f75e0/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", size = 649304, upload-time = "2024-09-20T17:39:24.55Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8e/d0aeffe69e53ccff5a28fa86f07ad1d2d2d6537a9506229431a2a02e2f15/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", size = 646537, upload-time = "2024-09-20T17:44:31.102Z" }, - { url = "https://files.pythonhosted.org/packages/05/79/e15408220bbb989469c8871062c97c6c9136770657ba779711b90870d867/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", size = 642506, upload-time = "2024-09-20T17:08:47.852Z" }, - { url = "https://files.pythonhosted.org/packages/18/87/470e01a940307796f1d25f8167b551a968540fbe0551c0ebb853cb527dd6/greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", size = 602753, upload-time = "2024-09-20T17:08:38.079Z" }, - { url = "https://files.pythonhosted.org/packages/e2/72/576815ba674eddc3c25028238f74d7b8068902b3968cbe456771b166455e/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", size = 1122731, upload-time = "2024-09-20T17:44:20.556Z" }, - { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112, upload-time = "2024-09-20T17:09:28.753Z" }, +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" }, + { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" }, + { url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" }, + { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" }, + { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" }, + { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" }, + { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" }, + { url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" }, + { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/aa/952cf28c2ff949a8c971134fb43854dd7eaa737218723aaef758f8c9aead/greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357", size = 674261, upload-time = "2026-06-26T19:24:22.79Z" }, + { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/b00d6f5e63e531a93562b2ec1a4c320fbee91f580fc42e6417af69d706e5/greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128", size = 480322, upload-time = "2026-06-26T19:25:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" }, + { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/e9/39/0e0938a75115b939d42733a2a12e1d349653c9531fe6fe563e8a681f04e6/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91", size = 663706, upload-time = "2026-06-26T19:24:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/5b/41/35d1c678cdb3c3b9e6bee691728e563cfb294202b23c7a4c3c2ccc343589/greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d", size = 498803, upload-time = "2026-06-26T19:25:43.063Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" }, + { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" }, ] [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] name = "httpcore" -version = "1.0.7" +version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196, upload-time = "2024-11-15T12:30:47.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551, upload-time = "2024-11-15T12:30:45.782Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] @@ -507,11 +664,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -534,7 +691,7 @@ wheels = [ [[package]] name = "jsonschema" -version = "4.25.1" +version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -542,9 +699,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [[package]] @@ -561,14 +718,14 @@ wheels = [ [[package]] name = "mako" -version = "1.3.8" +version = "1.3.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/d9/8518279534ed7dace1795d5a47e49d5299dd0994eed1053996402a8902f9/mako-1.3.8.tar.gz", hash = "sha256:577b97e414580d3e088d47c2dbbe9594aa7a5146ed2875d4dfa9075af2dd3cc8", size = 392069, upload-time = "2024-12-07T18:41:33.96Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/bf/7a6a36ce2e4cafdfb202752be68850e22607fccd692847c45c1ae3c17ba6/Mako-1.3.8-py3-none-any.whl", hash = "sha256:42f48953c7eb91332040ff567eb7eea69b22e7a4affbc5ba8e845e8f730f6627", size = 78569, upload-time = "2024-12-07T18:41:35.983Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, ] [[package]] @@ -627,6 +784,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" @@ -647,11 +865,11 @@ wheels = [ [[package]] name = "pathspec" -version = "0.12.1" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] @@ -674,7 +892,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.0.1" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -683,18 +901,18 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/c8/e22c292035f1bac8b9f5237a2622305bc0304e776080b246f3df57c4ff9f/pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", size = 191678, upload-time = "2024-10-08T16:09:37.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713, upload-time = "2024-10-08T16:09:35.726Z" }, + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] name = "pyasn1" -version = "0.6.1" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, ] [[package]] @@ -708,16 +926,17 @@ wheels = [ [[package]] name = "pydantic" -version = "2.10.3" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/0f/27908242621b14e649a84e62b133de45f84c255eecb350ab02979844a788/pydantic-2.10.3.tar.gz", hash = "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9", size = 786486, upload-time = "2024-12-03T15:59:02.347Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/51/72c18c55cf2f46ff4f91ebcc8f75aa30f7305f3d726be3f4ebffb4ae972b/pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d", size = 456997, upload-time = "2024-12-03T15:58:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [package.optional-dependencies] @@ -727,54 +946,91 @@ email = [ [[package]] name = "pydantic-core" -version = "2.27.1" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/9f/7de1f19b6aea45aeb441838782d68352e71bfa98ee6fa048d5041991b33e/pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235", size = 412785, upload-time = "2024-11-22T00:24:49.865Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/51/2e9b3788feb2aebff2aa9dfbf060ec739b38c05c46847601134cc1fed2ea/pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f", size = 1895239, upload-time = "2024-11-22T00:22:13.775Z" }, - { url = "https://files.pythonhosted.org/packages/7b/9e/f8063952e4a7d0127f5d1181addef9377505dcce3be224263b25c4f0bfd9/pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02", size = 1805070, upload-time = "2024-11-22T00:22:15.438Z" }, - { url = "https://files.pythonhosted.org/packages/2c/9d/e1d6c4561d262b52e41b17a7ef8301e2ba80b61e32e94520271029feb5d8/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c", size = 1828096, upload-time = "2024-11-22T00:22:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/be/65/80ff46de4266560baa4332ae3181fffc4488ea7d37282da1a62d10ab89a4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac", size = 1857708, upload-time = "2024-11-22T00:22:19.412Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ca/3370074ad758b04d9562b12ecdb088597f4d9d13893a48a583fb47682cdf/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb", size = 2037751, upload-time = "2024-11-22T00:22:20.979Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/4ab72d93367194317b99d051947c071aef6e3eb95f7553eaa4208ecf9ba4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529", size = 2733863, upload-time = "2024-11-22T00:22:22.951Z" }, - { url = "https://files.pythonhosted.org/packages/8a/c6/8ae0831bf77f356bb73127ce5a95fe115b10f820ea480abbd72d3cc7ccf3/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35", size = 2161161, upload-time = "2024-11-22T00:22:24.785Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f4/b2fe73241da2429400fc27ddeaa43e35562f96cf5b67499b2de52b528cad/pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089", size = 1993294, upload-time = "2024-11-22T00:22:27.076Z" }, - { url = "https://files.pythonhosted.org/packages/77/29/4bb008823a7f4cc05828198153f9753b3bd4c104d93b8e0b1bfe4e187540/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381", size = 2001468, upload-time = "2024-11-22T00:22:29.346Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a9/0eaceeba41b9fad851a4107e0cf999a34ae8f0d0d1f829e2574f3d8897b0/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb", size = 2091413, upload-time = "2024-11-22T00:22:30.984Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/eb8697729725bc610fd73940f0d860d791dc2ad557faaefcbb3edbd2b349/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae", size = 2154735, upload-time = "2024-11-22T00:22:32.616Z" }, - { url = "https://files.pythonhosted.org/packages/52/e5/4f0fbd5c5995cc70d3afed1b5c754055bb67908f55b5cb8000f7112749bf/pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c", size = 1833633, upload-time = "2024-11-22T00:22:35.027Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f2/c61486eee27cae5ac781305658779b4a6b45f9cc9d02c90cb21b940e82cc/pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16", size = 1986973, upload-time = "2024-11-22T00:22:37.502Z" }, - { url = "https://files.pythonhosted.org/packages/df/a6/e3f12ff25f250b02f7c51be89a294689d175ac76e1096c32bf278f29ca1e/pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e", size = 1883215, upload-time = "2024-11-22T00:22:39.186Z" }, - { url = "https://files.pythonhosted.org/packages/0f/d6/91cb99a3c59d7b072bded9959fbeab0a9613d5a4935773c0801f1764c156/pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073", size = 1895033, upload-time = "2024-11-22T00:22:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/07/42/d35033f81a28b27dedcade9e967e8a40981a765795c9ebae2045bcef05d3/pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08", size = 1807542, upload-time = "2024-11-22T00:22:43.341Z" }, - { url = "https://files.pythonhosted.org/packages/41/c2/491b59e222ec7e72236e512108ecad532c7f4391a14e971c963f624f7569/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf", size = 1827854, upload-time = "2024-11-22T00:22:44.96Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f3/363652651779113189cefdbbb619b7b07b7a67ebb6840325117cc8cc3460/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737", size = 1857389, upload-time = "2024-11-22T00:22:47.305Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/be804aed6b479af5a945daec7538d8bf358d668bdadde4c7888a2506bdfb/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2", size = 2037934, upload-time = "2024-11-22T00:22:49.093Z" }, - { url = "https://files.pythonhosted.org/packages/42/01/295f0bd4abf58902917e342ddfe5f76cf66ffabfc57c2e23c7681a1a1197/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107", size = 2735176, upload-time = "2024-11-22T00:22:50.822Z" }, - { url = "https://files.pythonhosted.org/packages/9d/a0/cd8e9c940ead89cc37812a1a9f310fef59ba2f0b22b4e417d84ab09fa970/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51", size = 2160720, upload-time = "2024-11-22T00:22:52.638Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/9d0980e286627e0aeca4c352a60bd760331622c12d576e5ea4441ac7e15e/pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a", size = 1992972, upload-time = "2024-11-22T00:22:54.31Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ba/ae4480bc0292d54b85cfb954e9d6bd226982949f8316338677d56541b85f/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc", size = 2001477, upload-time = "2024-11-22T00:22:56.451Z" }, - { url = "https://files.pythonhosted.org/packages/55/b7/e26adf48c2f943092ce54ae14c3c08d0d221ad34ce80b18a50de8ed2cba8/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960", size = 2091186, upload-time = "2024-11-22T00:22:58.226Z" }, - { url = "https://files.pythonhosted.org/packages/ba/cc/8491fff5b608b3862eb36e7d29d36a1af1c945463ca4c5040bf46cc73f40/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23", size = 2154429, upload-time = "2024-11-22T00:22:59.985Z" }, - { url = "https://files.pythonhosted.org/packages/78/d8/c080592d80edd3441ab7f88f865f51dae94a157fc64283c680e9f32cf6da/pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05", size = 1833713, upload-time = "2024-11-22T00:23:01.715Z" }, - { url = "https://files.pythonhosted.org/packages/83/84/5ab82a9ee2538ac95a66e51f6838d6aba6e0a03a42aa185ad2fe404a4e8f/pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337", size = 1987897, upload-time = "2024-11-22T00:23:03.497Z" }, - { url = "https://files.pythonhosted.org/packages/df/c3/b15fb833926d91d982fde29c0624c9f225da743c7af801dace0d4e187e71/pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", size = 1882983, upload-time = "2024-11-22T00:23:05.983Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] [[package]] name = "pydantic-settings" -version = "2.6.1" +version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/d4/9dfbe238f45ad8b168f5c96ee49a3df0598ce18a0795a983b419949ce65b/pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0", size = 75646, upload-time = "2024-11-01T11:00:05.17Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f9/ff95fd7d760af42f647ea87f9b8a383d891cdb5e5dbd4613edaeb094252a/pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87", size = 28595, upload-time = "2024-11-01T11:00:02.64Z" }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] @@ -786,40 +1042,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725, upload-time = "2024-01-05T00:28:45.903Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [[package]] name = "pytest" -version = "8.3.4" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, + { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919, upload-time = "2024-12-01T12:54:25.98Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083, upload-time = "2024-12-01T12:54:19.735Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] name = "pytest-asyncio" -version = "0.24.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855, upload-time = "2024-08-22T08:03:18.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024, upload-time = "2024-08-22T08:03:15.536Z" }, + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] @@ -837,25 +1104,25 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.0.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-jose" -version = "3.3.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ecdsa" }, { name = "pyasn1" }, { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/19/b2c86504116dc5f0635d29f802da858404d77d930a25633d2e86a64a35b3/python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a", size = 129068, upload-time = "2021-06-05T03:30:40.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/2d/e94b2f7bab6773c70efc70a61d66e312e1febccd9e0db6b9e0adf58cbad1/python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a", size = 33530, upload-time = "2021-06-05T03:30:38.099Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, ] [package.optional-dependencies] @@ -865,11 +1132,59 @@ cryptography = [ [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, ] [[package]] @@ -914,7 +1229,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -922,14 +1237,14 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "requests-cache" -version = "1.2.1" +version = "1.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -939,9 +1254,9 @@ dependencies = [ { name = "url-normalize" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/be/7b2a95a9e7a7c3e774e43d067c51244e61dea8b120ae2deff7089a93fb2b/requests_cache-1.2.1.tar.gz", hash = "sha256:68abc986fdc5b8d0911318fbb5f7c80eebcd4d01bfacc6685ecf8876052511d1", size = 3018209, upload-time = "2024-06-18T17:18:03.774Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/ab/a340c7f529646f16e5656a8ba1424ed0de406203e4554868491786628730/requests_cache-1.3.3.tar.gz", hash = "sha256:79b72d5ac5143992d1836ad78f4d8e65666061dd44e220548caab3723089826b", size = 101179, upload-time = "2026-07-03T19:48:57.963Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/2e/8f4051119f460cfc786aa91f212165bb6e643283b533db572d7b33952bd2/requests_cache-1.2.1-py3-none-any.whl", hash = "sha256:1285151cddf5331067baa82598afe2d47c7495a1334bfe7a7d329b43e9fd3603", size = 61425, upload-time = "2024-06-18T17:17:45Z" }, + { url = "https://files.pythonhosted.org/packages/a5/bf/c1775e49b350225bd851576ba75263bc728d8f05c0e31439a45f3429cc7b/requests_cache-1.3.3-py3-none-any.whl", hash = "sha256:c8df20ff874ebfc026959e3874e6c12bd6724934cdb10925915908453d4b17e4", size = 70788, upload-time = "2026-07-03T19:48:56.693Z" }, ] [[package]] @@ -1039,15 +1354,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.48.0" +version = "2.64.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f0/0e9dc590513d5e742d7799e2038df3a05167cba084c6ca4f3cdd75b55164/sentry_sdk-2.48.0.tar.gz", hash = "sha256:5213190977ff7fdff8a58b722fb807f8d5524a80488626ebeda1b5676c0c1473", size = 384828, upload-time = "2025-12-16T14:55:41.722Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/31/b7341f156a5f6f36f0b4845d6f1c28a2ae4799171dba7007f3a1e9b234b4/sentry_sdk-2.64.0.tar.gz", hash = "sha256:68be2c29e14ae310f8a39e1a79916b6d85c6cb41dcce789d14ff05fe293e4c55", size = 921020, upload-time = "2026-06-30T08:13:47.682Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/19/8d77f9992e5cbfcaa9133c3bf63b4fbbb051248802e1e803fed5c552fbb2/sentry_sdk-2.48.0-py2.py3-none-any.whl", hash = "sha256:6b12ac256769d41825d9b7518444e57fa35b5642df4c7c5e322af4d2c8721172", size = 414555, upload-time = "2025-12-16T14:55:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/36/a8/3fb9a4319efa3b26f5be0e90e6d8918df43fa7c7e977d26390f589501d82/sentry_sdk-2.64.0-py3-none-any.whl", hash = "sha256:715ea91ca860a819e8d8a50a7bde3a80d0df3b4ed7b6660a20fb9a2d084188f1", size = 498901, upload-time = "2026-06-30T08:13:45.566Z" }, ] [package.optional-dependencies] @@ -1055,6 +1370,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" @@ -1075,65 +1441,107 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.36" +version = "2.0.51" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet", marker = "(python_full_version < '3.13' and platform_machine == 'AMD64') or (python_full_version < '3.13' and platform_machine == 'WIN32') or (python_full_version < '3.13' and platform_machine == 'aarch64') or (python_full_version < '3.13' and platform_machine == 'amd64') or (python_full_version < '3.13' and platform_machine == 'ppc64le') or (python_full_version < '3.13' and platform_machine == 'win32') or (python_full_version < '3.13' and platform_machine == 'x86_64')" }, + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/65/9cbc9c4c3287bed2499e05033e207473504dc4df999ce49385fb1f8b058a/sqlalchemy-2.0.36.tar.gz", hash = "sha256:7f2767680b6d2398aea7082e45a774b2b0767b5c8d8ffb9c8b683088ea9b29c5", size = 9574485, upload-time = "2024-10-15T19:41:44.446Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/bf/005dc47f0e57556e14512d5542f3f183b94fde46e15ff1588ec58ca89555/SQLAlchemy-2.0.36-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7b64e6ec3f02c35647be6b4851008b26cff592a95ecb13b6788a54ef80bbdd4", size = 2092378, upload-time = "2024-10-16T00:43:55.469Z" }, - { url = "https://files.pythonhosted.org/packages/94/65/f109d5720779a08e6e324ec89a744f5f92c48bd8005edc814bf72fbb24e5/SQLAlchemy-2.0.36-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46331b00096a6db1fdc052d55b101dbbfc99155a548e20a0e4a8e5e4d1362855", size = 2082778, upload-time = "2024-10-16T00:43:57.304Z" }, - { url = "https://files.pythonhosted.org/packages/60/f6/d9aa8c49c44f9b8c9b9dada1f12fa78df3d4c42aa2de437164b83ee1123c/SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdf3386a801ea5aba17c6410dd1dc8d39cf454ca2565541b5ac42a84e1e28f53", size = 3232191, upload-time = "2024-10-15T21:31:12.896Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ab/81d4514527c068670cb1d7ab62a81a185df53a7c379bd2a5636e83d09ede/SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9dfa18ff2a67b09b372d5db8743c27966abf0e5344c555d86cc7199f7ad83a", size = 3243044, upload-time = "2024-10-15T20:16:28.954Z" }, - { url = "https://files.pythonhosted.org/packages/35/b4/f87c014ecf5167dc669199cafdb20a7358ff4b1d49ce3622cc48571f811c/SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:90812a8933df713fdf748b355527e3af257a11e415b613dd794512461eb8a686", size = 3178511, upload-time = "2024-10-15T21:31:16.792Z" }, - { url = "https://files.pythonhosted.org/packages/ea/09/badfc9293bc3ccba6ede05e5f2b44a760aa47d84da1fc5a326e963e3d4d9/SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1bc330d9d29c7f06f003ab10e1eaced295e87940405afe1b110f2eb93a233588", size = 3205147, upload-time = "2024-10-15T20:16:32.718Z" }, - { url = "https://files.pythonhosted.org/packages/c8/60/70e681de02a13c4b27979b7b78da3058c49bacc9858c89ba672e030f03f2/SQLAlchemy-2.0.36-cp312-cp312-win32.whl", hash = "sha256:79d2e78abc26d871875b419e1fd3c0bca31a1cb0043277d0d850014599626c2e", size = 2062709, upload-time = "2024-10-15T20:16:29.946Z" }, - { url = "https://files.pythonhosted.org/packages/b7/ed/f6cd9395e41bfe47dd253d74d2dfc3cab34980d4e20c8878cb1117306085/SQLAlchemy-2.0.36-cp312-cp312-win_amd64.whl", hash = "sha256:b544ad1935a8541d177cb402948b94e871067656b3a0b9e91dbec136b06a2ff5", size = 2088433, upload-time = "2024-10-15T20:16:33.501Z" }, - { url = "https://files.pythonhosted.org/packages/78/5c/236398ae3678b3237726819b484f15f5c038a9549da01703a771f05a00d6/SQLAlchemy-2.0.36-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5cc79df7f4bc3d11e4b542596c03826063092611e481fcf1c9dfee3c94355ef", size = 2087651, upload-time = "2024-10-16T00:43:59.168Z" }, - { url = "https://files.pythonhosted.org/packages/a8/14/55c47420c0d23fb67a35af8be4719199b81c59f3084c28d131a7767b0b0b/SQLAlchemy-2.0.36-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3c01117dd36800f2ecaa238c65365b7b16497adc1522bf84906e5710ee9ba0e8", size = 2078132, upload-time = "2024-10-16T00:44:01.279Z" }, - { url = "https://files.pythonhosted.org/packages/3d/97/1e843b36abff8c4a7aa2e37f9bea364f90d021754c2de94d792c2d91405b/SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bc633f4ee4b4c46e7adcb3a9b5ec083bf1d9a97c1d3854b92749d935de40b9b", size = 3164559, upload-time = "2024-10-15T21:31:18.961Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c5/07f18a897b997f6d6b234fab2bf31dccf66d5d16a79fe329aefc95cd7461/SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e46ed38affdfc95d2c958de328d037d87801cfcbea6d421000859e9789e61c2", size = 3177897, upload-time = "2024-10-15T20:16:35.048Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cd/e16f3cbefd82b5c40b33732da634ec67a5f33b587744c7ab41699789d492/SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b2985c0b06e989c043f1dc09d4fe89e1616aadd35392aea2844f0458a989eacf", size = 3111289, upload-time = "2024-10-15T21:31:21.11Z" }, - { url = "https://files.pythonhosted.org/packages/15/85/5b8a3b0bc29c9928aa62b5c91fcc8335f57c1de0a6343873b5f372e3672b/SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a121d62ebe7d26fec9155f83f8be5189ef1405f5973ea4874a26fab9f1e262c", size = 3139491, upload-time = "2024-10-15T20:16:38.048Z" }, - { url = "https://files.pythonhosted.org/packages/a1/95/81babb6089938680dfe2cd3f88cd3fd39cccd1543b7cb603b21ad881bff1/SQLAlchemy-2.0.36-cp313-cp313-win32.whl", hash = "sha256:0572f4bd6f94752167adfd7c1bed84f4b240ee6203a95e05d1e208d488d0d436", size = 2060439, upload-time = "2024-10-15T20:16:36.182Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ce/5f7428df55660d6879d0522adc73a3364970b5ef33ec17fa125c5dbcac1d/SQLAlchemy-2.0.36-cp313-cp313-win_amd64.whl", hash = "sha256:8c78ac40bde930c60e0f78b3cd184c580f89456dd87fc08f9e3ee3ce8765ce88", size = 2084574, upload-time = "2024-10-15T20:16:38.686Z" }, - { url = "https://files.pythonhosted.org/packages/b8/49/21633706dd6feb14cd3f7935fc00b60870ea057686035e1a99ae6d9d9d53/SQLAlchemy-2.0.36-py3-none-any.whl", hash = "sha256:fddbe92b4760c6f5d48162aef14824add991aeda8ddadb3c31d56eb15ca69f8e", size = 1883787, upload-time = "2024-10-15T20:04:30.265Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, ] [[package]] name = "sqlmodel" -version = "0.0.31" +version = "0.0.39" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "sqlalchemy" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/b8/e7cd6def4a773f25d6e29ffce63ccbfd6cf9488b804ab6fb9b80d334b39d/sqlmodel-0.0.31.tar.gz", hash = "sha256:2d41a8a9ee05e40736e2f9db8ea28cbfe9b5d4e5a18dd139e80605025e0c516c", size = 94952, upload-time = "2025-12-28T12:35:01.436Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/ee/22a0559283c3cf6048678e787ed5d4959dcd00dedd8ba4567eeae684eeb1/sqlmodel-0.0.39.tar.gz", hash = "sha256:23d8e50a8d8ee936032ed79c55023a5d618dd6bc3c510bbf4909d1a7a605a570", size = 91057, upload-time = "2026-06-25T13:01:38.475Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/72/5aa5be921800f6418a949a73c9bb7054890881143e6bc604a93d228a95a3/sqlmodel-0.0.31-py3-none-any.whl", hash = "sha256:6d946d56cac4c2db296ba1541357cee2e795d68174e2043cd138b916794b1513", size = 27093, upload-time = "2025-12-28T12:35:00.108Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7d/b9813a582d4eb310be35e1fc7dfaae71207d7b62e9e53be314ebd251b53b/sqlmodel-0.0.39-py3-none-any.whl", hash = "sha256:90ebe92ce5cc11d7fff8dc7cb594790a102333c8fe7c14865254f6fc5c939795", size = 29680, upload-time = "2026-06-25T13:01:37.494Z" }, ] [[package]] name = "starlette" -version = "0.41.3" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "testcontainers" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/4c/9b5764bd22eec91c4039ef4c55334e9187085da2d8a2df7bd570869aae18/starlette-0.41.3.tar.gz", hash = "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835", size = 2574159, upload-time = "2024-11-18T19:45:04.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225, upload-time = "2024-11-18T19:45:02.027Z" }, + { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, ] [[package]] name = "typing-extensions" -version = "4.12.2" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] @@ -1150,11 +1558,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.2" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] @@ -1172,16 +1580,16 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.28.0" +version = "20.36.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/75/53316a5a8050069228a2f6d11f32046cfa94fbb6cc3f08703f59b873de2e/virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa", size = 7650368, upload-time = "2024-11-26T04:32:39.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/f9/0919cf6f1432a8c4baa62511f8f8da8225432d22e83e3476f5be1a1edc6e/virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0", size = 4276702, upload-time = "2024-11-26T04:32:36.948Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] [[package]] @@ -1215,18 +1623,24 @@ dependencies = [ { name = "requests" }, { name = "requests-cache" }, { name = "sentry-sdk", extra = ["fastapi"] }, + { name = "shapely" }, { name = "sqlalchemy" }, { name = "sqlmodel" }, { name = "uvicorn" }, ] +[package.optional-dependencies] +integration = [ + { name = "testcontainers" }, +] + [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.14.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, { name = "autoflake", specifier = ">=2.3.1" }, { name = "bcrypt", specifier = "==4.0.1" }, - { name = "black", specifier = ">=24.1.0" }, + { name = "black", specifier = ">=26.3.1" }, { name = "cachetools" }, { name = "fastapi", specifier = ">=0.115.6" }, { name = "geoalchemy2", specifier = ">=0.18.1" }, @@ -1239,16 +1653,83 @@ requires-dist = [ { name = "pydantic", extras = ["email"], specifier = ">=2.5.2" }, { name = "pydantic-settings", specifier = ">=2.6.1" }, { name = "pyjwt" }, - { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=0.23.5" }, { name = "pytest-cov", specifier = ">=4.1.0" }, - { name = "python-dotenv", specifier = ">=1.0.1" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, - { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "python-multipart", specifier = ">=0.0.31" }, { 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 = "testcontainers", extras = ["postgres"], marker = "extra == 'integration'", specifier = ">=4.0.0" }, { name = "uvicorn", specifier = ">=0.32.1" }, ] +provides-extras = ["integration"] + +[[package]] +name = "wrapt" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" }, + { url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" }, + { url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" }, + { url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" }, + { url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" }, + { url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" }, + { url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" }, + { url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" }, + { url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" }, + { url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, +]