From 7c053eb64e315e191d3c321cfc19554f9a69d396 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 15 Apr 2026 15:26:15 -0600 Subject: [PATCH] feat: implement API concurrency fix strategy by converting async route handlers to sync and enhancing error handling --- .github/workflows/CD_production.yml | 2 +- .github/workflows/CD_staging.yml | 2 +- .github/workflows/CD_testing.yml | 157 ++++++++++++++++++ ADR2.md | 119 +++++++++++++ api/author.py | 2 +- api/contact.py | 100 ++++++----- api/geochronology.py | 4 +- api/geospatial.py | 4 +- api/group.py | 12 +- api/lexicon.py | 30 ++-- api/location.py | 10 +- api/ngwmn.py | 6 +- api/observation.py | 24 +-- api/publication.py | 2 +- api/sample.py | 34 ++-- api/search.py | 2 +- api/sensor.py | 10 +- api/thing.py | 105 ++++++------ .../test_nma_legacy_relationships.py | 4 +- tests/test_nma_chemistry_lineage.py | 4 +- 20 files changed, 451 insertions(+), 182 deletions(-) create mode 100644 .github/workflows/CD_testing.yml create mode 100644 ADR2.md diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 96f103567..e7b89642f 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -88,7 +88,7 @@ jobs: run: | export MAX_INSTANCES="10" export SERVICE_NAME="ocotillo-api" - export ENTRYPOINT="gunicorn -w 1 -k uvicorn.workers.UvicornWorker main:app" + export ENTRYPOINT="gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app" export MIN_INSTANCES="0" envsubst < .github/app.template.yaml > app.yaml diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index ac8002537..c723eb6ae 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -88,7 +88,7 @@ jobs: run: | export MAX_INSTANCES="10" export SERVICE_NAME="ocotillo-api-staging" - export ENTRYPOINT="gunicorn -w 1 -k uvicorn.workers.UvicornWorker main:app" + export ENTRYPOINT="gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app" export MIN_INSTANCES="0" envsubst < .github/app.template.yaml > app.yaml diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml new file mode 100644 index 000000000..b924519c1 --- /dev/null +++ b/.github/workflows/CD_testing.yml @@ -0,0 +1,157 @@ +name: CD (Testing) + +on: + push: + branches: [jir*] + +permissions: + contents: write + +jobs: + testing-deploy: + + runs-on: ubuntu-latest + environment: staging + + steps: + - name: Check out source repository + uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + + - name: Install uv in container + uses: astral-sh/setup-uv@v8.0.0 + with: + version: "latest" + + - name: Generate requirements.txt + run: | + uv export \ + --format requirements-txt \ + --no-emit-project \ + --no-dev \ + --output-file requirements.txt + + - name: Authenticate to Google Cloud + uses: 'google-github-actions/auth@v3' + with: + credentials_json: ${{ secrets.CLOUD_DEPLOY_SERVICE_ACCOUNT_KEY }} + + - name: Run Alembic migrations on staging database + env: + DB_DRIVER: "cloudsql" + CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" + CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" + CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" + CLOUD_SQL_IAM_AUTH: true + run: | + uv run alembic upgrade head + + - name: Refresh materialized views on staging database + env: + DB_DRIVER: "cloudsql" + CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" + CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" + CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" + CLOUD_SQL_IAM_AUTH: true + run: | + uv run python -m cli.cli refresh-pygeoapi-materialized-views + + - name: Ensure envsubst is available + run: | + if ! command -v envsubst >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y gettext-base + fi + + - name: Render App Engine configs + env: + ENVIRONMENT: "staging" + CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" + CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" + CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" + PYGEOAPI_POSTGRES_DB: "${{ vars.CLOUD_SQL_DATABASE }}" + PYGEOAPI_POSTGRES_USER: "${{ secrets.PYGEOAPI_POSTGRES_USER }}" + PYGEOAPI_POSTGRES_HOST: "${{ vars.PYGEOAPI_POSTGRES_HOST || '127.0.0.1' }}" + PYGEOAPI_POSTGRES_PORT: "${{ vars.PYGEOAPI_POSTGRES_PORT || '5432' }}" + PYGEOAPI_POSTGRES_PASSWORD: "${{ secrets.PYGEOAPI_POSTGRES_PASSWORD }}" + PYGEOAPI_SERVER_URL: "${{ vars.PYGEOAPI_SERVER_URL }}" + CLOUD_SQL_IAM_AUTH: "true" + GCS_SERVICE_ACCOUNT_KEY: "${{ secrets.GCS_SERVICE_ACCOUNT_KEY }}" + GCS_BUCKET_NAME: "${{ vars.GCS_BUCKET_NAME }}" + AUTHENTIK_URL: "${{ vars.AUTHENTIK_URL }}" + AUTHENTIK_CLIENT_ID: "${{ vars.AUTHENTIK_CLIENT_ID }}" + AUTHENTIK_AUTHORIZE_URL: "${{ vars.AUTHENTIK_AUTHORIZE_URL }}" + AUTHENTIK_TOKEN_URL: "${{ vars.AUTHENTIK_TOKEN_URL }}" + SESSION_SECRET_KEY: "${{ secrets.SESSION_SECRET_KEY }}" + APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}" + run: | + export MAX_INSTANCES="10" + export SERVICE_NAME="ocotillo-api-testing" + export ENTRYPOINT="gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app" + export MIN_INSTANCES="0" + envsubst < .github/app.template.yaml > app.yaml + + - name: Deploy to Google Cloud + run: | + gcloud app deploy \ + app.yaml \ + --quiet \ + --project ${{ vars.GCP_PROJECT_ID }} + + - name: Clean up oldest versions + run: | + SERVICE="ocotillo-api-testing" + VERSIONS_JSON="$(gcloud app versions list --service="$SERVICE" --project=${{ vars.GCP_PROJECT_ID }} --format=json --sort-by="version.createTime" 2>/dev/null || printf '[]')" + export VERSIONS_JSON + DELETE_VERSION="$(python - <<'PY' + import json + import os + + versions = json.loads(os.environ.get("VERSIONS_JSON", "[]") or "[]") + if len(versions) <= 1: + print("") + raise SystemExit(0) + + def traffic_split(version): + for key in ("traffic_split", "trafficSplit"): + value = version.get(key) + if value is not None: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + return 0.0 + + for version in versions: + if traffic_split(version) == 0.0: + print(version.get("id", "")) + break + else: + print("") + PY + )" + if [ -n "$DELETE_VERSION" ]; then + echo "Deleting old non-serving version for $SERVICE: $DELETE_VERSION" + gcloud app versions delete "$DELETE_VERSION" --service="$SERVICE" --project=${{ vars.GCP_PROJECT_ID }} --quiet + else + echo "No old non-serving versions to delete for $SERVICE" + fi + + - name: Remove rendered configs + run: | + rm app.yaml + + # Use PR author's username as git user name + - name: Set up git user + run: | + git config --global user.name "${{ github.actor }}" + git config --global user.email "${{ github.actor }}@users.noreply.github.com" + + # ":" are not alloed in git tags, so replace with "-" + - name: Tag commit + run: | + git tag -a "testing-deploy-$(date -u +%Y-%m-%d)T$(date -u +%H-%M-%S%z)" -m "testing gcloud deployment: $ + (date + -u +%Y-%m-%d)T$(date -u +%H:%M:%S%z)" + git push origin --tags diff --git a/ADR2.md b/ADR2.md new file mode 100644 index 000000000..862f4b299 --- /dev/null +++ b/ADR2.md @@ -0,0 +1,119 @@ +# ADR2: API Concurrency Fix Strategy + +## Summary + +This document describes a verified FastAPI concurrency issue in the API stack and recommends a two-phase remediation plan for maintainers. + +The API uses synchronous SQLAlchemy sessions backed by `psycopg`. When those sessions are consumed from `async def` route handlers, blocking database work runs on the event loop thread if the handlers call synchronous ORM helpers directly. The lowest-risk immediate fix is to convert database-bound route handlers that do not perform asynchronous work into plain `def`. The longer-term fix is to introduce a real async SQLAlchemy stack and migrate the affected handlers and helpers incrementally. + +## Problem + +FastAPI supports synchronous generator dependencies such as `get_db_session()`. The issue is not the dependency shape itself. The issue is that the injected object is a synchronous SQLAlchemy `Session`, and any `async def` route that consumes it while executing synchronous ORM queries directly will block the event loop thread. + +In this configuration, FastAPI runs the `async def` route body on the event loop thread. If that body performs blocking database I/O through the synchronous session, the worker cannot make progress on other requests assigned to that event loop until the database call returns. A slow well query can therefore delay unrelated lightweight requests handled by the same worker. + +This is a concurrency problem, not a correctness problem. The endpoints can still return correct data while reducing throughput and responsiveness under load. + +## Evidence In This Repo + +- [`db/engine.py`](db/engine.py) creates `database_sessionmaker = sessionmaker(engine, expire_on_commit=False)` and `get_db_session()` yields a regular synchronous `Session`. +- [`db/engine.py`](db/engine.py) builds synchronous `postgresql+psycopg` engines for both the default PostgreSQL path and the Cloud SQL path, confirming that the active database layer is synchronous. +- [`core/dependencies.py`](core/dependencies.py) injects that session through `session_dependency`. +- [`services/well_details_helper.py`](services/well_details_helper.py) performs synchronous ORM operations such as `session.scalars(...).all()` and related query chains. +- [`api/thing.py`](api/thing.py) contains representative database-backed routes that pass the synchronous session into helper functions such as `get_db_things(...)` and `get_well_details_payload(...)`. +- [`api/asset.py`](api/asset.py) shows a contrasting safe pattern for non-database blocking work by wrapping synchronous GCS calls in `run_in_threadpool(...)`. +- The short-term fix described in this ADR converts database-bound routes from `async def` to `def` where they do not need `await`, but the helper/query layer remains synchronous until a real async session stack is introduced. + +## Short-Term Fix + +The short-term fix is to convert database-bound route handlers from `async def` to `def` when they do not actually perform asynchronous work. + +This lets FastAPI offload the entire route function to a worker thread instead of running its synchronous database calls on the event loop thread. It does not require changing the current database engine, dependency, query helpers, or response schemas. + +### Short-term implementation guidance + +- Convert any route handler that: + - receives `session: session_dependency`, + - performs synchronous ORM work directly or through helpers, and + - does not require `await` for other operations in the route body. +- Prioritize the highest-value endpoints first: + - high-traffic list and detail endpoints, + - endpoints known to run expensive joins or eager-loads, + - endpoints that affect warmup or perceived application responsiveness. +- Keep route behavior unchanged: + - do not change paths, status codes, payloads, or auth dependencies as part of this phase. +- Avoid mixed patterns: + - do not leave a route as `async def` if it still calls synchronous SQLAlchemy code directly. +- Use `run_in_threadpool(...)` only when a route must remain `async def` for a separate reason, such as mixing in another async operation, and only for isolated blocking helpers rather than as a blanket wrapper for all DB access. + +### Expected impact + +- Lower risk than a full async migration. +- No intended HTTP contract changes. +- Better worker responsiveness because blocking DB work moves off the event loop thread. + +## Long-Term Fix + +The long-term fix is to add a real async database stack and migrate selected API areas to it incrementally. + +This phase should introduce an explicit async path rather than trying to reuse the current synchronous dependency. Importing async SQLAlchemy primitives is not enough; the repo needs a working async engine, async sessionmaker, async dependency, and async query/helper layer for migrated endpoints. + +### Long-term target architecture + +- Add an `AsyncEngine` configured for the intended async driver. +- Add an `async_sessionmaker` that yields `AsyncSession` instances. +- Add a dedicated async dependency such as `get_async_db_session()` rather than overloading `get_db_session()`. +- Update migrated handlers and helper functions to use async database access: + - `await session.execute(...)` + - `await session.scalars(...)` + - other `AsyncSession`-compatible patterns as needed + +### Long-term migration guidance + +- Migrate by subsystem, not all at once. +- Start with a bounded route/helper cluster where the query patterns are understood. +- Keep sync and async paths separate during migration to avoid ambiguous dependencies and accidental sync calls from async routes. +- Treat helper-layer migration as part of the work. Converting route signatures alone is insufficient if the helper functions still expect synchronous sessions. + +### Non-goals and cautions + +- Do not claim the repo already has a working async DB session path unless one is actually implemented and used. +- Do not treat “switch everything to async” as a trivial refactor. +- Do not mix `AsyncSession` route code with synchronous helper/query internals. + +## Recommended Path + +The recommended order is: + +1. Convert database-bound `async def` routes that do not use `await` into plain `def`. +2. Validate behavior and measure the effect on responsiveness. +3. Introduce a dedicated async DB stack. +4. Migrate selected route/helper subsystems incrementally to `AsyncSession`. + +This sequence delivers immediate concurrency improvement with limited risk, while preserving a clear path to a full async architecture later. + +## Acceptance Criteria + +### Short-term acceptance criteria + +- Targeted API tests continue to pass after `async def` to `def` conversions. +- HTTP behavior is unchanged: + - same routes, + - same auth requirements, + - same status codes, + - same payload shapes. +- Concurrency smoke checks or request-timing instrumentation show that DB-heavy requests no longer block the event loop thread for that worker in the same way they do today. + +### Long-term acceptance criteria + +- Migrated endpoints pass the existing API test coverage for their subsystem. +- The async session lifecycle is correct for successful and failing requests. +- Migrated `async def` routes do not call synchronous session helpers. +- Before/after measurements are captured for latency and concurrency so the migration can be evaluated against real behavior rather than assumptions. + +## Defaults And Assumptions + +- This document is written for maintainers and assumes familiarity with FastAPI and SQLAlchemy internals. +- The document is self-contained and does not require code changes to be useful. +- The recommended short-term action is intentionally conservative and does not prescribe a file-by-file rollout sequence. +- The recommended long-term action is a staged migration, not a flag-day rewrite. diff --git a/api/author.py b/api/author.py index a54b1139e..b715b6760 100644 --- a/api/author.py +++ b/api/author.py @@ -30,7 +30,7 @@ "/{author_id}/publications", response_model=list[PublicationResponse], ) -async def get_author_publications( +def get_author_publications( user: viewer_dependency, author_id: int, session: session_dependency ): """ diff --git a/api/contact.py b/api/contact.py index c38f52e26..f5d46c031 100644 --- a/api/contact.py +++ b/api/contact.py @@ -14,10 +14,9 @@ # limitations under the License. # =============================================================================== from fastapi import APIRouter, Query -from fastapi import APIRouter from sqlalchemy import select from starlette import status -from sqlalchemy.exc import ProgrammingError +from sqlalchemy.exc import IntegrityError, ProgrammingError from api.pagination import CustomPage from fastapi_pagination.ext.sqlalchemy import paginate @@ -57,58 +56,48 @@ def database_error_handler( - payload: CreateEmail | CreateContact | CreatePhone, error: ProgrammingError + payload: CreateAddress | CreateEmail | CreateContact | CreatePhone, + error: IntegrityError | ProgrammingError, ) -> None: """ Handle errors raised by the database when adding or updating a sample. """ - error_message = error.orig.args[0]["M"] + orig = getattr(error, "orig", None) + if hasattr(orig, "args") and orig.args and isinstance(orig.args[0], dict): + error_message = orig.args[0].get("M", "") + else: + error_message = str(orig or error) - if ( - error_message - == 'insert or update on table "thing_contact_association" violates foreign key constraint "thing_contact_association_thing_id_fkey"' - ): + if 'constraint "thing_contact_association_thing_id_fkey"' in error_message: detail = { "loc": ["body", "thing_id"], "msg": f"Thing with ID {payload.thing_id} not found.", "type": "value_error", "input": {"thing_id": payload.thing_id}, } - elif ( - error_message - == 'insert or update on table "email" violates foreign key constraint "email_contact_id_fkey"' - ): + elif 'constraint "email_contact_id_fkey"' in error_message: detail = { "loc": ["body", "contact_id"], "msg": f"Contact with ID {payload.contact_id} not found.", "type": "value_error", "input": {"contact_id": payload.contact_id}, } - elif ( - error_message - == 'insert or update on table "phone" violates foreign key constraint "phone_contact_id_fkey"' - ): + elif 'constraint "phone_contact_id_fkey"' in error_message: detail = { "loc": ["body", "contact_id"], "msg": f"Contact with ID {payload.contact_id} not found.", "type": "value_error", "input": {"contact_id": payload.contact_id}, } - elif ( - error_message - == 'insert or update on table "address" violates foreign key constraint "address_contact_id_fkey"' - ): + elif 'constraint "address_contact_id_fkey"' in error_message: detail = { "loc": ["body", "contact_id"], "msg": f"Contact with ID {payload.contact_id} not found.", "type": "value_error", "input": {"contact_id": payload.contact_id}, } - elif ( - error_message - == 'insert or update on table "contact" violates foreign key constraint "contact_contact_type_fkey"' - ): + elif 'constraint "contact_contact_type_fkey"' in error_message: valid_terms = get_terms_by_category("contact_type") valid_contact_types_for_msg = " | ".join(valid_terms) detail = { @@ -117,6 +106,13 @@ def database_error_handler( "type": "value_error", "input": {"contact_type": payload.contact_type}, } + else: + detail = { + "loc": ["body"], + "msg": error_message, + "type": "value_error", + "input": {}, + } raise PydanticStyleException(status_code=status.HTTP_409_CONFLICT, detail=[detail]) @@ -129,12 +125,12 @@ def database_error_handler( summary="Create a new contact", status_code=status.HTTP_201_CREATED, ) -async def create_contact( +def create_contact( contact_data: CreateContact, session: session_dependency, user: amp_admin_dependency ) -> ContactResponse: try: return add_contact(session, contact_data, user=user) - except ProgrammingError as e: + except (IntegrityError, ProgrammingError) as e: database_error_handler(contact_data, e) @@ -143,7 +139,7 @@ async def create_contact( summary="Add an address to a contact", status_code=status.HTTP_201_CREATED, ) -async def create_address( +def create_address( address_data: CreateAddress, session: session_dependency, user: amp_admin_dependency, @@ -157,7 +153,7 @@ async def create_address( """ try: return model_adder(session, Address, address_data, user=user) - except ProgrammingError as e: + except (IntegrityError, ProgrammingError) as e: database_error_handler(address_data, e) @@ -166,14 +162,14 @@ async def create_address( summary="Add an email to a contact", status_code=status.HTTP_201_CREATED, ) -async def create_email( +def create_email( email_data: CreateEmail, session: session_dependency, user: amp_admin_dependency, ) -> EmailResponse: try: return model_adder(session, Email, email_data, user=user) - except ProgrammingError as e: + except (IntegrityError, ProgrammingError) as e: database_error_handler(email_data, e) @@ -182,14 +178,14 @@ async def create_email( summary="Add a phone number to a contact", status_code=status.HTTP_201_CREATED, ) -async def create_phone( +def create_phone( phone_data: CreatePhone, session: session_dependency, user: amp_admin_dependency, ) -> PhoneResponse: try: return model_adder(session, Phone, phone_data, user=user) - except ProgrammingError as e: + except (IntegrityError, ProgrammingError) as e: database_error_handler(phone_data, e) @@ -221,7 +217,7 @@ async def create_phone( @router.patch( "/email/{email_id}", ) -async def update_contact_email( +def update_contact_email( email_id: int, email_data: UpdateEmail, session: session_dependency, @@ -236,7 +232,7 @@ async def update_contact_email( @router.patch( "/phone/{phone_id}", ) -async def update_contact_phone( +def update_contact_phone( phone_id: int, phone_data: UpdatePhone, session: session_dependency, @@ -256,7 +252,7 @@ async def update_contact_phone( @router.patch( "/address/{address_id}", ) -async def update_contact_address( +def update_contact_address( address_id: int, address_data: UpdateAddress, session: session_dependency, @@ -304,7 +300,7 @@ async def update_contact_address( @router.patch("/{contact_id}", summary="Update contact") -async def update_contact( +def update_contact( contact_id: int, contact_data: UpdateContact, session: session_dependency, @@ -365,7 +361,7 @@ async def update_contact( try: return model_patcher(session, Contact, contact_id, contact_data, user=user) - except ProgrammingError as e: + except (IntegrityError, ProgrammingError) as e: database_error_handler(contact_data, e) @@ -373,7 +369,7 @@ async def update_contact( @router.get("/email", summary="Get all emails") -async def get_emails( +def get_emails( session: session_dependency, user: amp_viewer_dependency ) -> CustomPage[EmailResponse]: """ @@ -385,7 +381,7 @@ async def get_emails( @router.get("/email/{email_id}", summary="Get email by ID") -async def get_email_by_id( +def get_email_by_id( email_id: int, session: session_dependency, user: amp_viewer_dependency ) -> EmailResponse: """ @@ -395,7 +391,7 @@ async def get_email_by_id( @router.get("/phone", summary="Get all phones") -async def get_phones( +def get_phones( session: session_dependency, user: amp_viewer_dependency ) -> CustomPage[PhoneResponse]: """ @@ -407,7 +403,7 @@ async def get_phones( @router.get("/phone/{phone_id}", summary="Get phone by ID") -async def get_phone_by_id( +def get_phone_by_id( phone_id: int, session: session_dependency, user: amp_viewer_dependency ) -> PhoneResponse: """ @@ -417,7 +413,7 @@ async def get_phone_by_id( @router.get("/address", summary="Get all addresses") -async def get_addresses( +def get_addresses( session: session_dependency, user: amp_viewer_dependency ) -> CustomPage[AddressResponse]: """ @@ -429,7 +425,7 @@ async def get_addresses( @router.get("/address/{address_id}", summary="Get address by ID") -async def get_address_by_id( +def get_address_by_id( address_id: int, session: session_dependency, user: amp_viewer_dependency ) -> AddressResponse: """ @@ -468,7 +464,7 @@ async def get_address_by_id( @router.get("", summary="Get contacts") -async def get_contacts( +def get_contacts( session: session_dependency, user: amp_viewer_dependency, sort: str = None, @@ -485,7 +481,7 @@ async def get_contacts( @router.get("/{contact_id}", summary="Get contact by ID") -async def get_contact_by_id( +def get_contact_by_id( contact_id: int, session: session_dependency, user: amp_viewer_dependency ) -> ContactResponse: """ @@ -495,7 +491,7 @@ async def get_contact_by_id( @router.get("/{contact_id}/email", summary="Get contact emails") -async def get_contact_emails( +def get_contact_emails( contact_id: int, session: session_dependency, user: amp_viewer_dependency ) -> CustomPage[EmailResponse]: """ @@ -507,7 +503,7 @@ async def get_contact_emails( @router.get("/{contact_id}/phone", summary="Get contact phones") -async def get_contact_phones( +def get_contact_phones( contact_id: int, session: session_dependency, user: amp_viewer_dependency ) -> CustomPage[PhoneResponse]: """ @@ -519,7 +515,7 @@ async def get_contact_phones( @router.get("/{contact_id}/address", summary="Get contact addresses") -async def get_contact_addresses( +def get_contact_addresses( contact_id: int, session: session_dependency, user: amp_viewer_dependency ) -> CustomPage[AddressResponse]: """ @@ -548,7 +544,7 @@ async def get_contact_addresses( @router.delete("/email/{email_id}", summary="Delete contact email") -async def delete_contact_email( +def delete_contact_email( email_id: int, session: session_dependency, user: amp_admin_dependency ): """ @@ -558,7 +554,7 @@ async def delete_contact_email( @router.delete("/phone/{phone_id}", summary="Delete contact phone") -async def delete_contact_phone( +def delete_contact_phone( phone_id: int, session: session_dependency, user: amp_admin_dependency ): """ @@ -568,7 +564,7 @@ async def delete_contact_phone( @router.delete("/address/{address_id}", summary="Delete contact address") -async def delete_contact_address( +def delete_contact_address( address_id: int, session: session_dependency, user: amp_admin_dependency ): """ @@ -593,7 +589,7 @@ async def delete_contact_address( @router.delete("/{contact_id}", summary="Delete contact") -async def delete_contact( +def delete_contact( contact_id: int, session: session_dependency, user: amp_admin_dependency ): """ diff --git a/api/geochronology.py b/api/geochronology.py index af3d984cf..0497f0e38 100644 --- a/api/geochronology.py +++ b/api/geochronology.py @@ -24,7 +24,7 @@ @router.post("/age", tags=["geochronology"], status_code=status.HTTP_201_CREATED) -async def create_age( +def create_age( user: viewer_dependency, age: CreateGeochronologyAge, session: session_dependency ): """ @@ -36,7 +36,7 @@ async def create_age( @router.get("/age", tags=["geochronology"]) -async def get_geochronology_age( +def get_geochronology_age( user: viewer_dependency, session: session_dependency, method: str = "arar" ): """ diff --git a/api/geospatial.py b/api/geospatial.py index f718b41ed..082979f8a 100644 --- a/api/geospatial.py +++ b/api/geospatial.py @@ -32,7 +32,7 @@ @router.get("") -async def get_geospatial( +def get_geospatial( user: viewer_dependency, session: session_dependency, thing_type: Annotated[List[str], Query(title="thing_type")] = None, @@ -61,7 +61,7 @@ async def get_geospatial( @router.get("/project-area/{group_id}", summary="Get project area for group") -async def get_project_area( +def get_project_area( user: viewer_dependency, session: session_dependency, group_id: int ) -> FeatureCollectionResponse: diff --git a/api/group.py b/api/group.py index 39b53791b..5399ce103 100644 --- a/api/group.py +++ b/api/group.py @@ -38,7 +38,7 @@ @router.post("", summary="Create a new group", status_code=HTTP_201_CREATED) -async def create_group( +def create_group( group_data: CreateGroup, session: session_dependency, user: admin_dependency ) -> GroupResponse: """ @@ -66,7 +66,7 @@ async def create_group( # ============= Get ============================================= @router.get("", summary="Get groups") -async def get_groups( +def get_groups( user: viewer_dependency, session: session_dependency, filter_: str = Query(alias="filter", default=None), @@ -78,7 +78,7 @@ async def get_groups( @router.get("/{group_id}", summary="Get group by ID") -async def get_group_by_id( +def get_group_by_id( user: viewer_dependency, group_id: int, session: session_dependency ) -> GroupResponse: """ @@ -100,7 +100,7 @@ async def get_group_by_id( # ============= Patch ============================================= @router.patch("/{group_id}", summary="Update a group by ID") -async def update_group( +def update_group( user: editor_dependency, group_id: int, group_data: UpdateGroup, @@ -116,9 +116,7 @@ async def update_group( @router.delete( "/{group_id}", summary="Delete a group by ID", status_code=HTTP_204_NO_CONTENT ) -async def delete_group( - user: admin_dependency, group_id: int, session: session_dependency -): +def delete_group(user: admin_dependency, group_id: int, session: session_dependency): return model_deleter(session, Group, group_id) diff --git a/api/lexicon.py b/api/lexicon.py index e0f08b56e..ee71831e2 100644 --- a/api/lexicon.py +++ b/api/lexicon.py @@ -106,7 +106,7 @@ def disabled_endpoint(): deprecated=True, dependencies=[Depends(disabled_endpoint)], ) -async def add_category( +def add_category( category_data: CreateLexiconCategory, session: session_dependency, user: lexicon_admin_dependency, @@ -124,7 +124,7 @@ async def add_category( deprecated=True, dependencies=[Depends(disabled_endpoint)], ) -async def add_term( +def add_term( term_data: CreateLexiconTerm, session: session_dependency, user: lexicon_admin_dependency, @@ -145,7 +145,7 @@ async def add_term( deprecated=True, dependencies=[Depends(disabled_endpoint)], ) -async def add_triple( +def add_triple( triple_data: CreateLexiconTriple, session: session_dependency, user: lexicon_admin_dependency, @@ -166,7 +166,7 @@ async def add_triple( deprecated=True, dependencies=[Depends(disabled_endpoint)], ) -async def update_lexicon_term( +def update_lexicon_term( term_id: int, term_data: UpdateLexiconTerm, session: session_dependency, @@ -182,7 +182,7 @@ async def update_lexicon_term( deprecated=True, dependencies=[Depends(disabled_endpoint)], ) -async def update_lexicon_category( +def update_lexicon_category( category_id: int, category_data: UpdateLexiconCategory, session: session_dependency, @@ -199,7 +199,7 @@ async def update_lexicon_category( deprecated=True, dependencies=[Depends(disabled_endpoint)], ) -async def update_lexicon_triple( +def update_lexicon_triple( triple_id: int, triple_data: UpdateLexiconTriple, session: session_dependency, @@ -215,7 +215,7 @@ async def update_lexicon_triple( @router.get("/term", summary="Get lexicon terms", status_code=HTTP_200_OK) -async def get_lexicon_terms( +def get_lexicon_terms( session: session_dependency, user: viewer_dependency, category: str | None = None, @@ -252,14 +252,14 @@ async def get_lexicon_terms( @router.get("/term/{term_id}", status_code=HTTP_200_OK) -async def get_lexicon_term( +def get_lexicon_term( term_id: int, session: session_dependency, user: viewer_dependency ) -> LexiconTermResponse: return simple_get_by_id(session, LexiconTerm, term_id) @router.get("/category") -async def get_lexicon_categories( +def get_lexicon_categories( session: session_dependency, user: viewer_dependency, name: str | None = None, @@ -278,14 +278,14 @@ async def get_lexicon_categories( @router.get("/category/{category_id}") -async def get_lexicon_category( +def get_lexicon_category( category_id: int, user: viewer_dependency, session: session_dependency ) -> LexiconCategoryResponse: return simple_get_by_id(session, LexiconCategory, category_id) @router.get("/triple", summary="Get lexicon triples", status_code=HTTP_200_OK) -async def get_lexicon_triples( +def get_lexicon_triples( session: session_dependency, user: viewer_dependency, sort: str = "subject", @@ -299,7 +299,7 @@ async def get_lexicon_triples( @router.get("/triple/{triple_id}", status_code=HTTP_200_OK) -async def get_lexicon_triple( +def get_lexicon_triple( triple_id: int, session: session_dependency, user: viewer_dependency ) -> LexiconTripleResponse: return simple_get_by_id(session, LexiconTriple, triple_id) @@ -315,7 +315,7 @@ async def get_lexicon_triple( deprecated=True, dependencies=[Depends(disabled_endpoint)], ) -async def delete_lexicon_term( +def delete_lexicon_term( session: session_dependency, user: lexicon_admin_dependency, term_id: int ): return model_deleter(session, LexiconTerm, term_id) @@ -328,7 +328,7 @@ async def delete_lexicon_term( deprecated=True, dependencies=[Depends(disabled_endpoint)], ) -async def delete_lexicon_category( +def delete_lexicon_category( session: session_dependency, user: lexicon_admin_dependency, category_id: int ): return model_deleter(session, LexiconCategory, category_id) @@ -341,7 +341,7 @@ async def delete_lexicon_category( deprecated=True, dependencies=[Depends(disabled_endpoint)], ) -async def delete_lexicon_triple( +def delete_lexicon_triple( session: session_dependency, user: lexicon_admin_dependency, triple_id: int ): return model_deleter(session, LexiconTriple, triple_id) diff --git a/api/location.py b/api/location.py index af2590e58..b5e595d3e 100644 --- a/api/location.py +++ b/api/location.py @@ -43,7 +43,7 @@ summary="Create a new sample location", status_code=status.HTTP_201_CREATED, ) -async def create_location( +def create_location( location_data: CreateLocation, session: session_dependency, user: admin_dependency ) -> LocationResponse: """ @@ -58,7 +58,7 @@ async def create_location( "/{location_id}", summary="Update a location", ) -async def update_location( +def update_location( location_id: int, location_data: UpdateLocation, session: session_dependency, @@ -131,7 +131,7 @@ async def update_location( "", summary="Get all locations", ) -async def get_location( +def get_location( session: session_dependency, user: viewer_dependency, nearby_point: str = None, @@ -168,7 +168,7 @@ async def get_location( "/{location_id}", summary="Get location by ID", ) -async def get_location_by_id( +def get_location_by_id( location_id: int, session: session_dependency, user: viewer_dependency ) -> LocationResponse: """ @@ -179,7 +179,7 @@ async def get_location_by_id( @router.delete("/{location_id}", summary="Delete location by ID") -async def delete_location( +def delete_location( location_id: int, session: session_dependency, user: admin_dependency ) -> Response: """ diff --git a/api/ngwmn.py b/api/ngwmn.py index 4d8b065cf..7fc2e1d51 100644 --- a/api/ngwmn.py +++ b/api/ngwmn.py @@ -30,7 +30,7 @@ "/waterlevels/{pointid}", summary="Get waterlevels for a given pointid in the NGWMN format", ) -async def read_ngwmn_waterlevels(pointid: str, db: session_dependency): +def read_ngwmn_waterlevels(pointid: str, db: session_dependency): data = make_waterlevels_response(pointid, db) return Response(content=data, media_type="application/xml") @@ -39,7 +39,7 @@ async def read_ngwmn_waterlevels(pointid: str, db: session_dependency): "/wellconstruction/{pointid}", summary="Get wellconstruction for a given pointid in the NGWMN format", ) -async def read_ngwmn_wellconstruction(pointid: str, db: session_dependency): +def read_ngwmn_wellconstruction(pointid: str, db: session_dependency): data = make_well_construction_response(pointid, db) return Response(content=data, media_type="application/xml") @@ -48,7 +48,7 @@ async def read_ngwmn_wellconstruction(pointid: str, db: session_dependency): "/lithology/{pointid}", summary="Get lithology for a given pointid in the NGWMN format", ) -async def read_ngwmn_lithology(pointid: str, db: session_dependency): +def read_ngwmn_lithology(pointid: str, db: session_dependency): data = make_lithology_response(pointid, db) return Response(content=data, media_type="application/xml") diff --git a/api/observation.py b/api/observation.py index 3b446bd71..d4c7fff78 100644 --- a/api/observation.py +++ b/api/observation.py @@ -64,7 +64,7 @@ # ============= Post ============================================= @router.post("/groundwater-level", status_code=HTTP_201_CREATED) -async def add_groundwater_level_observation( +def add_groundwater_level_observation( obs_data: CreateGroundwaterLevelObservation, session: session_dependency, user: amp_admin_dependency, @@ -76,7 +76,7 @@ async def add_groundwater_level_observation( @router.post("/water-chemistry", status_code=HTTP_201_CREATED) -async def add_water_chemistry_observation( +def add_water_chemistry_observation( obs_data: CreateWaterChemistryObservation, session: session_dependency, user: amp_admin_dependency, @@ -116,7 +116,7 @@ async def bulk_upload_groundwater_levels( @router.patch("/groundwater-level/{observation_id}", status_code=HTTP_200_OK) -async def update_groundwater_level_observation( +def update_groundwater_level_observation( observation_id: int, obs_data: UpdateGroundwaterLevelObservation, session: session_dependency, @@ -130,7 +130,7 @@ async def update_groundwater_level_observation( @router.patch("/water-chemistry/{observation_id}", status_code=HTTP_200_OK) -async def update_water_chemistry_observation( +def update_water_chemistry_observation( observation_id: int, obs_data: UpdateWaterChemistryObservation, session: session_dependency, @@ -148,7 +148,7 @@ async def update_water_chemistry_observation( "/transducer-groundwater-level", summary="Get transducer groundwater level observations", ) -async def get_transducer_groundwater_level_observations( +def get_transducer_groundwater_level_observations( request: Request, session: session_dependency, user: amp_viewer_dependency, @@ -170,7 +170,7 @@ async def get_transducer_groundwater_level_observations( @router.get("/groundwater-level", summary="Get groundwater level observations") -async def get_groundwater_level_observations( +def get_groundwater_level_observations( request: Request, session: session_dependency, user: amp_viewer_dependency, @@ -204,7 +204,7 @@ async def get_groundwater_level_observations( "/groundwater-level/{observation_id}", summary="Get groundwater level observation by ID", ) -async def get_groundwater_level_observation_by_id( +def get_groundwater_level_observation_by_id( session: session_dependency, request: Request, user: amp_viewer_dependency, @@ -218,7 +218,7 @@ async def get_groundwater_level_observation_by_id( @router.get("/water-chemistry", summary="Get water chemistry observations") -async def get_water_chemistry_observations( +def get_water_chemistry_observations( request: Request, session: session_dependency, user: amp_viewer_dependency, @@ -251,7 +251,7 @@ async def get_water_chemistry_observations( @router.get( "/water-chemistry/{observation_id}", summary="Get water chemistry observation by ID" ) -async def get_water_chemistry_observation_by_id( +def get_water_chemistry_observation_by_id( session: session_dependency, request: Request, user: amp_viewer_dependency, @@ -265,7 +265,7 @@ async def get_water_chemistry_observation_by_id( @router.get("", summary="Get all observations") -async def get_all_observations( +def get_all_observations( request: Request, session: session_dependency, user: amp_viewer_dependency, @@ -293,7 +293,7 @@ async def get_all_observations( @router.get("/{observation_id}", summary="Get an observation by its ID") -async def get_observation_by_id( +def get_observation_by_id( session: session_dependency, user: amp_viewer_dependency, observation_id: int ) -> ObservationResponse: return simple_get_by_id(session, Observation, observation_id) @@ -307,7 +307,7 @@ async def get_observation_by_id( summary="Delete an observation", status_code=HTTP_204_NO_CONTENT, ) -async def delete_observation( +def delete_observation( session: session_dependency, user: amp_admin_dependency, observation_id: int ) -> None: return model_deleter(session, Observation, observation_id) diff --git a/api/publication.py b/api/publication.py index 751c0ec88..76ca58893 100644 --- a/api/publication.py +++ b/api/publication.py @@ -29,7 +29,7 @@ @router.post( "/add", response_model=PublicationResponse, status_code=status.HTTP_201_CREATED ) -async def post_publication( +def post_publication( user: admin_dependency, publication_data: CreatePublication, # Replace with your actual schema session: Session = Depends( diff --git a/api/sample.py b/api/sample.py index fdd471cb4..5dba46160 100644 --- a/api/sample.py +++ b/api/sample.py @@ -55,11 +55,12 @@ def database_error_handler( """ Handle errors raised by the database when adding or updating a sample. """ - error_message = error.orig.args[0]["M"] - if ( - error_message == "duplicate key value violates unique " - 'constraint "sample_sample_name_key"' - ): + orig = getattr(error, "orig", None) + if hasattr(orig, "args") and orig.args and isinstance(orig.args[0], dict): + error_message = orig.args[0].get("M", "") + else: + error_message = str(orig or error) + if 'constraint "sample_sample_name_key"' in error_message: detail = { "loc": ["body", "sample_name"], "msg": ( @@ -68,11 +69,7 @@ def database_error_handler( "type": "value_error", "input": {"sample_name": payload.sample_name}, } - elif ( - error_message - == 'insert or update on table "sample" violates foreign key constraint ' - '"sample_field_activity_id_fkey"' - ): + elif 'constraint "sample_field_activity_id_fkey"' in error_message: detail = { "loc": ["body", "field_activity_id"], "msg": ( @@ -81,6 +78,13 @@ def database_error_handler( "type": "value_error", "input": {"field_activity_id": payload.field_activity_id}, } + else: + detail = { + "loc": ["body"], + "msg": error_message, + "type": "value_error", + "input": {}, + } raise PydanticStyleException( status_code=HTTP_409_CONFLICT, @@ -90,7 +94,7 @@ def database_error_handler( # ============= Post ============================================= @router.post("", status_code=HTTP_201_CREATED) -async def add_sample( +def add_sample( sample_data: CreateSample, session: session_dependency, user: admin_dependency, @@ -108,7 +112,7 @@ async def add_sample( # ============= Update ============================================= @router.patch("/{sample_id}", summary="Update Sample") -async def update_sample( +def update_sample( sample_id: int, sample_data: UpdateSample, session: session_dependency, @@ -133,7 +137,7 @@ async def update_sample( # ============= Get ============================================= @router.get("", summary="Get Samples") -async def get_samples( +def get_samples( session: session_dependency, user: viewer_dependency, thing_id: int | None = None, @@ -154,7 +158,7 @@ async def get_samples( @router.get("/{sample_id}", summary="Get Sample by ID") -async def get_sample_by_id( +def get_sample_by_id( sample_id: int, session: session_dependency, user: viewer_dependency, @@ -172,7 +176,7 @@ async def get_sample_by_id( "/{sample_id}", summary="Delete Sample by ID", ) -async def delete_sample_by_id( +def delete_sample_by_id( sample_id: int, session: session_dependency, user: admin_dependency, diff --git a/api/search.py b/api/search.py index 9c587a016..b1a6b36f7 100644 --- a/api/search.py +++ b/api/search.py @@ -197,7 +197,7 @@ def _get_asset_results(session: Session, q: str, limit: int) -> list[dict]: @router.get("") -async def search_api( +def search_api( user: viewer_dependency, session: session_dependency, q: str, diff --git a/api/sensor.py b/api/sensor.py index 49e1c0ba5..69ab28160 100644 --- a/api/sensor.py +++ b/api/sensor.py @@ -39,7 +39,7 @@ @router.post("", status_code=status.HTTP_201_CREATED) -async def add_sensor( +def add_sensor( sensor_data: CreateSensor, session: session_dependency, user: admin_dependency ) -> SensorResponse: """ @@ -55,7 +55,7 @@ async def add_sensor( @router.patch("/{sensor_id}", status_code=status.HTTP_200_OK) -async def update_sensor( +def update_sensor( sensor_id: int, sensor_data: UpdateSensor, session: session_dependency, @@ -115,7 +115,7 @@ async def update_sensor( @router.delete("/{sensor_id}") -async def delete_sensor( +def delete_sensor( sensor_id: int, session: session_dependency, user: admin_dependency ) -> Response: """ @@ -128,7 +128,7 @@ async def delete_sensor( @router.get("", status_code=status.HTTP_200_OK) -async def get_sensors( +def get_sensors( session: session_dependency, user: viewer_dependency, thing_id: int = None, # Optional filter for thing_id. Filter by the Thing where equipment is deployed @@ -157,7 +157,7 @@ async def get_sensors( @router.get("/{sensor_id}", status_code=status.HTTP_200_OK) -async def get_sensor( +def get_sensor( sensor_id: int, session: session_dependency, user: viewer_dependency ) -> SensorResponse: """ diff --git a/api/thing.py b/api/thing.py index 5b8a52e1d..8ba57c76a 100644 --- a/api/thing.py +++ b/api/thing.py @@ -17,7 +17,7 @@ from fastapi import APIRouter, Query, Request from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select -from sqlalchemy.exc import ProgrammingError +from sqlalchemy.exc import IntegrityError, ProgrammingError from sqlalchemy.orm import selectinload from starlette.status import ( HTTP_200_OK, @@ -80,50 +80,41 @@ def database_error_handler( - payload: CreateWell | CreateSpring, error: ProgrammingError + payload: CreateWell | CreateSpring | CreateWellScreen | CreateThingIdLink, + error: IntegrityError | ProgrammingError, ) -> None: """ Handle errors raised by the database when adding or updating a thing. """ - error_message = error.orig.args[0]["M"] - - if ( - error_message - == 'insert or update on table "group_thing_association" violates foreign key constraint "group_thing_association_group_id_fkey"' - ): + orig = getattr(error, "orig", None) + if hasattr(orig, "args") and orig.args and isinstance(orig.args[0], dict): + error_message = orig.args[0].get("M", "") + else: + error_message = str(orig or error) + if 'constraint "group_thing_association_group_id_fkey"' in error_message: detail = { "loc": ["body", "group_id"], "msg": f"Group with ID {payload.group_id} not found.", "type": "value_error", "input": {"group_id": payload.group_id}, } - elif ( - error_message - == 'insert or update on table "location_thing_association" violates foreign key constraint "location_thing_association_location_id_fkey"' - ): - + elif 'constraint "location_thing_association_location_id_fkey"' in error_message: detail = { "loc": ["body", "location_id"], "msg": f"Location with ID {payload.location_id} not found.", "type": "value_error", "input": {"location_id": payload.location_id}, } - elif ( - error_message - == 'insert or update on table "well_screen" violates foreign key constraint "well_screen_thing_id_fkey"' - ): + elif 'constraint "well_screen_thing_id_fkey"' in error_message: detail = { "loc": ["body", "thing_id"], "msg": f"Thing with ID {payload.thing_id} not found.", "type": "value_error", "input": {"thing_id": payload.thing_id}, } - elif ( - error_message - == 'insert or update on table "well_screen" violates foreign key constraint "well_screen_screen_type_fkey"' - ): + elif 'constraint "well_screen_screen_type_fkey"' in error_message: valid_screen_types = get_terms_by_category("casing_material") valid_screen_types_for_msg = " | ".join(valid_screen_types) detail = { @@ -132,16 +123,20 @@ def database_error_handler( "type": "value_error", "input": {"screen_type": payload.screen_type}, } - elif ( - error_message - == 'insert or update on table "thing_id_link" violates foreign key constraint "thing_id_link_thing_id_fkey"' - ): + elif 'constraint "thing_id_link_thing_id_fkey"' in error_message: detail = { "loc": ["body", "thing_id"], "msg": f"Thing with ID {payload.thing_id} not found.", "type": "value_error", "input": {"thing_id": payload.thing_id}, } + else: + detail = { + "loc": ["body"], + "msg": error_message, + "type": "value_error", + "input": {}, + } raise PydanticStyleException(status_code=HTTP_409_CONFLICT, detail=[detail]) @@ -150,7 +145,7 @@ def database_error_handler( @router.get("/water-well", summary="Get all water wells", status_code=HTTP_200_OK) -async def get_water_wells( +def get_water_wells( user: viewer_dependency, session: session_dependency, request: Request, @@ -180,7 +175,7 @@ async def get_water_wells( @router.get( "/water-well/{thing_id}", summary="Get water well by ID", status_code=HTTP_200_OK ) -async def get_well_by_id( +def get_well_by_id( user: viewer_dependency, thing_id: int, session: session_dependency, @@ -197,7 +192,7 @@ async def get_well_by_id( summary="Get water well details payload", status_code=HTTP_200_OK, ) -async def get_well_details( +def get_well_details( user: viewer_dependency, thing_id: int, session: session_dependency, @@ -219,7 +214,7 @@ async def get_well_details( summary="Get water well export payload", status_code=HTTP_200_OK, ) -async def get_well_export( +def get_well_export( user: viewer_dependency, thing_id: int, session: session_dependency, @@ -240,7 +235,7 @@ async def get_well_export( summary="Get well screens by water well ID", status_code=HTTP_200_OK, ) -async def get_well_screens_by_well_id( +def get_well_screens_by_well_id( user: viewer_dependency, thing_id: int, session: session_dependency, @@ -258,7 +253,7 @@ async def get_well_screens_by_well_id( "/well-screen", summary="Get well screens", ) -async def get_well_screens( +def get_well_screens( user: viewer_dependency, session: session_dependency, thing_id: int = None, @@ -277,7 +272,7 @@ async def get_well_screens( "/well-screen/{wellscreen_id}", summary="Get well screen by ID", ) -async def get_well_screen_by_id( +def get_well_screen_by_id( user: viewer_dependency, session: session_dependency, wellscreen_id: int, @@ -290,7 +285,7 @@ async def get_well_screen_by_id( @router.get("/spring", summary="Get all springs") -async def get_springs( +def get_springs( user: viewer_dependency, session: session_dependency, request: Request, @@ -307,7 +302,7 @@ async def get_springs( @router.get("/spring/{thing_id}", summary="Get spring by ID", status_code=HTTP_200_OK) -async def get_spring_by_id( +def get_spring_by_id( user: viewer_dependency, thing_id: int, session: session_dependency, @@ -323,7 +318,7 @@ async def get_spring_by_id( "/id-link", summary="Get all thing links", ) -async def get_thing_id_links( +def get_thing_id_links( user: viewer_dependency, session: session_dependency, filter_: str = Query(alias="filter", default=None), @@ -341,7 +336,7 @@ async def get_thing_id_links( @public_route @router.get("/id-link/{link_id}", summary="Get thing links by link ID") -async def get_thing_id_links( +def get_thing_id_links( user: viewer_dependency, link_id: int, session: session_dependency, @@ -354,7 +349,7 @@ async def get_thing_id_links( @public_route @router.get("", summary="Get all things", status_code=HTTP_200_OK) -async def get_things( +def get_things( user: viewer_dependency, session: session_dependency, within: Optional[str] = None, @@ -383,7 +378,7 @@ async def get_things( @router.get("/{thing_id}", summary="Get thing by ID", status_code=HTTP_200_OK) -async def get_thing_by_id( +def get_thing_by_id( user: viewer_dependency, thing_id: int, session: session_dependency, @@ -397,7 +392,7 @@ async def get_thing_by_id( @router.get("/{thing_id}/id-link", summary="Get thing links by thing ID") -async def get_thing_id_links( +def get_thing_id_links( user: viewer_dependency, thing_id: int, session: session_dependency, @@ -411,7 +406,7 @@ async def get_thing_id_links( @router.get("/{thing_id}/deployment", summary="Get deployments by thing ID") -async def get_thing_deployments( +def get_thing_deployments( user: viewer_dependency, thing_id: int, session: session_dependency, @@ -431,7 +426,7 @@ async def get_thing_deployments( @router.post( "/id-link", status_code=HTTP_201_CREATED, summary="Create a new thing link" ) -async def create_thing_id_link( +def create_thing_id_link( link_data: CreateThingIdLink, session: session_dependency, user: admin_dependency, @@ -441,7 +436,7 @@ async def create_thing_id_link( """ try: return model_adder(session, ThingIdLink, link_data, user=user) - except ProgrammingError as e: + except (IntegrityError, ProgrammingError) as e: database_error_handler(link_data, e) @@ -450,7 +445,7 @@ async def create_thing_id_link( summary="Create a water well", status_code=HTTP_201_CREATED, ) -async def create_well( +def create_well( thing_data: CreateWell, session: session_dependency, request: Request, @@ -463,7 +458,7 @@ async def create_well( thing = add_thing(session=session, data=thing_data, request=request, user=user) modify_well_descriptor_tables(session, thing, thing_data, user) return thing - except ProgrammingError as e: + except (IntegrityError, ProgrammingError) as e: database_error_handler(thing_data, e) @@ -472,7 +467,7 @@ async def create_well( summary="Create a new spring", status_code=HTTP_201_CREATED, ) -async def create_spring( +def create_spring( thing_data: CreateSpring, session: session_dependency, request: Request, @@ -483,7 +478,7 @@ async def create_spring( """ try: return add_thing(session=session, data=thing_data, request=request, user=user) - except ProgrammingError as e: + except (IntegrityError, ProgrammingError) as e: database_error_handler(thing_data, e) @@ -492,7 +487,7 @@ async def create_spring( summary="Create a new well screen", status_code=HTTP_201_CREATED, ) -async def create_wellscreen( +def create_wellscreen( session: session_dependency, user: admin_dependency, well_screen_data: CreateWellScreen, @@ -502,7 +497,7 @@ async def create_wellscreen( """ try: return add_well_screen(session, well_screen_data, user=user) - except ProgrammingError as e: + except (IntegrityError, ProgrammingError) as e: database_error_handler(well_screen_data, e) except PydanticStyleException as e: raise e @@ -516,7 +511,7 @@ async def create_wellscreen( summary="Update well by parent thing ID", status_code=HTTP_200_OK, ) -async def update_water_well( +def update_water_well( thing_id: int, thing_data: UpdateWell, session: session_dependency, @@ -545,7 +540,7 @@ async def update_water_well( summary="Update spring by parent thing ID", status_code=HTTP_200_OK, ) -async def update_spring( +def update_spring( thing_id: int, thing_data: UpdateSpring, session: session_dependency, @@ -561,7 +556,7 @@ async def update_spring( @router.patch( "/id-link/{link_id}", summary="Update thing link by ID", status_code=HTTP_200_OK ) -async def update_thing_id_link( +def update_thing_id_link( link_id: int, link_data: UpdateThingIdLink, session: session_dependency, @@ -575,7 +570,7 @@ async def update_thing_id_link( summary="Update Well Screen by ID", status_code=HTTP_200_OK, ) -async def update_well_screen( +def update_well_screen( well_screen_id: int, well_screen_data: UpdateWellScreen, session: session_dependency, @@ -594,7 +589,7 @@ async def update_well_screen( @router.delete( "/{thing_id}", summary="Delete thing by ID", status_code=HTTP_204_NO_CONTENT ) -async def delete_thing( +def delete_thing( thing_id: int, session: session_dependency, user: admin_dependency, @@ -610,7 +605,7 @@ async def delete_thing( summary="Delete well screen by ID", status_code=HTTP_204_NO_CONTENT, ) -async def delete_well_screen( +def delete_well_screen( well_screen_id: int, session: session_dependency, user: admin_dependency, @@ -626,7 +621,7 @@ async def delete_well_screen( summary="Delete thing link by ID", status_code=HTTP_204_NO_CONTENT, ) -async def delete_thing_id_link( +def delete_thing_id_link( link_id: int, session: session_dependency, user: admin_dependency, diff --git a/tests/integration/test_nma_legacy_relationships.py b/tests/integration/test_nma_legacy_relationships.py index c613f13cd..7731f86a8 100644 --- a/tests/integration/test_nma_legacy_relationships.py +++ b/tests/integration/test_nma_legacy_relationships.py @@ -212,11 +212,11 @@ def test_chemistry_sample_requires_thing(self): with session_ctx() as session: record = NMA_Chemistry_SampleInfo( nma_sample_pt_id=uuid.uuid4(), - nma_sample_point_id="ORPHAN-CHEM", + nma_sample_point_id="ORPHAN", # No thing_id - should fail on commit ) session.add(record) - # pg8000 raises ProgrammingError for NOT NULL violations (error code 23502) + # Driver may surface NOT NULL violations as ProgrammingError (code 23502) with pytest.raises((IntegrityError, ProgrammingError, ValueError)): session.commit() session.rollback() diff --git a/tests/test_nma_chemistry_lineage.py b/tests/test_nma_chemistry_lineage.py index f0853958d..b67109317 100644 --- a/tests/test_nma_chemistry_lineage.py +++ b/tests/test_nma_chemistry_lineage.py @@ -348,7 +348,7 @@ def test_sample_info_requires_thing(shared_thing): # No thing_id - should fail ) session.add(sample_info) - # pg8000 raises ProgrammingError for NOT NULL violations (error code 23502) + # Driver may surface NOT NULL violations as ProgrammingError (code 23502) with pytest.raises((IntegrityError, ProgrammingError, ValueError)): session.commit() session.rollback() @@ -461,7 +461,7 @@ def test_mtc_requires_chemistry_sample_info(): # No chemistry_sample_info_id - should fail ) session.add(mtc) - # pg8000 raises ProgrammingError for NOT NULL violations (error code 23502) + # Driver may surface NOT NULL violations as ProgrammingError (code 23502) with pytest.raises((IntegrityError, ProgrammingError)): session.commit() session.rollback()