From 01df48f7486683ecbbb45daa56e463579d13a7c3 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 26 Aug 2025 15:51:57 -0600 Subject: [PATCH 01/21] refactor: only get things of type throughh path params --- api/thing.py | 4 ++-- services/thing_helper.py | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/api/thing.py b/api/thing.py index c36d572a1..250ee8143 100644 --- a/api/thing.py +++ b/api/thing.py @@ -69,12 +69,13 @@ prefix="/thing", tags=["thing"], dependencies=[Depends(viewer_function)] ) +# GET ========================================================================== + @router.get("") def get_things( session: session_dependency, thing_id: int = None, - thing_type: List[str] | str = Query(default=[]), within: str = None, query: str = None, sort: str = None, @@ -97,7 +98,6 @@ def get_things( query, session, sort, - thing_type, with_location=True, within=within, ) diff --git a/services/thing_helper.py b/services/thing_helper.py index de5b06c60..fac2c6e85 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -41,7 +41,6 @@ def get_db_things( query, session, sort, - thing_type: str | list[str] = None, with_location: bool = False, within: str = None, ): @@ -57,13 +56,6 @@ def get_db_things( ) sql = sql.join(Location) - if isinstance(thing_type, str): - thing_type = thing_type.lower() - thing_type = [thing_type] - elif isinstance(thing_type, list): - thing_type = [t.lower() for t in thing_type] - - sql = sql.where(Thing.thing_type.in_(thing_type)) if thing_type else sql sql = order_sort_filter(sql, Thing, sort, order, filter_) if within: From f2b53c2b1b5c9f27dbd047216595de950035701c Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 26 Aug 2025 16:44:53 -0600 Subject: [PATCH 02/21] refactor: organize API and implement thing helper functions --- api/thing.py | 187 ++++++++++++++++++++++----------------- services/thing_helper.py | 47 +++++++++- 2 files changed, 150 insertions(+), 84 deletions(-) diff --git a/api/thing.py b/api/thing.py index 250ee8143..9d4b0fce6 100644 --- a/api/thing.py +++ b/api/thing.py @@ -13,13 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from typing import List - -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select from sqlalchemy.orm import Session -from starlette import status +from starlette.status import HTTP_200_OK, HTTP_201_CREATED from api.pagination import CustomPage from core.dependencies import ( @@ -62,7 +60,11 @@ paginated_all_getter, order_sort_filter, ) -from services.thing_helper import add_thing, get_db_things +from services.thing_helper import ( + add_thing, + get_db_things, + get_thing_of_a_thing_type_by_id, +) from services.validation.well import validate_screens router = APIRouter( @@ -72,80 +74,32 @@ # GET ========================================================================== -@router.get("") -def get_things( - session: session_dependency, - thing_id: int = None, - within: str = None, - query: str = None, - sort: str = None, - order: str = None, - filter_: str = Query( - default=None, - alias="filter", - ), -) -> CustomPage[ThingResponse]: - """ - Retrieve all things or filter by type. - """ - if thing_id: - sql = select(Thing).where(Thing.id == thing_id) - return paginate(query=sql, conn=session) - else: - return get_db_things( - filter_, - order, - query, - session, - sort, - with_location=True, - within=within, - ) - - -@router.get( - "/well", summary="Get all wells", dependencies=[Depends(amp_viewer_function)] -) -async def get_wells( +@router.get("/water-well", summary="Get all water wells", status_code=HTTP_200_OK) +async def get_water_wells( session: session_dependency, - # api_id: str = None, - # ose_pod_id: str = None, + request: Request, sort: str = None, order: str = None, filter_: str = Query(alias="filter", default=None), - thing_type: List[str] | str = Query(default="water well"), query: str = None, ) -> CustomPage[WellResponse]: """ Retrieve all wells from the database. """ - - # if api_id: - # sql = select(WellThing).where(WellThing.api_id == api_id) - # elif ose_pod_id: - # sql = select(WellThing).where(WellThing.ose_pod_id == ose_pod_id) - return get_db_things(filter_, order, query, session, sort, thing_type) - # If no parameters, return all wells - # return simple_all_getter(session, Well) - - # result = session.execute(sql) - # return result.scalars().all() + thing_type = request.url.path.split("/")[2].replace("-", " ") + return get_db_things(filter_, order, query, session, sort, thing_type=thing_type) @router.get( - "/spring", summary="Get all springs", dependencies=[Depends(amp_viewer_function)] + "/water-well/{thing_id}", summary="Get water well by ID", status_code=HTTP_200_OK ) -async def get_springs( - session: session_dependency, - sort: str = None, - order: str = None, - filter_: str = Query(alias="filter", default=None), - thing_type: List[str] | str = Query(default="water well"), -) -> CustomPage[SpringResponse]: +async def get_well_by_id( + thing_id: int, session: session_dependency, request: Request +) -> WellResponse: """ - Retrieve all springs from the database. + Retrieve a water well by ID from the database. """ - return get_db_things(filter_, order, None, session, sort, thing_type) + return get_thing_of_a_thing_type_by_id(session, request, thing_id) @router.get( @@ -185,27 +139,30 @@ async def get_well_screen_by_id( return well_screen -@router.get("/{thing_id}/id-link", summary="Get thing links by thing ID") -def get_thing_id_links( - thing_id: int, +@router.get("/spring", summary="Get all springs") +async def get_springs( session: session_dependency, -) -> CustomPage[ThingIdLinkResponse]: + request: Request, + sort: str = None, + order: str = None, + filter_: str = Query(alias="filter", default=None), + query: str = None, +) -> CustomPage[SpringResponse]: """ - Retrieve all links for a specific thing by its ID. + Retrieve all springs from the database. """ - sql = select(ThingIdLink).where(ThingIdLink.thing_id == thing_id) - return paginate(query=sql, conn=session) + thing_type = request.url.path.split("/")[2].replace("-", " ") + return get_db_things(filter_, order, query, session, sort, thing_type=thing_type) -@router.get("/id-link/{link_id}", summary="Get thing links by link ID") -def get_thing_id_links( - link_id: int, - session: session_dependency, -) -> ThingIdLinkResponse: +@router.get("/spring/{thing_id}", summary="Get spring by ID", status_code=HTTP_200_OK) +async def get_spring_by_id( + thing_id: int, session: session_dependency, request: Request +) -> SpringResponse: """ - Retrieve all links for a specific thing by its ID. + Retrieve a spring by ID from the database. """ - return simple_get_by_id(session, ThingIdLink, link_id) + return get_thing_of_a_thing_type_by_id(session, request, thing_id) @router.get( @@ -227,11 +184,75 @@ def get_thing_id_links( return paginate(query=sql, conn=session) +@router.get("/id-link/{link_id}", summary="Get thing links by link ID") +def get_thing_id_links( + link_id: int, + session: session_dependency, +) -> ThingIdLinkResponse: + """ + Retrieve all links for a specific thing by its ID. + """ + return simple_get_by_id(session, ThingIdLink, link_id) + + +@router.get("", summary="Get all things", status_code=HTTP_200_OK) +def get_things( + session: session_dependency, + thing_id: int = None, + within: str = None, + query: str = None, + sort: str = None, + order: str = None, + filter_: str = Query( + default=None, + alias="filter", + ), +) -> CustomPage[ThingResponse]: + """ + Retrieve all things or filter by type. + """ + if thing_id: + sql = select(Thing).where(Thing.id == thing_id) + return paginate(query=sql, conn=session) + else: + return get_db_things( + filter_, + order, + query, + session, + sort, + with_location=True, + within=within, + ) + + +@router.get("/{thing_id}", summary="Get thing by ID", status_code=HTTP_200_OK) +async def get_thing_by_id( + thing_id: int, session: session_dependency, request: Request +) -> ThingResponse: + """ + Retrieve a thing by ID from the database. + """ + return simple_get_by_id(session, Thing, thing_id) + + +@router.get("/{thing_id}/id-link", summary="Get thing links by thing ID") +def get_thing_id_links( + thing_id: int, + session: session_dependency, +) -> CustomPage[ThingIdLinkResponse]: + """ + Retrieve all links for a specific thing by its ID. + """ + sql = select(ThingIdLink).where(ThingIdLink.thing_id == thing_id) + return paginate(query=sql, conn=session) + + # ===== POST ============= @router.post( - "/id-link", status_code=status.HTTP_201_CREATED, summary="Create a new thing link" + "/id-link", status_code=HTTP_201_CREATED, summary="Create a new thing link" ) def create_thing_id_link( link_data: CreateThingIdLink, @@ -247,7 +268,7 @@ def create_thing_id_link( @router.post( "/well", summary="Create a well", - status_code=status.HTTP_201_CREATED, + status_code=HTTP_201_CREATED, ) def create_well( thing_data: CreateWell, @@ -265,7 +286,7 @@ def create_well( @router.post( "/spring", summary="Create a new spring", - status_code=status.HTTP_201_CREATED, + status_code=HTTP_201_CREATED, ) def create_spring( thing_data: CreateSpring, @@ -281,7 +302,7 @@ def create_spring( @router.post( "", summary="Create a new thing", - status_code=status.HTTP_201_CREATED, + status_code=HTTP_201_CREATED, ) def create_thing( thing_data: CreateThing, @@ -297,7 +318,7 @@ def create_thing( @router.post( "/well-screen", summary="Create a new well screen", - status_code=status.HTTP_201_CREATED, + status_code=HTTP_201_CREATED, ) def create_wellscreen( session: session_dependency, diff --git a/services/thing_helper.py b/services/thing_helper.py index fac2c6e85..1f052d614 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -13,17 +13,20 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +from fastapi import Request from fastapi_pagination.ext.sqlalchemy import paginate from pydantic import BaseModel from sqlalchemy import select, func, and_ from sqlalchemy.orm import Session +from starlette.status import HTTP_404_NOT_FOUND from db import LocationThingAssociation, Thing, Base, Location from schemas.location import LocationResponse from db.group import Group, GroupThingAssociation from services.audit_helper import audit_add +from services.exceptions_helper import PydanticStyleException from services.geospatial_helper import make_within_wkt -from services.query_helper import make_query, order_sort_filter +from services.query_helper import make_query, order_sort_filter, simple_get_by_id from shapely import wkb from shapely.geometry import mapping @@ -41,6 +44,7 @@ def get_db_things( query, session, sort, + thing_type: str = None, with_location: bool = False, within: str = None, ): @@ -56,6 +60,9 @@ def get_db_things( ) sql = sql.join(Location) + if thing_type: + sql = sql.where(Thing.thing_type == thing_type) + sql = order_sort_filter(sql, Thing, sort, order, filter_) if within: @@ -100,6 +107,44 @@ def transformer(records): return paginate(query=sql, conn=session, transformer=transformer) +def get_thing_type_from_request(request: Request) -> str: + path = request.url.path + path_components = path.split("/") + if len(path_components) == 2: + # no thing type specified in path + thing_type_in_path = path_components[1] + if len(path_components) >= 3: + # thing type specified in path + thing_type_in_path = path_components[2] + + thing_type = thing_type_in_path.replace("-", " ") + return thing_type + + +def verify_thing_type_correspondence(thing: Thing, request: Request): + thing_type = get_thing_type_from_request(request) + if thing.thing_type != thing_type: + raise PydanticStyleException( + status_code=HTTP_404_NOT_FOUND, + detail=[ + { + "loc": ["path", "thing_id"], + "type": "value_error", + "input": {"thing_id": thing.id}, + "msg": f"Thing with ID {thing.id} is not a {thing_type} Thing. It is a {thing.thing_type} Thing.", + } + ], + ) + + +def get_thing_of_a_thing_type_by_id(session: Session, request: Request, thing_id: int): + thing = simple_get_by_id(session, Thing, thing_id) + + verify_thing_type_correspondence(thing, request) + + return thing + + # REFACTOR TODO: use enums (or enum-like object) for thing_type def add_thing( session: Session, data: BaseModel | dict, thing_type: str = None, user: dict = None From 151bf37b1cf3f23185f855fa6d7cb4f9c085c117 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 26 Aug 2025 16:45:20 -0600 Subject: [PATCH 03/21] test: implement GET tests for water well things --- tests/conftest.py | 37 ++++++++++++++--------- tests/test_thing.py | 72 +++++++++++++++++++++++++++++---------------- 2 files changed, 69 insertions(+), 40 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c80b5f22d..565714a71 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,13 +34,18 @@ def second_location(): @pytest.fixture(scope="session") -def thing(location): +def water_well_thing(location): with session_ctx() as session: wt = add_thing( session, { "location_id": location.id, "name": "Test Well", + "release_status": "draft", + "well_type": "Production", + "well_depth": 10, + "hole_depth": 10, + "well_construction_notes": "Test well construction notes", }, "water well", ) @@ -89,11 +94,11 @@ def second_sensor(): @pytest.fixture(scope="session") -def sample(thing, sensor): +def sample(water_well_thing, sensor): with session_ctx() as session: sample = Sample( sample_date="2025-01-01T00:00:00Z", - thing_id=thing.id, + thing_id=water_well_thing.id, sample_type="groundwater", sampler_name="Test Sampler", release_status="draft", @@ -114,10 +119,10 @@ def sample(thing, sensor): @pytest.fixture(scope="function") -def second_sample(thing, sensor): +def second_sample(water_well_thing, sensor): with session_ctx() as session: sample = Sample( - thing_id=thing.id, + thing_id=water_well_thing.id, sample_type="groundwater", field_sample_id="FS-9999999", sample_date="2025-01-01T00:00:00Z", @@ -140,7 +145,7 @@ def second_sample(thing, sensor): @pytest.fixture(scope="session") -def contact(thing): +def contact(water_well_thing): with session_ctx() as session: contact = Contact( name="Test Contact", @@ -150,7 +155,9 @@ def contact(thing): session.commit() session.refresh(contact) - association = ThingContactAssociation(thing_id=thing.id, contact_id=contact.id) + association = ThingContactAssociation( + thing_id=water_well_thing.id, contact_id=contact.id + ) session.add(association) session.commit() session.refresh(association) @@ -304,10 +311,10 @@ def asset(): @pytest.fixture(scope="function") -def asset_with_associated_thing(thing): +def asset_with_associated_thing(water_well_thing): with session_ctx() as session: asset = Asset( - name="Test Asset with thing", + name="Test Asset with water_well_thing", label="test label", mime_type="application/pdf", size=12345, @@ -319,7 +326,9 @@ def asset_with_associated_thing(thing): session.commit() session.refresh(asset) - association = AssetThingAssociation(asset_id=asset.id, thing_id=thing.id) + association = AssetThingAssociation( + asset_id=asset.id, thing_id=water_well_thing.id + ) session.add(association) session.commit() session.refresh(association) @@ -429,7 +438,7 @@ def observation_to_delete(sample, sensor): @pytest.fixture(scope="session") -def group(thing): +def group(water_well_thing): with session_ctx() as session: group = Group( name="Test Group", @@ -442,7 +451,7 @@ def group(thing): session.refresh(group) group_thing_association = GroupThingAssociation( - group_id=group.id, thing_id=thing.id + group_id=group.id, thing_id=water_well_thing.id ) session.add(group_thing_association) session.commit() @@ -454,7 +463,7 @@ def group(thing): @pytest.fixture(scope="function") -def second_group(thing): +def second_group(water_well_thing): with session_ctx() as session: group = Group( name="Second Test Group", @@ -467,7 +476,7 @@ def second_group(thing): session.refresh(group) group_thing_association = GroupThingAssociation( - group_id=group.id, thing_id=thing.id + group_id=group.id, thing_id=water_well_thing.id ) session.add(group_thing_association) session.commit() diff --git a/tests/test_thing.py b/tests/test_thing.py index 8c0cfd0b7..cc27e2497 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -48,18 +48,7 @@ def override_authentication_dependency_fixture(): app.dependency_overrides = {} -def test_add_group(): - response = client.post( - "/group", - json={ - "name": "collabnet", - "description": "CollabNet Group for testing", - }, - ) - assert response.status_code == 201 - data = response.json() - assert "id" in data - assert data["name"] == "collabnet" +# POST tests =================================================================== def test_add_well(location): @@ -172,23 +161,54 @@ def test_add_thing_link(): assert data["alternate_id"] == "4321-1234" -# ===================== get ========================== -# def test_get_thing_by_id(): -# # response = client.get("/thing?thing_id=1") -# response = client.get("/thing/base/1") -# assert response.status_code == 200 -# data = response.json() -# # assert "items" in data -# # items = data["items"] -# # assert len(items) == 1 -# assert data["id"] == 1 -# assert data["name"] == "Test Thing" +# GET tests ==================================================================== -def test_get_wells(): - response = client.get("/thing?thing_type=water well") +def test_get_water_wells(water_well_thing): + response = client.get("/thing/water-well") assert response.status_code == 200 - assert len(response.json()) > 0 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["id"] == water_well_thing.id + assert data["items"][0][ + "created_at" + ] == water_well_thing.created_at.isoformat().replace("+00:00", "Z") + assert data["items"][0]["name"] == water_well_thing.name + assert data["items"][0]["thing_type"] == water_well_thing.thing_type + assert data["items"][0]["release_status"] == water_well_thing.release_status + assert data["items"][0]["well_type"] == water_well_thing.well_type + assert data["items"][0]["well_depth"] == water_well_thing.well_depth + assert data["items"][0]["hole_depth"] == water_well_thing.hole_depth + assert ( + data["items"][0]["well_construction_notes"] + == water_well_thing.well_construction_notes + ) + + +def test_get_water_well_by_id(water_well_thing): + response = client.get(f"/thing/water-well/{water_well_thing.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == water_well_thing.id + assert data["created_at"] == water_well_thing.created_at.isoformat().replace( + "+00:00", "Z" + ) + assert data["name"] == water_well_thing.name + assert data["thing_type"] == water_well_thing.thing_type + assert data["release_status"] == water_well_thing.release_status + assert data["well_type"] == water_well_thing.well_type + assert data["well_depth"] == water_well_thing.well_depth + assert data["hole_depth"] == water_well_thing.hole_depth + assert data["well_construction_notes"] == water_well_thing.well_construction_notes + + +def test_get_water_well_by_id_404_not_found(water_well_thing): + bad_id = 99999 + response = client.get(f"/thing/water-well/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert "detail" in data + assert data["detail"] == f"Thing with ID {bad_id} not found." def test_get_springs(): From 7fd1fe1fc96f0a1dbd5f279d78ed0828cad56e76 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 26 Aug 2025 16:54:54 -0600 Subject: [PATCH 04/21] test: implement get spring thing tests --- schemas/thing.py | 14 ---------- tests/conftest.py | 19 ++++++++++++++ tests/test_thing.py | 64 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 80 insertions(+), 17 deletions(-) diff --git a/schemas/thing.py b/schemas/thing.py index 69fc0a7b7..e4298d74d 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -79,19 +79,6 @@ class CreateWellScreen(BaseModel): screen_type: str | None = None screen_description: str | None = None - @model_validator(mode="after") - def validate_screen_type(self): - if self.screen_type is not None: - valid_screen_types = [ - "PVC", - ] # todo: get valid screen types from database - if self.screen_type not in valid_screen_types: - raise ValueError( - f"Invalid screen_type: {self.screen_type}. " - f"Valid options are: {', '.join(valid_screen_types)}." - ) - return self - # validate that screen depth bottom is greater than top @model_validator(mode="after") def check_depths(self): @@ -106,7 +93,6 @@ def check_depths(self): class BaseThingResponse(ORMBaseModel): name: str thing_type: str - id: int release_status: str diff --git a/tests/conftest.py b/tests/conftest.py index 565714a71..efa16a848 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,6 +55,25 @@ def water_well_thing(location): session.close() +@pytest.fixture(scope="session") +def spring_thing(location): + with session_ctx() as session: + st = add_thing( + session, + { + "location_id": location.id, + "name": "Test Spring", + "release_status": "draft", + "spring_type": "Artesian", + }, + "spring", + ) + + yield st + + session.close() + + @pytest.fixture(scope="session") def sensor(): with session_ctx() as session: diff --git a/tests/test_thing.py b/tests/test_thing.py index cc27e2497..0bd912c7a 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -211,10 +211,68 @@ def test_get_water_well_by_id_404_not_found(water_well_thing): assert data["detail"] == f"Thing with ID {bad_id} not found." -def test_get_springs(): - response = client.get("/thing?thing_type=spring") +def test_get_water_well_by_id_404_wrong_type(spring_thing): + response = client.get(f"/thing/water-well/{spring_thing.id}") + assert response.status_code == 404 + data = response.json() + assert ( + data["detail"][0]["msg"] + == f"Thing with ID {spring_thing.id} is not a water well Thing. It is a spring Thing." + ) + assert data["detail"][0]["loc"] == ["path", "thing_id"] + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"thing_id": spring_thing.id} + + +def test_get_springs(spring_thing): + response = client.get("/thing/spring") assert response.status_code == 200 - assert len(response.json()) > 0 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["id"] == spring_thing.id + assert data["items"][0][ + "created_at" + ] == spring_thing.created_at.isoformat().replace("+00:00", "Z") + assert data["items"][0]["name"] == spring_thing.name + assert data["items"][0]["thing_type"] == spring_thing.thing_type + assert data["items"][0]["release_status"] == spring_thing.release_status + assert data["items"][0]["spring_type"] == spring_thing.spring_type + + +def test_get_spring_by_id(spring_thing): + response = client.get(f"/thing/spring/{spring_thing.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == spring_thing.id + assert data["created_at"] == spring_thing.created_at.isoformat().replace( + "+00:00", "Z" + ) + assert data["name"] == spring_thing.name + assert data["thing_type"] == spring_thing.thing_type + assert data["release_status"] == spring_thing.release_status + assert data["spring_type"] == spring_thing.spring_type + + +def test_get_spring_by_id_404_not_found(spring_thing): + bad_id = 99999 + response = client.get(f"/thing/spring/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert "detail" in data + assert data["detail"] == f"Thing with ID {bad_id} not found." + + +def test_get_spring_by_id_404_wrong_type(water_well_thing): + response = client.get(f"/thing/spring/{water_well_thing.id}") + assert response.status_code == 404 + data = response.json() + assert ( + data["detail"][0]["msg"] + == f"Thing with ID {water_well_thing.id} is not a spring Thing. It is a water well Thing." + ) + assert data["detail"][0]["loc"] == ["path", "thing_id"] + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"thing_id": water_well_thing.id} # def test_get_well_by_id(): From 4120d35aafb0b788f66a307bae568e9f79c0b6a9 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 27 Aug 2025 11:07:18 -0600 Subject: [PATCH 05/21] feat: implement GET well screens --- api/thing.py | 18 +++++++++-- tests/conftest.py | 15 ++++++++++ tests/test_thing.py | 73 +++++++++++++++++++++++++++++++++++++++------ 3 files changed, 95 insertions(+), 11 deletions(-) diff --git a/api/thing.py b/api/thing.py index 9d4b0fce6..79af468f0 100644 --- a/api/thing.py +++ b/api/thing.py @@ -102,6 +102,22 @@ async def get_well_by_id( return get_thing_of_a_thing_type_by_id(session, request, thing_id) +@router.get( + "/water-well/{thing_id}/well-screen", + summary="Get well screens by water well ID", + status_code=HTTP_200_OK, +) +async def get_well_screens_by_well_id( + thing_id: int, session: session_dependency, request: Request +) -> CustomPage[WellScreenResponse]: + """ + Retrieve all well screens for a specific water well by its ID. + """ + thing = get_thing_of_a_thing_type_by_id(session, request, thing_id) + sql = select(WellScreen).where(WellScreen.thing_id == thing.id) + return paginate(query=sql, conn=session) + + @router.get( "/well-screen", summary="Get well screens", @@ -134,8 +150,6 @@ async def get_well_screen_by_id( Retrieve a well screen by ID from the database. """ well_screen = simple_get_by_id(session, WellScreen, wellscreen_id) - if not well_screen: - return {"message": "Well screen not found"} return well_screen diff --git a/tests/conftest.py b/tests/conftest.py index efa16a848..b71fbf0ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,6 +55,21 @@ def water_well_thing(location): session.close() +@pytest.fixture(scope="session") +def well_screen(water_well_thing): + with session_ctx() as session: + screen = WellScreen( + thing_id=water_well_thing.id, + screen_depth_top=10.0, + screen_depth_bottom=20.0, + screen_type="PVC", + screen_description="Test well screen description", + ) + session.add(screen) + session.commit() + yield screen + + @pytest.fixture(scope="session") def spring_thing(location): with session_ctx() as session: diff --git a/tests/test_thing.py b/tests/test_thing.py index 0bd912c7a..dedc047f7 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -202,6 +202,41 @@ def test_get_water_well_by_id(water_well_thing): assert data["well_construction_notes"] == water_well_thing.well_construction_notes +def test_get_water_well_well_screens(water_well_thing, well_screen): + response = client.get(f"/thing/water-well/{water_well_thing.id}/well-screen") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["id"] == well_screen.id + assert data["items"][0]["thing_id"] == well_screen.thing_id + assert data["items"][0]["screen_depth_top"] == well_screen.screen_depth_top + assert data["items"][0]["screen_depth_bottom"] == well_screen.screen_depth_bottom + assert data["items"][0]["screen_type"] == well_screen.screen_type + assert data["items"][0]["screen_description"] == well_screen.screen_description + + +def test_get_water_well_well_screens_404_not_found(water_well_thing, well_screen): + bad_id = 99999 + response = client.get(f"/thing/water-well/{bad_id}/well-screen") + assert response.status_code == 404 + data = response.json() + assert "detail" in data + assert data["detail"] == f"Thing with ID {bad_id} not found." + + +def test_get_water_well_well_screens_404_wrong_type(spring_thing): + response = client.get(f"/thing/water-well/{spring_thing.id}/well-screen") + assert response.status_code == 404 + data = response.json() + assert ( + data["detail"][0]["msg"] + == f"Thing with ID {spring_thing.id} is not a water well Thing. It is a spring Thing." + ) + assert data["detail"][0]["loc"] == ["path", "thing_id"] + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"thing_id": spring_thing.id} + + def test_get_water_well_by_id_404_not_found(water_well_thing): bad_id = 99999 response = client.get(f"/thing/water-well/{bad_id}") @@ -275,18 +310,38 @@ def test_get_spring_by_id_404_wrong_type(water_well_thing): assert data["detail"][0]["input"] == {"thing_id": water_well_thing.id} -# def test_get_well_by_id(): -# response = client.get("/thing/well/1") -# assert response.status_code == 200 -# data = response.json() -# assert data["id"] == 1 +def test_get_well_screens(well_screen): + response = client.get("/thing/well-screen") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["id"] == well_screen.id + assert data["items"][0]["thing_id"] == well_screen.thing_id + assert data["items"][0]["screen_depth_top"] == well_screen.screen_depth_top + assert data["items"][0]["screen_depth_bottom"] == well_screen.screen_depth_bottom + assert data["items"][0]["screen_type"] == well_screen.screen_type + assert data["items"][0]["screen_description"] == well_screen.screen_description -def test_get_well_screens(): - # TODO: improve test indepedence - response = client.get("/thing/well-screen") +def test_get_well_screen_by_id(well_screen): + response = client.get(f"/thing/well-screen/{well_screen.id}") assert response.status_code == 200 - assert len(response.json()) > 0 + data = response.json() + assert data["id"] == well_screen.id + assert data["thing_id"] == well_screen.thing_id + assert data["screen_depth_top"] == well_screen.screen_depth_top + assert data["screen_depth_bottom"] == well_screen.screen_depth_bottom + assert data["screen_type"] == well_screen.screen_type + assert data["screen_description"] == well_screen.screen_description + + +def test_get_well_screen_by_id_404_not_found(well_screen): + bad_id = 99999 + response = client.get(f"/thing/well-screen/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert "detail" in data + assert data["detail"] == f"WellScreen with ID {bad_id} not found." def test_get_thing_links(): From 03a7f528edbc9ccbf9173278ca07c7fc04a234ac Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 27 Aug 2025 11:27:10 -0600 Subject: [PATCH 06/21] feat: implement GET thing id link --- api/thing.py | 3 +- tests/conftest.py | 14 ++++ tests/test_thing.py | 158 +++++++++++++++++++++----------------------- 3 files changed, 90 insertions(+), 85 deletions(-) diff --git a/api/thing.py b/api/thing.py index 79af468f0..503793c58 100644 --- a/api/thing.py +++ b/api/thing.py @@ -258,7 +258,8 @@ def get_thing_id_links( """ Retrieve all links for a specific thing by its ID. """ - sql = select(ThingIdLink).where(ThingIdLink.thing_id == thing_id) + thing = simple_get_by_id(session, Thing, thing_id) + sql = select(ThingIdLink).where(ThingIdLink.thing_id == thing.id) return paginate(query=sql, conn=session) diff --git a/tests/conftest.py b/tests/conftest.py index b71fbf0ef..2c7d90f18 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -70,6 +70,20 @@ def well_screen(water_well_thing): yield screen +@pytest.fixture(scope="session") +def thing_id_link(water_well_thing): + with session_ctx() as session: + id_link = ThingIdLink( + thing_id=water_well_thing.id, + relation="same_as", + alternate_id="4321-1234", + alternate_organization="USGS", + ) + session.add(id_link) + session.commit() + yield id_link + + @pytest.fixture(scope="session") def spring_thing(location): with session_ctx() as session: diff --git a/tests/test_thing.py b/tests/test_thing.py index dedc047f7..6a00d1342 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -202,41 +202,6 @@ def test_get_water_well_by_id(water_well_thing): assert data["well_construction_notes"] == water_well_thing.well_construction_notes -def test_get_water_well_well_screens(water_well_thing, well_screen): - response = client.get(f"/thing/water-well/{water_well_thing.id}/well-screen") - assert response.status_code == 200 - data = response.json() - assert data["total"] == 1 - assert data["items"][0]["id"] == well_screen.id - assert data["items"][0]["thing_id"] == well_screen.thing_id - assert data["items"][0]["screen_depth_top"] == well_screen.screen_depth_top - assert data["items"][0]["screen_depth_bottom"] == well_screen.screen_depth_bottom - assert data["items"][0]["screen_type"] == well_screen.screen_type - assert data["items"][0]["screen_description"] == well_screen.screen_description - - -def test_get_water_well_well_screens_404_not_found(water_well_thing, well_screen): - bad_id = 99999 - response = client.get(f"/thing/water-well/{bad_id}/well-screen") - assert response.status_code == 404 - data = response.json() - assert "detail" in data - assert data["detail"] == f"Thing with ID {bad_id} not found." - - -def test_get_water_well_well_screens_404_wrong_type(spring_thing): - response = client.get(f"/thing/water-well/{spring_thing.id}/well-screen") - assert response.status_code == 404 - data = response.json() - assert ( - data["detail"][0]["msg"] - == f"Thing with ID {spring_thing.id} is not a water well Thing. It is a spring Thing." - ) - assert data["detail"][0]["loc"] == ["path", "thing_id"] - assert data["detail"][0]["type"] == "value_error" - assert data["detail"][0]["input"] == {"thing_id": spring_thing.id} - - def test_get_water_well_by_id_404_not_found(water_well_thing): bad_id = 99999 response = client.get(f"/thing/water-well/{bad_id}") @@ -344,72 +309,97 @@ def test_get_well_screen_by_id_404_not_found(well_screen): assert data["detail"] == f"WellScreen with ID {bad_id} not found." -def test_get_thing_links(): - # TODO: improve test indepedence - response = client.get("/thing/id-link") +def test_get_well_screens_by_water_well(water_well_thing, well_screen): + response = client.get(f"/thing/water-well/{water_well_thing.id}/well-screen") assert response.status_code == 200 - assert len(response.json()) > 0 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["id"] == well_screen.id + assert data["items"][0]["thing_id"] == well_screen.thing_id + assert data["items"][0]["screen_depth_top"] == well_screen.screen_depth_top + assert data["items"][0]["screen_depth_bottom"] == well_screen.screen_depth_bottom + assert data["items"][0]["screen_type"] == well_screen.screen_type + assert data["items"][0]["screen_description"] == well_screen.screen_description -def test_get_thing_links_by_id(): - # TODO: improve test indepedence - response = client.get("/thing/id-link/1") - assert response.status_code == 200 +def test_get_well_screens_by_water_well_id_404_not_found(water_well_thing, well_screen): + bad_id = 99999 + response = client.get(f"/thing/water-well/{bad_id}/well-screen") + assert response.status_code == 404 data = response.json() - assert data["id"] == 1 - assert data["thing_id"] == 1 - assert data["relation"] == "same_as" - assert data["alternate_id"] == "4321-1234" - assert data["alternate_organization"] == "USGS" + assert "detail" in data + assert data["detail"] == f"Thing with ID {bad_id} not found." -def test_get_thing_links_by_thing_id(): - # TODO: improve test indepedence - response = client.get("/thing/1/id-link") - assert response.status_code == 200 +def test_get_well_screens_by_water_well_id_404_wrong_type(spring_thing): + response = client.get(f"/thing/water-well/{spring_thing.id}/well-screen") + assert response.status_code == 404 data = response.json() - assert isinstance(data, dict) - assert "items" in data - data = data["items"] - assert isinstance(data, list) - assert len(data) == 1 - item = data[0] - assert item["id"] == 1 - assert item["thing_id"] == 1 - assert item["relation"] == "same_as" - assert item["alternate_id"] == "4321-1234" - assert item["alternate_organization"] == "USGS" - - -def test_item_get_well_filter(): - response = client.get("/thing", params={"query": "well_type eq 'Monitoring'"}) + assert ( + data["detail"][0]["msg"] + == f"Thing with ID {spring_thing.id} is not a water well Thing. It is a spring Thing." + ) + assert data["detail"][0]["loc"] == ["path", "thing_id"] + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"thing_id": spring_thing.id} + + +def test_get_thing_id_links(thing_id_link): + response = client.get("/thing/id-link") assert response.status_code == 200 data = response.json() - assert "items" in data - assert len(data["items"]) == 1 - # assert "api_id" in data["items"][0] - # assert data["items"][0]["api_id"] == "1001-0002" + assert data["total"] == 1 + assert data["items"][0]["id"] == thing_id_link.id + assert data["items"][0]["thing_id"] == thing_id_link.thing_id + assert data["items"][0]["relation"] == thing_id_link.relation + assert data["items"][0]["alternate_id"] == thing_id_link.alternate_id + assert ( + data["items"][0]["alternate_organization"] + == thing_id_link.alternate_organization + ) -# @pytest.mark.skip -def test_item_get_well_filter_nonexistent(): - # response = client.get("/thing/well", params={"well_type": "9999-9999"}) - response = client.get("/thing", params={"query": "well_type eq 'foo'"}) +def test_get_thing_id_link_by_id(thing_id_link): + response = client.get(f"/thing/id-link/{thing_id_link.id}") assert response.status_code == 200 data = response.json() - assert "items" in data - assert len(data["items"]) == 0 + assert data["id"] == thing_id_link.id + assert data["thing_id"] == thing_id_link.thing_id + assert data["relation"] == thing_id_link.relation + assert data["alternate_id"] == thing_id_link.alternate_id + assert data["alternate_organization"] == thing_id_link.alternate_organization + + +def test_get_thing_id_link_by_id_404_not_found(thing_id_link): + bad_id = 99999 + response = client.get(f"/thing/id-link/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert "detail" in data + assert data["detail"] == f"ThingIdLink with ID {bad_id} not found." -# @pytest.mark.skip -def test_item_get_well_screens(): - response = client.get("/thing/well-screen/1") +def test_get_thing_links_by_thing_id(water_well_thing, thing_id_link): + response = client.get(f"/thing/{water_well_thing.id}/id-link") assert response.status_code == 200 data = response.json() - assert data["id"] == 1 - assert data["thing_id"] == 1 - assert data["screen_depth_top"] == 10.0 - assert data["screen_depth_bottom"] == 20.0 + assert data["total"] == 1 + assert data["items"][0]["id"] == thing_id_link.id + assert data["items"][0]["thing_id"] == thing_id_link.thing_id + assert data["items"][0]["relation"] == thing_id_link.relation + assert data["items"][0]["alternate_id"] == thing_id_link.alternate_id + assert ( + data["items"][0]["alternate_organization"] + == thing_id_link.alternate_organization + ) + + +def test_get_thing_links_by_thing_id_404_not_found(water_well_thing, thing_id_link): + bad_id = 99999 + response = client.get(f"/thing/{bad_id}/id-link") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Thing with ID {bad_id} not found." # weaver tests From 03f8343d1221bc2a3dfdd8366999bf287a8c7bd6 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 27 Aug 2025 12:24:27 -0600 Subject: [PATCH 07/21] refactor: remove location info from thing responses --- schemas/thing.py | 3 +- services/thing_helper.py | 44 +--- tests/test_thing.py | 456 ++++++++++++++++++++++----------------- 3 files changed, 265 insertions(+), 238 deletions(-) diff --git a/schemas/thing.py b/schemas/thing.py index e4298d74d..094044807 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -121,8 +121,7 @@ class SpringResponse(BaseThingResponse): class ThingResponse(WellResponse, SpringResponse): - location: LocationResponse | None = None # Optional location details - geometry: dict | None = None + pass class ThingIdLinkResponse(ORMBaseModel): diff --git a/services/thing_helper.py b/services/thing_helper.py index 1f052d614..18d748119 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -16,12 +16,11 @@ from fastapi import Request from fastapi_pagination.ext.sqlalchemy import paginate from pydantic import BaseModel -from sqlalchemy import select, func, and_ +from sqlalchemy import select from sqlalchemy.orm import Session from starlette.status import HTTP_404_NOT_FOUND from db import LocationThingAssociation, Thing, Base, Location -from schemas.location import LocationResponse from db.group import Group, GroupThingAssociation from services.audit_helper import audit_add from services.exceptions_helper import PydanticStyleException @@ -47,7 +46,7 @@ def get_db_things( thing_type: str = None, with_location: bool = False, within: str = None, -): +) -> list: if query: sql = select(Thing).where(make_query(Thing, query)) @@ -63,48 +62,13 @@ def get_db_things( if thing_type: sql = sql.where(Thing.thing_type == thing_type) - sql = order_sort_filter(sql, Thing, sort, order, filter_) if within: sql = make_within_wkt(sql, within) - def transformer(records): - thing_ids = sorted([record.id for record in records]) - subq = ( - select( - LocationThingAssociation.thing_id, - func.max(LocationThingAssociation.effective_start).label("max_start"), - ) - .where(LocationThingAssociation.thing_id.in_(thing_ids)) - .group_by(LocationThingAssociation.thing_id) - .subquery() - ) - stmt = ( - select(Location) - .join( - LocationThingAssociation, - Location.id == LocationThingAssociation.location_id, - ) - .join(Thing) - .join( - subq, - and_( - LocationThingAssociation.thing_id == subq.c.thing_id, - LocationThingAssociation.effective_start == subq.c.max_start, - ), - ) - .order_by(Thing.id.asc()) - ) - locations = session.scalars(stmt).all() - - for r, l in zip(records, locations): - - r.location = LocationResponse.model_validate(l) - r.geometry = wkb_to_geojson(l.point) if l.point else None - - return records + sql = order_sort_filter(sql, Thing, sort, order, filter_) - return paginate(query=sql, conn=session, transformer=transformer) + return paginate(query=sql, conn=session) def get_thing_type_from_request(request: Request) -> str: diff --git a/tests/test_thing.py b/tests/test_thing.py index 6a00d1342..f5252e10b 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -51,114 +51,114 @@ def override_authentication_dependency_fixture(): # POST tests =================================================================== -def test_add_well(location): - # response = client.post( - # "/lexicon/add", json={"term": "Monitoring", "definition": "Monitoring Well"} - # ) - # assert response.status_code == 200 - # response = client.post( - # "/lexicon/add", json={"term": "Production", "definition": "Production Well"} - # ) - # assert response.status_code == 200 - - response = client.post( - "/thing", - json={ - "thing_type": "water well", - "location_id": location.id, - "name": "Test Well", - "api_id": "1001-0001", - "ose_pod_id": "RA-0001", - "well_type": "Monitoring", - "well_depth": 100.0, - "well_construction_notes": "this is a test of notes", - }, - ) - assert response.status_code == 201 - data = response.json() - assert "id" in data - assert data["name"] == "Test Well" - assert data["well_type"] == "Monitoring" - - response = client.post( - "/thing", - json={ - "thing_type": "water well", - "location_id": location.id, - "name": "Test Well 2", - "api_id": "1001-0002", - "ose_pod_id": "RA-0002", - "well_type": "Production", - "well_depth": 1200.0, - "group": "collabnet", - }, - ) - assert response.status_code == 201 - data = response.json() - assert "id" in data - - -def test_add_spring(): - response = client.post( - "/thing", - json={ - "location_id": 1, - "name": "Test Spring", - "thing_type": "spring", - "spring_type": "Ephemeral", - }, - ) - assert response.status_code == 201 - data = response.json() - assert "id" in data - - assert "name" in data - assert data["name"] == "Test Spring" - - assert "thing_type" in data - assert data["thing_type"] == "spring" - - assert "spring_type" in data - assert data["spring_type"] == "Ephemeral" - - -def test_add_well_screen(): - # response = client.post( - # "/lexicon/add", - # json={"term": "PVC", "definition": "PVC Well Screen"}, - # ) - # assert response.status_code == 200 - response = client.post( - "/thing/well-screen", - json={ - "thing_id": 1, - "screen_depth_top": 10.0, - "screen_depth_bottom": 20.0, - "screen_type": "PVC", - }, - ) - - assert response.status_code == 201 - data = response.json() - assert "id" in data - assert data["thing_id"] == 1 - - -def test_add_thing_link(): - response = client.post( - "/thing/id-link", - json={ - "thing_id": 1, - "relation": "same_as", - "alternate_id": "4321-1234", - "alternate_organization": "USGS", - }, - ) - assert response.status_code == 201 - data = response.json() - assert "id" in data - assert data["thing_id"] == 1 - assert data["alternate_id"] == "4321-1234" +# def test_add_well(location): +# response = client.post( +# "/lexicon/add", json={"term": "Monitoring", "definition": "Monitoring Well"} +# ) +# assert response.status_code == 200 +# response = client.post( +# "/lexicon/add", json={"term": "Production", "definition": "Production Well"} +# ) +# assert response.status_code == 200 + +# response = client.post( +# "/thing", +# json={ +# "thing_type": "water well", +# "location_id": location.id, +# "name": "Test Well", +# "api_id": "1001-0001", +# "ose_pod_id": "RA-0001", +# "well_type": "Monitoring", +# "well_depth": 100.0, +# "well_construction_notes": "this is a test of notes", +# }, +# ) +# assert response.status_code == 201 +# data = response.json() +# assert "id" in data +# assert data["name"] == "Test Well" +# assert data["well_type"] == "Monitoring" + +# response = client.post( +# "/thing", +# json={ +# "thing_type": "water well", +# "location_id": location.id, +# "name": "Test Well 2", +# "api_id": "1001-0002", +# "ose_pod_id": "RA-0002", +# "well_type": "Production", +# "well_depth": 1200.0, +# "group": "collabnet", +# }, +# ) +# assert response.status_code == 201 +# data = response.json() +# assert "id" in data + + +# def test_add_spring(): +# response = client.post( +# "/thing", +# json={ +# "location_id": 1, +# "name": "Test Spring", +# "thing_type": "spring", +# "spring_type": "Ephemeral", +# }, +# ) +# assert response.status_code == 201 +# data = response.json() +# assert "id" in data + +# assert "name" in data +# assert data["name"] == "Test Spring" + +# assert "thing_type" in data +# assert data["thing_type"] == "spring" + +# assert "spring_type" in data +# assert data["spring_type"] == "Ephemeral" + + +# def test_add_well_screen(): +# # response = client.post( +# # "/lexicon/add", +# # json={"term": "PVC", "definition": "PVC Well Screen"}, +# # ) +# # assert response.status_code == 200 +# response = client.post( +# "/thing/well-screen", +# json={ +# "thing_id": 1, +# "screen_depth_top": 10.0, +# "screen_depth_bottom": 20.0, +# "screen_type": "PVC", +# }, +# ) + +# assert response.status_code == 201 +# data = response.json() +# assert "id" in data +# assert data["thing_id"] == 1 + + +# def test_add_thing_link(): +# response = client.post( +# "/thing/id-link", +# json={ +# "thing_id": 1, +# "relation": "same_as", +# "alternate_id": "4321-1234", +# "alternate_organization": "USGS", +# }, +# ) +# assert response.status_code == 201 +# data = response.json() +# assert "id" in data +# assert data["thing_id"] == 1 +# assert data["alternate_id"] == "4321-1234" # GET tests ==================================================================== @@ -402,109 +402,173 @@ def test_get_thing_links_by_thing_id_404_not_found(water_well_thing, thing_id_li assert data["detail"] == f"Thing with ID {bad_id} not found." -# weaver tests -def test_weaver_get_wells_geojson(): - response = client.get("/geospatial", params={"type": "well"}) - assert response.status_code == 200 - data = response.json() - assert "type" in data - assert data["type"] == "FeatureCollection" - assert len(data["features"]) > 0 - assert "id" in data["features"][0]["properties"] - - -def test_weaver_get_all_collabnet_wells(): - response = client.get( - "/geospatial", params={"type": "well", "group": "collabnet"} - ) # TODO: QUESTION: use type filter and a group filter instead of /collabnet endpoint? +def test_get_things(water_well_thing, spring_thing): + response = client.get("/thing") assert response.status_code == 200 data = response.json() - assert "features" in data - assert len(data["features"]) > 0 - for feature in data["features"]: - assert "geometry" in feature - assert isinstance(feature["geometry"], dict) - assert "properties" in feature - assert isinstance(feature["properties"], dict) - assert "coordinates" in feature["geometry"] - assert "id" in feature or "name" in feature["properties"] - assert "group" in feature["properties"] + assert data["total"] == 2 + assert data["items"][0]["id"] == water_well_thing.id + assert data["items"][0][ + "created_at" + ] == water_well_thing.created_at.isoformat().replace("+00:00", "Z") + assert data["items"][0]["name"] == water_well_thing.name + assert data["items"][0]["thing_type"] == water_well_thing.thing_type + assert data["items"][0]["release_status"] == water_well_thing.release_status + assert data["items"][0]["well_type"] == water_well_thing.well_type + assert data["items"][0]["well_depth"] == water_well_thing.well_depth + assert data["items"][0]["hole_depth"] == water_well_thing.hole_depth + assert ( + data["items"][0]["well_construction_notes"] + == water_well_thing.well_construction_notes + ) + assert data["items"][0]["spring_type"] is None -def test_weaver_thing_contact_info_by_id(): - response = client.get("/contact?thing_id=1") # or something like this + assert data["items"][1]["id"] == spring_thing.id + assert data["items"][1][ + "created_at" + ] == spring_thing.created_at.isoformat().replace("+00:00", "Z") + assert data["items"][1]["name"] == spring_thing.name + assert data["items"][1]["thing_type"] == spring_thing.thing_type + assert data["items"][1]["release_status"] == spring_thing.release_status + assert data["items"][1]["spring_type"] == spring_thing.spring_type + assert data["items"][1]["well_type"] is None + assert data["items"][1]["well_depth"] is None + assert data["items"][1]["hole_depth"] is None + assert data["items"][1]["well_construction_notes"] is None + + +def test_get_thing_by_id(water_well_thing): + response = client.get(f"/thing/{water_well_thing.id}") assert response.status_code == 200 data = response.json() - assert isinstance(data, dict) - assert "items" in data - assert len(data["items"]) > 0 - item = data["items"][0] - assert "id" in item - assert "name" in item - assert "addresses" in item - assert "emails" in item - assert "phones" in item - assert isinstance(item["addresses"], list) - assert isinstance(item["emails"], list) - assert isinstance(item["phones"], list) + assert data["id"] == water_well_thing.id + assert data["created_at"] == water_well_thing.created_at.isoformat().replace( + "+00:00", "Z" + ) + assert data["name"] == water_well_thing.name + assert data["thing_type"] == water_well_thing.thing_type + assert data["release_status"] == water_well_thing.release_status + assert data["well_type"] == water_well_thing.well_type + assert data["well_depth"] == water_well_thing.well_depth + assert data["hole_depth"] == water_well_thing.hole_depth + assert data["well_construction_notes"] == water_well_thing.well_construction_notes + assert data["spring_type"] is None -# Patch tests -def test_patch_thing_link(): - response = client.patch( - "/thing/id-link/1", - json={ - "relation": "same_as", - "alternate_id": "USGS-43211234", - "alternate_organization": "USGS", - }, - ) - assert response.status_code == 200 - data = response.json() - assert data["id"] == 1 - assert data["relation"] == "same_as" - assert data["alternate_id"] == "USGS-43211234" - assert data["alternate_organization"] == "USGS" - - -def test_patch_thing(): - response = client.patch( - "/thing/1", - json={ - "name": "Updated Test Thing", - }, - ) - assert response.status_code == 200 +def test_get_thing_by_id_404_not_found(water_well_thing): + bad_id = 99999 + response = client.get(f"/thing/{bad_id}") + assert response.status_code == 404 data = response.json() - assert data["id"] == 1 - assert data["name"] == "Updated Test Thing" + assert data["detail"] == f"Thing with ID {bad_id} not found." -def test_patch_well(): - response = client.patch( - "/thing/1", - json={ - "well_depth": 150.0, - }, - ) - assert response.status_code == 200 - data = response.json() - assert data["id"] == 1 - assert data["well_depth"] == 150.0 +# # weaver tests +# def test_weaver_get_wells_geojson(): +# response = client.get("/geospatial", params={"type": "well"}) +# assert response.status_code == 200 +# data = response.json() +# assert "type" in data +# assert data["type"] == "FeatureCollection" +# assert len(data["features"]) > 0 +# assert "id" in data["features"][0]["properties"] + + +# def test_weaver_get_all_collabnet_wells(): +# response = client.get( +# "/geospatial", params={"type": "well", "group": "collabnet"} +# ) # TODO: QUESTION: use type filter and a group filter instead of /collabnet endpoint? +# assert response.status_code == 200 +# data = response.json() + +# assert "features" in data +# assert len(data["features"]) > 0 +# for feature in data["features"]: +# assert "geometry" in feature +# assert isinstance(feature["geometry"], dict) +# assert "properties" in feature +# assert isinstance(feature["properties"], dict) +# assert "coordinates" in feature["geometry"] +# assert "id" in feature or "name" in feature["properties"] +# assert "group" in feature["properties"] + + +# def test_weaver_thing_contact_info_by_id(): +# response = client.get("/contact?thing_id=1") # or something like this +# assert response.status_code == 200 +# data = response.json() +# assert isinstance(data, dict) +# assert "items" in data +# assert len(data["items"]) > 0 +# item = data["items"][0] +# assert "id" in item +# assert "name" in item +# assert "addresses" in item +# assert "emails" in item +# assert "phones" in item + +# assert isinstance(item["addresses"], list) +# assert isinstance(item["emails"], list) +# assert isinstance(item["phones"], list) -def test_patch_thing_location(): - response = client.patch( - "/thing/4/location", - json={ - "point": "POINT(-106.61 35.08)", - }, - ) - assert response.status_code == 200 - data = response.json() - assert data["point"] == "POINT (-106.61 35.08)" +# Patch tests +# def test_patch_thing_link(): +# response = client.patch( +# "/thing/id-link/1", +# json={ +# "relation": "same_as", +# "alternate_id": "USGS-43211234", +# "alternate_organization": "USGS", +# }, +# ) +# assert response.status_code == 200 +# data = response.json() +# assert data["id"] == 1 +# assert data["relation"] == "same_as" +# assert data["alternate_id"] == "USGS-43211234" +# assert data["alternate_organization"] == "USGS" + + +# def test_patch_thing(): +# response = client.patch( +# "/thing/1", +# json={ +# "name": "Updated Test Thing", +# }, +# ) +# assert response.status_code == 200 +# data = response.json() +# assert data["id"] == 1 +# assert data["name"] == "Updated Test Thing" + + +# def test_patch_well(): +# response = client.patch( +# "/thing/1", +# json={ +# "well_depth": 150.0, +# }, +# ) +# assert response.status_code == 200 +# data = response.json() +# assert data["id"] == 1 +# assert data["well_depth"] == 150.0 + + +# def test_patch_thing_location(): +# response = client.patch( +# "/thing/4/location", +# json={ +# "point": "POINT(-106.61 35.08)", +# }, +# ) +# assert response.status_code == 200 +# data = response.json() +# assert data["point"] == "POINT (-106.61 35.08)" # ============= EOF ============================================= From 3b9d8b916342bf2b3d505f601e143aeb0300448e Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 27 Aug 2025 14:56:25 -0600 Subject: [PATCH 08/21] feat: implement POST water well --- api/thing.py | 56 +++++++++++++++--- schemas/thing.py | 16 ++++-- services/thing_helper.py | 22 ++------ tests/conftest.py | 53 +++++++++-------- tests/test_thing.py | 119 ++++++++++++++++++++++++--------------- 5 files changed, 165 insertions(+), 101 deletions(-) diff --git a/api/thing.py b/api/thing.py index 503793c58..cd91ab4b4 100644 --- a/api/thing.py +++ b/api/thing.py @@ -17,7 +17,8 @@ from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select from sqlalchemy.orm import Session -from starlette.status import HTTP_200_OK, HTTP_201_CREATED +from sqlalchemy.exc import ProgrammingError +from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_409_CONFLICT from api.pagination import CustomPage from core.dependencies import ( @@ -55,6 +56,7 @@ UpdateWellScreen, ) from services.crud_helper import model_patcher, model_adder +from services.exceptions_helper import PydanticStyleException from services.query_helper import ( simple_get_by_id, paginated_all_getter, @@ -71,6 +73,42 @@ prefix="/thing", tags=["thing"], dependencies=[Depends(viewer_function)] ) + +def database_error_handler( + payload: CreateWell | CreateSpring, error: 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"' + ): + + 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"' + ): + + detail = { + "loc": ["body", "location_id"], + "msg": f"Location with ID {payload.location_id} not found.", + "type": "value_error", + "input": {"location_id": payload.location_id}, + } + + raise PydanticStyleException(status_code=HTTP_409_CONFLICT, detail=[detail]) + + # GET ========================================================================== @@ -263,7 +301,7 @@ def get_thing_id_links( return paginate(query=sql, conn=session) -# ===== POST ============= +# POST ======================================================================== @router.post( @@ -281,21 +319,23 @@ def create_thing_id_link( @router.post( - "/well", - summary="Create a well", + "/water-well", + summary="Create a water well", status_code=HTTP_201_CREATED, ) def create_well( thing_data: CreateWell, session: session_dependency, + request: Request, user: amp_admin_dependency, ) -> WellResponse: """ - Create a new well in the database. + Create a new water well in the database. """ - # print("Creating well with data:", well_data, user) - - return add_thing(session, thing_data, thing_type="water well", user=user) + try: + return add_thing(session, thing_data, request, user=user) + except ProgrammingError as e: + database_error_handler(thing_data, e) @router.post( diff --git a/schemas/thing.py b/schemas/thing.py index 094044807..45e972383 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -34,11 +34,19 @@ class CreateThingIdLink(BaseModel): class CreateBaseThing(BaseModel): + """ + Developer's notes + + thing_type does not need to be set by the user, this is determined by the + POST endpoint + + e.g. POST /thing/water-well, POST /thing/spring determines the thing_type + """ + location_id: int | None = None # Optional location ID for the thing + group_id: int | None = None # Optional group ID for the thing name: str # Name of the thing - group: str | None = None # Optional group ID for the thing - thing_type: str | None = None # Type of the thing (e.g., "Well", "Spring", etc.) - release_status: str | None = "draft" # Release status of the thing + release_status: str # Release status of the thing class CreateWell(CreateBaseThing): @@ -46,8 +54,6 @@ class CreateWell(CreateBaseThing): Schema for creating a well. """ - # api_id: str | None = None - # ose_pod_id: str | None = None well_type: str | None = None well_depth: float | None = None # in feet hole_depth: float | None = None # in feet diff --git a/services/thing_helper.py b/services/thing_helper.py index 18d748119..b9df15caf 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -21,7 +21,7 @@ from starlette.status import HTTP_404_NOT_FOUND from db import LocationThingAssociation, Thing, Base, Location -from db.group import Group, GroupThingAssociation +from db.group import GroupThingAssociation from services.audit_helper import audit_add from services.exceptions_helper import PydanticStyleException from services.geospatial_helper import make_within_wkt @@ -109,31 +109,16 @@ def get_thing_of_a_thing_type_by_id(session: Session, request: Request, thing_id return thing -# REFACTOR TODO: use enums (or enum-like object) for thing_type def add_thing( - session: Session, data: BaseModel | dict, thing_type: str = None, user: dict = None + session: Session, data: BaseModel | dict, request: Request, user: dict = None ) -> Base: + thing_type = get_thing_type_from_request(request) if isinstance(data, BaseModel): data = data.model_dump() location_id = data.pop("location_id", None) - group_id = data.pop("group_id", None) - if not group_id: - group_name = data.pop("group", None) - if group_name is not None: - sql = select(Group).where(Group.name == group_name) - dbg = session.scalars(sql).one_or_none() - if dbg: - group_id = dbg.id - else: - raise ValueError(f"Group '{group_name}' not found.") - - if not thing_type: - thing_type = data.get("thing_type", None) - if not thing_type: - raise ValueError("Thing type must be specified.") thing = Thing(**data) thing.thing_type = thing_type @@ -144,6 +129,7 @@ def add_thing( session.commit() session.refresh(thing) + # endpoint catches ProgrammingError if location_id or group_id do not exist if group_id: assoc = GroupThingAssociation() audit_add(user, assoc) diff --git a/tests/conftest.py b/tests/conftest.py index 2c7d90f18..afcc38930 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,6 @@ from db import * from db.engine import session_ctx -from services.thing_helper import add_thing @pytest.fixture(scope="session") @@ -36,21 +35,24 @@ def second_location(): @pytest.fixture(scope="session") def water_well_thing(location): with session_ctx() as session: - wt = add_thing( - session, - { - "location_id": location.id, - "name": "Test Well", - "release_status": "draft", - "well_type": "Production", - "well_depth": 10, - "hole_depth": 10, - "well_construction_notes": "Test well construction notes", - }, - "water well", + water_well = Thing( + name="Test Well", + release_status="draft", + well_type="Production", + well_depth=10, + hole_depth=10, + well_construction_notes="Test well construction notes", ) + session.add(water_well) + session.commit() + session.refresh(water_well) + + assoc = LocationThingAssociation() + assoc.location_id = location.id + assoc.thing_id = water_well.id + session.add(assoc) - yield wt + yield water_well session.close() @@ -87,18 +89,21 @@ def thing_id_link(water_well_thing): @pytest.fixture(scope="session") def spring_thing(location): with session_ctx() as session: - st = add_thing( - session, - { - "location_id": location.id, - "name": "Test Spring", - "release_status": "draft", - "spring_type": "Artesian", - }, - "spring", + spring = Thing( + name="Test Spring", + release_status="draft", + spring_type="Artesian", ) + session.add(spring) + session.commit() + session.refresh(spring) + + assoc = LocationThingAssociation() + assoc.location_id = location.id + assoc.thing_id = spring.id + session.add(assoc) - yield st + yield spring session.close() diff --git a/tests/test_thing.py b/tests/test_thing.py index f5252e10b..a324ec947 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -14,7 +14,9 @@ # limitations under the License. # =============================================================================== import pytest -from tests import client, override_authentication + +from db import Thing +from tests import client, override_authentication, cleanup_post_test from main import app from core.dependencies import ( admin_function, @@ -51,51 +53,76 @@ def override_authentication_dependency_fixture(): # POST tests =================================================================== -# def test_add_well(location): -# response = client.post( -# "/lexicon/add", json={"term": "Monitoring", "definition": "Monitoring Well"} -# ) -# assert response.status_code == 200 -# response = client.post( -# "/lexicon/add", json={"term": "Production", "definition": "Production Well"} -# ) -# assert response.status_code == 200 - -# response = client.post( -# "/thing", -# json={ -# "thing_type": "water well", -# "location_id": location.id, -# "name": "Test Well", -# "api_id": "1001-0001", -# "ose_pod_id": "RA-0001", -# "well_type": "Monitoring", -# "well_depth": 100.0, -# "well_construction_notes": "this is a test of notes", -# }, -# ) -# assert response.status_code == 201 -# data = response.json() -# assert "id" in data -# assert data["name"] == "Test Well" -# assert data["well_type"] == "Monitoring" - -# response = client.post( -# "/thing", -# json={ -# "thing_type": "water well", -# "location_id": location.id, -# "name": "Test Well 2", -# "api_id": "1001-0002", -# "ose_pod_id": "RA-0002", -# "well_type": "Production", -# "well_depth": 1200.0, -# "group": "collabnet", -# }, -# ) -# assert response.status_code == 201 -# data = response.json() -# assert "id" in data +def test_add_water_well(location, group): + payload = { + "location_id": location.id, + "group_id": group.id, + "release_status": "draft", + "name": "Test Well", + "well_type": "Monitoring", + "well_depth": 100.0, + "hole_depth": 110, + "well_construction_notes": "this is a test of notes", + } + + response = client.post("/thing/water-well", json=payload) + assert response.status_code == 201 + data = response.json() + assert "id" in data + assert "created_at" in data + assert data["release_status"] == payload["release_status"] + assert data["name"] == payload["name"] + assert data["thing_type"] == "water well" + assert data["well_type"] == payload["well_type"] + assert data["hole_depth"] == payload["hole_depth"] + assert data["well_depth"] == payload["well_depth"] + assert data["well_construction_notes"] == payload["well_construction_notes"] + + cleanup_post_test(Thing, data["id"]) + + +def test_add_water_well_409_bad_group_id(location): + bad_group_id = 9999 + payload = { + "location_id": location.id, + "group_id": bad_group_id, # Invalid group ID + "release_status": "draft", + "name": "Test Well", + "well_type": "Monitoring", + "well_depth": 100.0, + "hole_depth": 110, + "well_construction_notes": "this is a test of notes", + } + + response = client.post("/thing/water-well", json=payload) + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["loc"] == ["body", "group_id"] + assert data["detail"][0]["msg"] == f"Group with ID {bad_group_id} not found." + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"group_id": bad_group_id} + + +def test_add_water_well_409_bad_location_id(group): + bad_location_id = 9999 + payload = { + "location_id": bad_location_id, + "group_id": group.id, # Invalid group ID + "release_status": "draft", + "name": "Test Well", + "well_type": "Monitoring", + "well_depth": 100.0, + "hole_depth": 110, + "well_construction_notes": "this is a test of notes", + } + + response = client.post("/thing/water-well", json=payload) + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["loc"] == ["body", "location_id"] + assert data["detail"][0]["msg"] == f"Location with ID {bad_location_id} not found." + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"location_id": bad_location_id} # def test_add_spring(): From 67a20a93e7700cc68c2d4fef6e11a76351262408 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 27 Aug 2025 15:31:59 -0600 Subject: [PATCH 09/21] fix: remove location/thing artifact --- api/thing.py | 7 ++-- services/thing_helper.py | 66 ++++++++++++++++++------------------- tests/conftest.py | 2 ++ tests/test_thing.py | 70 +++++++++++++++++++++++++++++----------- 4 files changed, 91 insertions(+), 54 deletions(-) diff --git a/api/thing.py b/api/thing.py index cd91ab4b4..092179336 100644 --- a/api/thing.py +++ b/api/thing.py @@ -273,7 +273,6 @@ def get_things( query, session, sort, - with_location=True, within=within, ) @@ -346,12 +345,16 @@ def create_well( def create_spring( thing_data: CreateSpring, session: session_dependency, + request: Request, user: amp_admin_dependency, ) -> SpringResponse: """ Create a new well in the database. """ - return add_thing(session, thing_data, thing_type="spring", user=user) + try: + return add_thing(session, thing_data, request, user=user) + except ProgrammingError as e: + database_error_handler(thing_data, e) @router.post( diff --git a/services/thing_helper.py b/services/thing_helper.py index b9df15caf..782475812 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -44,7 +44,6 @@ def get_db_things( session, sort, thing_type: str = None, - with_location: bool = False, within: str = None, ) -> list: @@ -53,17 +52,14 @@ def get_db_things( else: sql = select(Thing) - if with_location or within: - sql = sql.join( - LocationThingAssociation, Thing.id == LocationThingAssociation.thing_id - ) - sql = sql.join(Location) - if thing_type: sql = sql.where(Thing.thing_type == thing_type) if within: - + sql = sql.join( + LocationThingAssociation, Thing.id == LocationThingAssociation.thing_id + ) + sql = sql.join(Location) sql = make_within_wkt(sql, within) sql = order_sort_filter(sql, Thing, sort, order, filter_) @@ -120,31 +116,35 @@ def add_thing( location_id = data.pop("location_id", None) group_id = data.pop("group_id", None) - thing = Thing(**data) - thing.thing_type = thing_type - - audit_add(user, thing) - - session.add(thing) - session.commit() - session.refresh(thing) - - # endpoint catches ProgrammingError if location_id or group_id do not exist - if group_id: - assoc = GroupThingAssociation() - audit_add(user, assoc) - assoc.group_id = group_id - assoc.thing_id = thing.id - session.add(assoc) - - if location_id is not None: - assoc = LocationThingAssociation() - audit_add(user, assoc) - assoc.location_id = location_id - assoc.thing_id = thing.id - session.add(assoc) - - session.commit() + try: + thing = Thing(**data) + thing.thing_type = thing_type + + audit_add(user, thing) + + session.add(thing) + session.flush() + session.refresh(thing) + + # endpoint catches ProgrammingError if location_id or group_id do not exist + if group_id: + assoc = GroupThingAssociation() + audit_add(user, assoc) + assoc.group_id = group_id + assoc.thing_id = thing.id + session.add(assoc) + + if location_id is not None: + assoc = LocationThingAssociation() + audit_add(user, assoc) + assoc.location_id = location_id + assoc.thing_id = thing.id + session.add(assoc) + + session.commit() + except Exception as e: + session.rollback() + raise e return thing diff --git a/tests/conftest.py b/tests/conftest.py index afcc38930..222440adf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,6 +37,7 @@ def water_well_thing(location): with session_ctx() as session: water_well = Thing( name="Test Well", + thing_type="water well", release_status="draft", well_type="Production", well_depth=10, @@ -91,6 +92,7 @@ def spring_thing(location): with session_ctx() as session: spring = Thing( name="Test Spring", + thing_type="spring", release_status="draft", spring_type="Artesian", ) diff --git a/tests/test_thing.py b/tests/test_thing.py index a324ec947..d370f2ea8 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -125,28 +125,60 @@ def test_add_water_well_409_bad_location_id(group): assert data["detail"][0]["input"] == {"location_id": bad_location_id} -# def test_add_spring(): -# response = client.post( -# "/thing", -# json={ -# "location_id": 1, -# "name": "Test Spring", -# "thing_type": "spring", -# "spring_type": "Ephemeral", -# }, -# ) -# assert response.status_code == 201 -# data = response.json() -# assert "id" in data +def test_add_spring(location, group): + payload = { + "location_id": location.id, + "group_id": group.id, + "name": "test spring", + "release_status": "draft", + "spring_type": "Ephemeral", + } + response = client.post("/thing/spring", json=payload) + assert response.status_code == 201 + data = response.json() + assert "id" in data + assert "created_at" in data + assert data["name"] == payload["name"] + assert data["release_status"] == payload["release_status"] + assert data["spring_type"] == payload["spring_type"] -# assert "name" in data -# assert data["name"] == "Test Spring" + cleanup_post_test(Thing, data["id"]) -# assert "thing_type" in data -# assert data["thing_type"] == "spring" -# assert "spring_type" in data -# assert data["spring_type"] == "Ephemeral" +def test_add_spring_409_bad_group_id(location): + bad_group_id = 9999 + payload = { + "location_id": location.id, + "group_id": bad_group_id, # Invalid group ID + "name": "test spring", + "release_status": "draft", + "spring_type": "Ephemeral", + } + response = client.post("/thing/spring", json=payload) + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["loc"] == ["body", "group_id"] + assert data["detail"][0]["msg"] == f"Group with ID {bad_group_id} not found." + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"group_id": bad_group_id} + + +def test_add_spring_409_bad_location_id(group): + bad_location_id = 9999 + payload = { + "location_id": bad_location_id, + "group_id": group.id, # Invalid group ID + "name": "test spring", + "release_status": "draft", + "spring_type": "Ephemeral", + } + response = client.post("/thing/spring", json=payload) + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["loc"] == ["body", "location_id"] + assert data["detail"][0]["msg"] == f"Location with ID {bad_location_id} not found." + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"location_id": bad_location_id} # def test_add_well_screen(): From f0e9bc1091c9a3079f167c1c2b5252e31e4b9d1e Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 27 Aug 2025 16:32:21 -0600 Subject: [PATCH 10/21] feat: implement POST well screen --- api/thing.py | 51 ++++++++++++--------- services/thing_helper.py | 34 +++++++++++++- tests/test_thing.py | 97 +++++++++++++++++++++++++++++++--------- 3 files changed, 139 insertions(+), 43 deletions(-) diff --git a/api/thing.py b/api/thing.py index 092179336..3f0d5a095 100644 --- a/api/thing.py +++ b/api/thing.py @@ -50,7 +50,6 @@ UpdateWell, SpringResponse, CreateSpring, - CreateThing, ThingIdLinkResponse, UpdateThingIdLink, UpdateWellScreen, @@ -64,10 +63,11 @@ ) from services.thing_helper import ( add_thing, + add_well_screen, get_db_things, get_thing_of_a_thing_type_by_id, ) -from services.validation.well import validate_screens +from services.lexicon_helper import get_terms_by_category router = APIRouter( prefix="/thing", tags=["thing"], dependencies=[Depends(viewer_function)] @@ -105,6 +105,28 @@ def database_error_handler( "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"' + ): + 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"' + ): + valid_screen_types = get_terms_by_category("casing_material") + valid_screen_types_for_msg = " | ".join(valid_screen_types) + detail = { + "loc": ["body", "screen_type"], + "msg": f"{payload.screen_type} is an invalid screen type. Valid types are: {valid_screen_types_for_msg}.", + "type": "value_error", + "input": {"screen_type": payload.screen_type}, + } raise PydanticStyleException(status_code=HTTP_409_CONFLICT, detail=[detail]) @@ -357,22 +379,6 @@ def create_spring( database_error_handler(thing_data, e) -@router.post( - "", - summary="Create a new thing", - status_code=HTTP_201_CREATED, -) -def create_thing( - thing_data: CreateThing, - session: session_dependency, - user: admin_dependency, -) -> ThingResponse: - """ - Create a new well in the database. - """ - return add_thing(session, thing_data, user=user) - - @router.post( "/well-screen", summary="Create a new well screen", @@ -381,12 +387,17 @@ def create_thing( def create_wellscreen( session: session_dependency, user: amp_admin_dependency, - well_screen_data: CreateWellScreen = Depends(validate_screens), + well_screen_data: CreateWellScreen, # = Depends(validate_screens), ) -> WellScreenResponse: """ Create a new well screen in the database. """ - return model_adder(session, WellScreen, well_screen_data, user=user) + try: + return add_well_screen(session, well_screen_data, user=user) + except ProgrammingError as e: + database_error_handler(well_screen_data, e) + except PydanticStyleException as e: + raise e @router.patch("/{thing_id}", summary="Update thing") diff --git a/services/thing_helper.py b/services/thing_helper.py index 782475812..8f715b834 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -18,9 +18,9 @@ from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session -from starlette.status import HTTP_404_NOT_FOUND +from starlette.status import HTTP_404_NOT_FOUND, HTTP_409_CONFLICT -from db import LocationThingAssociation, Thing, Base, Location +from db import LocationThingAssociation, Thing, Base, Location, WellScreen from db.group import GroupThingAssociation from services.audit_helper import audit_add from services.exceptions_helper import PydanticStyleException @@ -148,4 +148,34 @@ def add_thing( return thing +def add_well_screen(session, well_screen_data: BaseModel, user: dict = None): + try: + well_screen_data_dump = well_screen_data.model_dump() + well_screen = WellScreen(**well_screen_data_dump) + audit_add(user, well_screen) + + session.add(well_screen) + session.flush() + + thing = session.get(Thing, well_screen_data.thing_id) + if thing.thing_type != "water well": + raise PydanticStyleException( + status_code=HTTP_409_CONFLICT, + detail=[ + { + "loc": ["body", "thing_id"], + "type": "value_error", + "input": {"thing_id": thing.id}, + "msg": f"Thing with ID {thing.id} is not a water well Thing. It is a {thing.thing_type} Thing.", + } + ], + ) + + session.commit() + except Exception as e: + session.rollback() + raise e + return well_screen + + # ============= EOF ============================================= diff --git a/tests/test_thing.py b/tests/test_thing.py index d370f2ea8..07c24712c 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -15,7 +15,7 @@ # =============================================================================== import pytest -from db import Thing +from db import Thing, WellScreen from tests import client, override_authentication, cleanup_post_test from main import app from core.dependencies import ( @@ -181,26 +181,81 @@ def test_add_spring_409_bad_location_id(group): assert data["detail"][0]["input"] == {"location_id": bad_location_id} -# def test_add_well_screen(): -# # response = client.post( -# # "/lexicon/add", -# # json={"term": "PVC", "definition": "PVC Well Screen"}, -# # ) -# # assert response.status_code == 200 -# response = client.post( -# "/thing/well-screen", -# json={ -# "thing_id": 1, -# "screen_depth_top": 10.0, -# "screen_depth_bottom": 20.0, -# "screen_type": "PVC", -# }, -# ) +def test_add_well_screen(water_well_thing): + payload = { + "thing_id": water_well_thing.id, + "screen_depth_top": 10.0, + "screen_depth_bottom": 20.0, + "screen_type": "PVC", + } + response = client.post("/thing/well-screen", json=payload) -# assert response.status_code == 201 -# data = response.json() -# assert "id" in data -# assert data["thing_id"] == 1 + assert response.status_code == 201 + data = response.json() + assert "id" in data + assert "created_at" in data + assert data["thing_id"] == water_well_thing.id + assert data["screen_depth_top"] == payload["screen_depth_top"] + assert data["screen_depth_bottom"] == payload["screen_depth_bottom"] + assert data["screen_type"] == payload["screen_type"] + + cleanup_post_test(WellScreen, data["id"]) + + +def test_add_well_screen_409_bad_thing_id(): + bad_thing_id = 9999 + payload = { + "thing_id": bad_thing_id, + "screen_depth_top": 10.0, + "screen_depth_bottom": 20.0, + "screen_type": "PVC", + } + response = client.post("/thing/well-screen", json=payload) + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["loc"] == ["body", "thing_id"] + assert data["detail"][0]["msg"] == f"Thing with ID {bad_thing_id} not found." + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"thing_id": bad_thing_id} + + +def test_well_add_well_screen_409_wrong_thing_type(spring_thing): + payload = { + "thing_id": spring_thing.id, + "screen_depth_top": 10.0, + "screen_depth_bottom": 20.0, + "screen_type": "PVC", + } + response = client.post("/thing/well-screen", json=payload) + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["loc"] == ["body", "thing_id"] + assert ( + data["detail"][0]["msg"] + == f"Thing with ID {spring_thing.id} is not a water well Thing. It is a spring Thing." + ) + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"thing_id": spring_thing.id} + + +def test_add_well_screen_409_bad_screen_type(water_well_thing): + payload = { + "thing_id": water_well_thing.id, + "screen_depth_top": 10.0, + "screen_depth_bottom": 20.0, + "screen_type": "NotARealType", + } + response = client.post("/thing/well-screen", json=payload) + + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["loc"] == ["body", "screen_type"] + assert ( + data["detail"][0]["msg"] + == f"{payload['screen_type']} is an invalid screen type. Valid types are: PVC | Steel | Concrete." + ) + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"screen_type": payload["screen_type"]} # def test_add_thing_link(): @@ -465,7 +520,7 @@ def test_get_things(water_well_thing, spring_thing): response = client.get("/thing") assert response.status_code == 200 data = response.json() - + print(data) assert data["total"] == 2 assert data["items"][0]["id"] == water_well_thing.id From 15aede3204305429f06bfb1c114fb1f1efbae2c1 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 27 Aug 2025 16:34:41 -0600 Subject: [PATCH 11/21] doc: remove outdate note --- api/thing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/thing.py b/api/thing.py index 3f0d5a095..2a23e327f 100644 --- a/api/thing.py +++ b/api/thing.py @@ -387,7 +387,7 @@ def create_spring( def create_wellscreen( session: session_dependency, user: amp_admin_dependency, - well_screen_data: CreateWellScreen, # = Depends(validate_screens), + well_screen_data: CreateWellScreen, ) -> WellScreenResponse: """ Create a new well screen in the database. From 04d587e152a3a537cdf80946d396046ac8b3b681 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 27 Aug 2025 16:53:21 -0600 Subject: [PATCH 12/21] feat: implement POST thing id link --- api/thing.py | 19 ++++++++++++++++- tests/test_thing.py | 52 +++++++++++++++++++++++++++++++-------------- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/api/thing.py b/api/thing.py index 2a23e327f..8e2f6a164 100644 --- a/api/thing.py +++ b/api/thing.py @@ -82,6 +82,7 @@ def database_error_handler( """ error_message = error.orig.args[0]["M"] + print(error_message) if ( error_message @@ -127,6 +128,16 @@ 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"' + ): + detail = { + "loc": ["body", "thing_id"], + "msg": f"Thing with ID {payload.thing_id} not found.", + "type": "value_error", + "input": {"thing_id": payload.thing_id}, + } raise PydanticStyleException(status_code=HTTP_409_CONFLICT, detail=[detail]) @@ -336,7 +347,10 @@ def create_thing_id_link( """ Create a new link between a thing and an alternate ID. """ - return model_adder(session, ThingIdLink, link_data, user=user) + try: + return model_adder(session, ThingIdLink, link_data, user=user) + except ProgrammingError as e: + database_error_handler(link_data, e) @router.post( @@ -400,6 +414,9 @@ def create_wellscreen( raise e +# PATCH ======================================================================== + + @router.patch("/{thing_id}", summary="Update thing") def update_thing( thing_id: int, diff --git a/tests/test_thing.py b/tests/test_thing.py index 07c24712c..a636db7c1 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -15,7 +15,7 @@ # =============================================================================== import pytest -from db import Thing, WellScreen +from db import Thing, WellScreen, ThingIdLink from tests import client, override_authentication, cleanup_post_test from main import app from core.dependencies import ( @@ -258,21 +258,41 @@ def test_add_well_screen_409_bad_screen_type(water_well_thing): assert data["detail"][0]["input"] == {"screen_type": payload["screen_type"]} -# def test_add_thing_link(): -# response = client.post( -# "/thing/id-link", -# json={ -# "thing_id": 1, -# "relation": "same_as", -# "alternate_id": "4321-1234", -# "alternate_organization": "USGS", -# }, -# ) -# assert response.status_code == 201 -# data = response.json() -# assert "id" in data -# assert data["thing_id"] == 1 -# assert data["alternate_id"] == "4321-1234" +def test_add_thing_link(spring_thing): + payload = { + "thing_id": spring_thing.id, + "relation": "same_as", + "alternate_id": "4321-1234", + "alternate_organization": "USGS", + } + response = client.post("/thing/id-link", json=payload) + assert response.status_code == 201 + data = response.json() + assert "id" in data + assert "created_at" in data + assert data["thing_id"] == spring_thing.id + assert data["relation"] == payload["relation"] + assert data["alternate_id"] == payload["alternate_id"] + assert data["alternate_organization"] == payload["alternate_organization"] + + cleanup_post_test(ThingIdLink, data["id"]) + + +def test_add_thing_id_link_409_bad_thing_id(): + bad_thing_id = 9999 + payload = { + "thing_id": bad_thing_id, + "relation": "same_as", + "alternate_id": "4321-1234", + "alternate_organization": "USGS", + } + response = client.post("/thing/id-link", json=payload) + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["loc"] == ["body", "thing_id"] + assert data["detail"][0]["msg"] == f"Thing with ID {bad_thing_id} not found." + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"thing_id": bad_thing_id} # GET tests ==================================================================== From 6815ee9a19b363bef1a475e8c0fba19b3cdce18a Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 27 Aug 2025 16:57:45 -0600 Subject: [PATCH 13/21] feat: enable location's name to be PATCHed --- schemas/location.py | 2 +- tests/test_location.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/schemas/location.py b/schemas/location.py index 34a082a0c..ed23fdf63 100644 --- a/schemas/location.py +++ b/schemas/location.py @@ -16,7 +16,6 @@ from geoalchemy2 import WKBElement from geoalchemy2.shape import to_shape from pydantic import BaseModel, field_validator -from shapely import wkt from schemas import ORMBaseModel from services.validation.geospatial import validate_wkt_geometry @@ -94,6 +93,7 @@ class UpdateLocation(BaseModel): Schema for updating a location. """ + name: str | None = None notes: str | None = None point: str | None = None release_status: str | None = None diff --git a/tests/test_location.py b/tests/test_location.py index cee06ab2b..f69068c3f 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -63,13 +63,18 @@ def test_add_location(): def test_update_location(location): - payload = {"point": "POINT (10.1 20.2)", "release_status": "draft"} + payload = { + "point": "POINT (10.1 20.2)", + "release_status": "draft", + "name": "patched name", + } response = client.patch(f"/location/{location.id}", json=payload) assert response.status_code == 200 data = response.json() assert data["id"] == location.id assert data["point"] == payload["point"] assert data["release_status"] == payload["release_status"] + assert data["name"] == payload["name"] # cleanup after test cleanup_patch_test(Location, payload, location) From d46710de1e6618febc50f0b721775ea35c891237 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 27 Aug 2025 18:53:48 -0600 Subject: [PATCH 14/21] fix: close all database sessions --- tests/conftest.py | 43 +++---------------------------------------- 1 file changed, 3 insertions(+), 40 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 222440adf..eaa2f762b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,11 +52,9 @@ def water_well_thing(location): assoc.location_id = location.id assoc.thing_id = water_well.id session.add(assoc) - + session.commit() yield water_well - session.close() - @pytest.fixture(scope="session") def well_screen(water_well_thing): @@ -104,11 +102,9 @@ def spring_thing(location): assoc.location_id = location.id assoc.thing_id = spring.id session.add(assoc) - + session.commit() yield spring - session.close() - @pytest.fixture(scope="session") def sensor(): @@ -125,7 +121,6 @@ def sensor(): session.add(sensor) session.commit() yield sensor - session.close() @pytest.fixture(scope="function") @@ -145,7 +140,6 @@ def second_sensor(): yield sensor session.delete(sensor) session.commit() - session.close() @pytest.fixture(scope="session") @@ -170,8 +164,6 @@ def sample(water_well_thing, sensor): session.commit() yield sample - session.close() - @pytest.fixture(scope="function") def second_sample(water_well_thing, sensor): @@ -196,7 +188,6 @@ def second_sample(water_well_thing, sensor): yield sample session.delete(sample) session.commit() - session.close() @pytest.fixture(scope="session") @@ -219,8 +210,6 @@ def contact(water_well_thing): yield contact - session.close() - @pytest.fixture(scope="session") def address(contact): @@ -240,8 +229,6 @@ def address(contact): session.refresh(address) yield address - session.close() - @pytest.fixture(scope="session") def email(contact): @@ -254,8 +241,6 @@ def email(contact): session.refresh(email) yield email - session.close() - @pytest.fixture(scope="session") def phone(contact): @@ -268,8 +253,6 @@ def phone(contact): session.refresh(phone) yield phone - session.close() - @pytest.fixture(scope="function") def second_contact(): @@ -286,7 +269,6 @@ def second_contact(): session.delete(contact) session.commit() - session.close() @pytest.fixture(scope="function") @@ -303,7 +285,6 @@ def second_email(second_contact): yield email session.delete(email) session.commit() - session.close() @pytest.fixture(scope="function") @@ -320,7 +301,6 @@ def second_phone(second_contact): yield phone session.delete(phone) session.commit() - session.close() @pytest.fixture(scope="function") @@ -342,7 +322,6 @@ def second_address(second_contact): yield address session.delete(address) session.commit() - session.close() @pytest.fixture(scope="session") @@ -362,8 +341,6 @@ def asset(): session.refresh(asset) yield asset - session.close() - @pytest.fixture(scope="function") def asset_with_associated_thing(water_well_thing): @@ -392,7 +369,6 @@ def asset_with_associated_thing(water_well_thing): session.delete(asset) session.delete(association) session.commit() - session.close() @pytest.fixture(scope="function") @@ -412,7 +388,7 @@ def second_asset(): session.refresh(asset) yield asset session.delete(asset) - session.close() + session.commit() @pytest.fixture(scope="session") @@ -433,8 +409,6 @@ def groundwater_level_observation(sensor, sample): session.commit() yield observation - session.close() - @pytest.fixture(scope="session") def water_chemistry_observation(sensor, sample): @@ -452,8 +426,6 @@ def water_chemistry_observation(sensor, sample): session.commit() yield observation - session.close() - @pytest.fixture(scope="session") def geothermal_observation(sensor, sample): @@ -472,8 +444,6 @@ def geothermal_observation(sensor, sample): session.commit() yield observation - session.close() - @pytest.fixture(scope="function") def observation_to_delete(sample, sensor): @@ -514,8 +484,6 @@ def group(water_well_thing): yield group - session.close() - @pytest.fixture(scope="function") def second_group(water_well_thing): @@ -539,8 +507,6 @@ def second_group(water_well_thing): yield group - session.close() - @pytest.fixture(scope="session") def lexicon_category(): @@ -609,7 +575,6 @@ def second_lexicon_term(lexicon_category): session.refresh(term_category_association) yield term - session.commit() @pytest.fixture(scope="session") @@ -631,7 +596,6 @@ def third_lexicon_term(lexicon_category): session.refresh(term_category_association) yield term - session.commit() @pytest.fixture(scope="session") @@ -653,7 +617,6 @@ def fourth_lexicon_term(lexicon_category): session.refresh(term_category_association) yield term - session.commit() @pytest.fixture(scope="session") From ee93a3fdee6209368082df45967d698ff7ceef46 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 27 Aug 2025 18:54:34 -0600 Subject: [PATCH 15/21] fix: change tests to use spring/water well fixtures --- api/thing.py | 1 - tests/test_asset.py | 10 +++++----- tests/test_contact.py | 4 ++-- tests/test_geospatial.py | 3 ++- tests/test_observation.py | 7 +++++-- tests/test_sample.py | 8 ++++---- tests/test_search.py | 4 ++-- tests/test_thing.py | 1 - 8 files changed, 20 insertions(+), 18 deletions(-) diff --git a/api/thing.py b/api/thing.py index 8e2f6a164..048ba2921 100644 --- a/api/thing.py +++ b/api/thing.py @@ -82,7 +82,6 @@ def database_error_handler( """ error_message = error.orig.args[0]["M"] - print(error_message) if ( error_message diff --git a/tests/test_asset.py b/tests/test_asset.py index e56b463a4..7cce58112 100644 --- a/tests/test_asset.py +++ b/tests/test_asset.py @@ -86,9 +86,9 @@ def test_upload_asset(): assert "storage_path" in data -def test_add_asset(thing): +def test_add_asset(water_well_thing): payload = { - "thing_id": thing.id, + "thing_id": water_well_thing.id, "name": "test_asset.png", "label": "Test Asset", "uri": "https://storage.googleapis.com/mock-bucket/mock-asset", @@ -114,7 +114,7 @@ def test_add_asset(thing): cleanup_post_test(Asset, data["id"]) -def test_add_asset_409_bad_thing_id(thing): +def test_add_asset_409_bad_thing_id(water_well_thing): bad_thing_id = 99999 payload = { "thing_id": bad_thing_id, @@ -160,9 +160,9 @@ def test_get_assets(asset, asset_with_associated_thing): assert data["items"][1]["signed_url"] == None -def test_get_assets_thing_id(asset_with_associated_thing, thing): +def test_get_assets_thing_id(asset_with_associated_thing, water_well_thing): with patch("api.asset.get_storage_bucket", return_value=MockStorageBucket()): - query_parameters = {"thing_id": thing.id} + query_parameters = {"thing_id": water_well_thing.id} response = client.get("/asset", params=query_parameters) assert response.status_code == 200 data = response.json() diff --git a/tests/test_contact.py b/tests/test_contact.py index 19ec570e4..33a9ee1b0 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -59,11 +59,11 @@ def test_validate_email(): # ADD tests ==================================================================== -def test_add_contact(thing): +def test_add_contact(spring_thing): payload = { "name": "Test Contact 2", "role": "Owner", - "thing_id": thing.id, + "thing_id": spring_thing.id, "emails": [{"email": "testcontact2@gmail.com", "email_type": "Primary"}], "phones": [{"phone_number": "+14153334444", "phone_type": "Primary"}], "addresses": [ diff --git a/tests/test_geospatial.py b/tests/test_geospatial.py index 82592d195..442e96004 100644 --- a/tests/test_geospatial.py +++ b/tests/test_geospatial.py @@ -102,6 +102,8 @@ def populate(): session.delete(loc1) session.delete(loc2) session.delete(group) + session.delete(thing1) + session.delete(thing2) session.commit() @@ -112,7 +114,6 @@ def test_get_project_area(): assert "type" in data assert data["type"] == "FeatureCollection" assert "features" in data - print(data) assert len(data["features"]) > 0 assert data["features"][0]["properties"]["group_id"] == 1 assert data["features"][0]["properties"]["group_name"] == "Test Group Foo" diff --git a/tests/test_observation.py b/tests/test_observation.py index 6dcfc85de..1200af45b 100644 --- a/tests/test_observation.py +++ b/tests/test_observation.py @@ -429,10 +429,13 @@ def test_get_groundwater_observation_by_sample(sample): assert len(items) > 0, "Expected at least one groundwater observation for the thing" -def test_get_groundwater_observation_by_thing(thing): +def test_get_groundwater_observation_by_thing(water_well_thing): response = client.get( "/observation/groundwater-level", - params={"thing_id": thing.id, "observed_property": "groundwater level"}, + params={ + "thing_id": water_well_thing.id, + "observed_property": "groundwater level", + }, ) assert response.status_code == 200 data = response.json() diff --git a/tests/test_sample.py b/tests/test_sample.py index d3d432413..35961c99b 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -53,12 +53,12 @@ def test_validate_sample_top_and_bottom(): # ============= Post tests for samples ============================================= -def test_add_sample(thing, sensor): +def test_add_sample(spring_thing, sensor): """ Test adding a sample. """ payload = { - "thing_id": thing.id, + "thing_id": spring_thing.id, "sample_type": "groundwater", "field_sample_id": "FS-1234567", "sample_date": "2025-01-01T00:00:00Z", @@ -96,12 +96,12 @@ def test_add_sample(thing, sensor): cleanup_post_test(Sample, data["id"]) -def test_409_add_sample_invalid_field_sample_id(sample, thing): +def test_409_add_sample_invalid_field_sample_id(sample, spring_thing): """ Test adding a sample with an invalid field_sample_id. """ payload = { - "thing_id": thing.id, + "thing_id": spring_thing.id, "sample_type": "groundwater", "field_sample_id": sample.field_sample_id, # This should already exist "sample_date": "2025-01-01T00:00:00Z", diff --git a/tests/test_search.py b/tests/test_search.py index 620c74cc2..e7619b0c9 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -22,14 +22,14 @@ from tests import client -def test_search_api(thing, contact, email, phone, address): +def test_search_api(water_well_thing, spring_thing, contact): response = client.get("/search", params={"q": "Test"}) assert response.status_code == 200 data = response.json() assert isinstance(data, dict) items = data.get("items") assert isinstance(items, list) - assert len(items) == 2 + assert len(items) == 3 @pytest.mark.skip(reason="This test is not working .") diff --git a/tests/test_thing.py b/tests/test_thing.py index a636db7c1..23baab08c 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -540,7 +540,6 @@ def test_get_things(water_well_thing, spring_thing): response = client.get("/thing") assert response.status_code == 200 data = response.json() - print(data) assert data["total"] == 2 assert data["items"][0]["id"] == water_well_thing.id From 4d7813ac8a910a1878e3ff77f777c17906494ec7 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 28 Aug 2025 08:11:45 -0600 Subject: [PATCH 16/21] refactor: let thing_type by set by request or string for the API it should always used the request for data transfers it can be set by a string --- api/thing.py | 4 ++-- services/thing_helper.py | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/api/thing.py b/api/thing.py index 048ba2921..81a047119 100644 --- a/api/thing.py +++ b/api/thing.py @@ -367,7 +367,7 @@ def create_well( Create a new water well in the database. """ try: - return add_thing(session, thing_data, request, user=user) + return add_thing(session=session, data=thing_data, request=request, user=user) except ProgrammingError as e: database_error_handler(thing_data, e) @@ -387,7 +387,7 @@ def create_spring( Create a new well in the database. """ try: - return add_thing(session, thing_data, request, user=user) + return add_thing(session=session, data=thing_data, request=request, user=user) except ProgrammingError as e: database_error_handler(thing_data, e) diff --git a/services/thing_helper.py b/services/thing_helper.py index 8f715b834..cfd6967da 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -106,9 +106,14 @@ def get_thing_of_a_thing_type_by_id(session: Session, request: Request, thing_id def add_thing( - session: Session, data: BaseModel | dict, request: Request, user: dict = None + session: Session, + data: BaseModel | dict, + user: dict = None, + request: Request | None = None, + thing_type: str | None = None, # to be used only for data transfers, not the API ) -> Base: - thing_type = get_thing_type_from_request(request) + if request is not None: + thing_type = get_thing_type_from_request(request) if isinstance(data, BaseModel): data = data.model_dump() From d0d5dc79c916347ef923f2bd808634abfa0fb83d Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 28 Aug 2025 12:57:16 -0600 Subject: [PATCH 17/21] feat: implement PATCH water well --- api/thing.py | 89 ++++++++++++-------------------- schemas/thing.py | 13 ++--- services/thing_helper.py | 15 ++++++ tests/test_thing.py | 107 ++++++++++++++++++++------------------- 4 files changed, 107 insertions(+), 117 deletions(-) diff --git a/api/thing.py b/api/thing.py index 81a047119..7b834eec6 100644 --- a/api/thing.py +++ b/api/thing.py @@ -16,7 +16,6 @@ from fastapi import APIRouter, Depends, Query, Request from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select -from sqlalchemy.orm import Session from sqlalchemy.exc import ProgrammingError from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_409_CONFLICT @@ -34,11 +33,8 @@ # no_permission_function, amp_editor_dependency, ) -from db.engine import get_db_session -from db.location import LocationThingAssociation, Location from db.thing import Thing, WellScreen from db.thing import ThingIdLink -from schemas.location import LocationResponse, UpdateLocation from schemas.thing import ( CreateThingIdLink, CreateWell, @@ -46,7 +42,7 @@ ThingResponse, WellResponse, WellScreenResponse, - UpdateThing, + UpdateSpring, UpdateWell, SpringResponse, CreateSpring, @@ -63,6 +59,7 @@ ) from services.thing_helper import ( add_thing, + patch_thing, add_well_screen, get_db_things, get_thing_of_a_thing_type_by_id, @@ -416,69 +413,45 @@ def create_wellscreen( # PATCH ======================================================================== -@router.patch("/{thing_id}", summary="Update thing") -def update_thing( - thing_id: int, - thing_data: UpdateWell | UpdateThing, - user: editor_dependency, - session: Session = Depends(get_db_session), -) -> ThingResponse: - """ - Update an existing thing by ID. - """ - - return model_patcher(session, Thing, thing_id, thing_data, user=user) - - -@router.patch("/{thing_id}/location", summary="Update thing location") -def update_thing_location( - thing_id: int, - location_data: UpdateLocation, - session: session_dependency, - user: editor_dependency, -) -> LocationResponse: - """ - Update the location of an existing thing by ID. - """ - - # get active location associated with the thing - location_id = session.execute( - select(LocationThingAssociation.location_id) - .where(LocationThingAssociation.thing_id == thing_id) - .order_by(LocationThingAssociation.effective_start.desc()) - ).scalar_one_or_none() - - return model_patcher(session, Location, location_id, location_data, user=user) - - -@router.patch("/{thing_id}", summary="Update thing") -def update_thing( +@router.patch( + "/water-well/{thing_id}", + summary="Update well by parent thing ID", + status_code=HTTP_200_OK, +) +async def update_water_well( thing_id: int, - thing_data: UpdateThing, + thing_data: UpdateWell, session: session_dependency, - user: editor_dependency, -) -> ThingResponse: + user: amp_editor_dependency, + request: Request, +) -> WellResponse: """ Update an existing well by ID. """ - return model_patcher(session, Thing, thing_id, thing_data, user=user) + return patch_thing(session, request, thing_id, thing_data, user=user) -@router.patch("/well/{thing_id}", summary="Update well by parent thing ID") -def update_thing( +@router.patch( + "/spring/{thing_id}", + summary="Update spring by parent thing ID", + status_code=HTTP_200_OK, +) +async def update_spring( thing_id: int, - thing_data: UpdateWell, + spring_data: UpdateSpring, session: session_dependency, user: amp_editor_dependency, -) -> WellResponse: +) -> SpringResponse: """ - Update an existing well by ID. + Update an existing spring by ID. """ - return model_patcher(session, Thing, thing_id, thing_data, user=user) + return model_patcher(session, Thing, thing_id, spring_data, user=user) -@router.patch("/id-link/{link_id}", summary="Update thing link by ID") -def update_thing_id_link( +@router.patch( + "/id-link/{link_id}", summary="Update thing link by ID", status_code=HTTP_200_OK +) +async def update_thing_id_link( link_id: int, link_data: UpdateThingIdLink, session: session_dependency, @@ -487,8 +460,12 @@ def update_thing_id_link( return model_patcher(session, ThingIdLink, link_id, link_data, user=user) -@router.patch("/well-screen/{well_screen_id}", summary="Update Well Screen by ID") -def update_well_screen( +@router.patch( + "/well-screen/{well_screen_id}", + summary="Update Well Screen by ID", + status_code=HTTP_200_OK, +) +async def update_well_screen( well_screen_id: int, well_screen_data: UpdateWellScreen, session: session_dependency, diff --git a/schemas/thing.py b/schemas/thing.py index 45e972383..64baa3219 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -197,25 +197,20 @@ class UpdateThing(BaseModel): Schema for updating a thing. """ - # location_id: int | None = None # Optional location ID for the thing name: str | None = None # Optional name for the thing release_status: str | None = None - # group: str | None = None # Optional group for the thing - # description: str | None = None # Optional description of the thing - # tags: list[str] | None = None # Optional tags associated with the thing class UpdateWell(UpdateThing): - # location_id: int | None = None # Optional location ID for the well - # name: str | None = None # Optional name for the well - # api_id: str | None = None - # ose_pod_id: str | None = None + well_type: str | None = None well_depth: float | None = None # in feet hole_depth: float | None = None # in feet well_construction_notes: str | None = None - # group: str | None = None # Optional group for the well + +class UpdateSpring(UpdateThing): + spring_type: str | None = None class UpdateThingIdLink(BaseModel): diff --git a/services/thing_helper.py b/services/thing_helper.py index cfd6967da..8ca1b750b 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -23,6 +23,7 @@ from db import LocationThingAssociation, Thing, Base, Location, WellScreen from db.group import GroupThingAssociation from services.audit_helper import audit_add +from services.crud_helper import model_patcher from services.exceptions_helper import PydanticStyleException from services.geospatial_helper import make_within_wkt from services.query_helper import make_query, order_sort_filter, simple_get_by_id @@ -183,4 +184,18 @@ def add_well_screen(session, well_screen_data: BaseModel, user: dict = None): return well_screen +def patch_thing( + session: Session, + request: Request, + thing_id: int, + payload: BaseModel, + user: dict, +): + thing = simple_get_by_id(session, Thing, thing_id) + + verify_thing_type_correspondence(thing, request) + + return model_patcher(session, Thing, thing_id, payload, user) + + # ============= EOF ============================================= diff --git a/tests/test_thing.py b/tests/test_thing.py index 23baab08c..615cf9361 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -16,7 +16,7 @@ import pytest from db import Thing, WellScreen, ThingIdLink -from tests import client, override_authentication, cleanup_post_test +from tests import client, override_authentication, cleanup_post_test, cleanup_patch_test from main import app from core.dependencies import ( admin_function, @@ -648,60 +648,63 @@ def test_get_thing_by_id_404_not_found(water_well_thing): # assert isinstance(item["phones"], list) -# Patch tests -# def test_patch_thing_link(): -# response = client.patch( -# "/thing/id-link/1", -# json={ -# "relation": "same_as", -# "alternate_id": "USGS-43211234", -# "alternate_organization": "USGS", -# }, -# ) -# assert response.status_code == 200 -# data = response.json() -# assert data["id"] == 1 -# assert data["relation"] == "same_as" -# assert data["alternate_id"] == "USGS-43211234" -# assert data["alternate_organization"] == "USGS" - - -# def test_patch_thing(): -# response = client.patch( -# "/thing/1", -# json={ -# "name": "Updated Test Thing", -# }, -# ) -# assert response.status_code == 200 -# data = response.json() -# assert data["id"] == 1 -# assert data["name"] == "Updated Test Thing" +# PATCH tests ================================================================== -# def test_patch_well(): -# response = client.patch( -# "/thing/1", -# json={ -# "well_depth": 150.0, -# }, -# ) -# assert response.status_code == 200 -# data = response.json() -# assert data["id"] == 1 -# assert data["well_depth"] == 150.0 +def test_patch_water_well(water_well_thing): + payload = { + "name": "patched water well", + "release_status": "provisional", + "well_type": "Injection", + "well_depth": 20, + "hole_depth": 40, + "well_construction_notes": "patched well construction notes", + } + response = client.patch(f"/thing/water-well/{water_well_thing.id}", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["name"] == payload["name"] + assert data["release_status"] == payload["release_status"] + assert data["well_type"] == payload["well_type"] + assert data["well_depth"] == payload["well_depth"] + assert data["hole_depth"] == payload["hole_depth"] + assert data["well_construction_notes"] == payload["well_construction_notes"] + cleanup_patch_test(Thing, payload, water_well_thing) -# def test_patch_thing_location(): -# response = client.patch( -# "/thing/4/location", -# json={ -# "point": "POINT(-106.61 35.08)", -# }, -# ) -# assert response.status_code == 200 -# data = response.json() -# assert data["point"] == "POINT (-106.61 35.08)" + +def test_patch_water_well_404_not_found(): + bad_id = 99999 + payload = { + "name": "patched water well", + "release_status": "provisional", + "well_type": "Injection", + "well_depth": 20, + "hole_depth": 40, + "well_construction_notes": "patched well construction notes", + } + response = client.patch(f"/thing/water-well/{bad_id}", json=payload) + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Thing with ID {bad_id} not found." -# ============= EOF ============================================= +def test_patch_water_well_404_wrong_type(spring_thing): + payload = { + "name": "patched water well", + "release_status": "provisional", + "well_type": "Injection", + "well_depth": 20, + "hole_depth": 40, + "well_construction_notes": "patched well construction notes", + } + response = client.patch(f"/thing/water-well/{spring_thing.id}", json=payload) + assert response.status_code == 404 + data = response.json() + assert ( + data["detail"][0]["msg"] + == f"Thing with ID {spring_thing.id} is not a water well Thing. It is a spring Thing." + ) + assert data["detail"][0]["loc"] == ["path", "thing_id"] + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"thing_id": spring_thing.id} From 380feeec95bacd278a1cc209211335124c0d51b6 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 28 Aug 2025 13:04:09 -0600 Subject: [PATCH 18/21] feat: implement PATCH spring thing --- api/thing.py | 5 +++-- tests/test_thing.py | 47 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/api/thing.py b/api/thing.py index 7b834eec6..8b52fef31 100644 --- a/api/thing.py +++ b/api/thing.py @@ -438,14 +438,15 @@ async def update_water_well( ) async def update_spring( thing_id: int, - spring_data: UpdateSpring, + thing_data: UpdateSpring, session: session_dependency, user: amp_editor_dependency, + request: Request, ) -> SpringResponse: """ Update an existing spring by ID. """ - return model_patcher(session, Thing, thing_id, spring_data, user=user) + return patch_thing(session, request, thing_id, thing_data, user=user) @router.patch( diff --git a/tests/test_thing.py b/tests/test_thing.py index 615cf9361..156f5c480 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -708,3 +708,50 @@ def test_patch_water_well_404_wrong_type(spring_thing): assert data["detail"][0]["loc"] == ["path", "thing_id"] assert data["detail"][0]["type"] == "value_error" assert data["detail"][0]["input"] == {"thing_id": spring_thing.id} + + +def test_patch_spring(spring_thing): + payload = { + "name": "patched spring", + "release_status": "private", + "spring_type": "Mineral", + } + response = client.patch(f"/thing/spring/{spring_thing.id}", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["name"] == payload["name"] + assert data["release_status"] == payload["release_status"] + assert data["spring_type"] == payload["spring_type"] + + cleanup_patch_test(Thing, payload, spring_thing) + + +def test_patch_spring_404_not_found(spring_thing): + bad_id = 99999 + payload = { + "name": "patched spring", + "release_status": "private", + "spring_type": "Mineral", + } + response = client.patch(f"/thing/spring/{bad_id}", json=payload) + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Thing with ID {bad_id} not found." + + +def test_patch_spring_404_wrong_type(water_well_thing): + payload = { + "name": "patched spring", + "release_status": "private", + "spring_type": "Mineral", + } + response = client.patch(f"/thing/spring/{water_well_thing.id}", json=payload) + assert response.status_code == 404 + data = response.json() + assert ( + data["detail"][0]["msg"] + == f"Thing with ID {water_well_thing.id} is not a spring Thing. It is a water well Thing." + ) + assert data["detail"][0]["loc"] == ["path", "thing_id"] + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"thing_id": water_well_thing.id} From 1fa14d69a42e3dc8c6950a2aabc42a4a73a2cfcf Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 28 Aug 2025 13:13:38 -0600 Subject: [PATCH 19/21] feat: implement PATCH thing id link --- schemas/thing.py | 1 - tests/test_thing.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/schemas/thing.py b/schemas/thing.py index 64baa3219..46cfefc8d 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -217,7 +217,6 @@ class UpdateThingIdLink(BaseModel): alternate_organization: str | None = None alternate_id: str | None = None relation: str | None = None - thing_id: int | None = None class UpdateWellScreen(BaseModel): diff --git a/tests/test_thing.py b/tests/test_thing.py index 156f5c480..cddd2278c 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -755,3 +755,32 @@ def test_patch_spring_404_wrong_type(water_well_thing): assert data["detail"][0]["loc"] == ["path", "thing_id"] assert data["detail"][0]["type"] == "value_error" assert data["detail"][0]["input"] == {"thing_id": water_well_thing.id} + + +def test_patch_thing_id_link(thing_id_link): + payload = { + "relation": "related_to", + "alternate_id": "9999-8888", + "alternate_organization": "TWDB", + } + response = client.patch(f"/thing/id-link/{thing_id_link.id}", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["relation"] == payload["relation"] + assert data["alternate_id"] == payload["alternate_id"] + assert data["alternate_organization"] == payload["alternate_organization"] + + cleanup_patch_test(ThingIdLink, payload, thing_id_link) + + +def test_patch_thing_id_link_404_not_found(): + bad_id = 9999 + payload = { + "relation": "related_to", + "alternate_id": "9999-8888", + "alternate_organization": "EPA", + } + response = client.patch(f"/thing/id-link/{bad_id}", json=payload) + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"ThingIdLink with ID {bad_id} not found." From bfefba8e3f92b0671038af13044c7e7babf9d56c Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 28 Aug 2025 13:23:23 -0600 Subject: [PATCH 20/21] feat: implement PATCH well screen --- tests/test_thing.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_thing.py b/tests/test_thing.py index cddd2278c..95873e765 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -784,3 +784,35 @@ def test_patch_thing_id_link_404_not_found(): assert response.status_code == 404 data = response.json() assert data["detail"] == f"ThingIdLink with ID {bad_id} not found." + + +def test_patch_well_screen(well_screen): + payload = { + "screen_depth_bottom": 2, + "screen_depth_top": 1, + "screen_description": "patched screen description", + "screen_type": "Steel", + } + response = client.patch(f"/thing/well-screen/{well_screen.id}", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["screen_depth_bottom"] == payload["screen_depth_bottom"] + assert data["screen_depth_top"] == payload["screen_depth_top"] + assert data["screen_description"] == payload["screen_description"] + assert data["screen_type"] == data["screen_type"] + + cleanup_patch_test(WellScreen, payload, well_screen) + + +def test_patch_well_screen_404_not_found(): + bad_id = 9999 + payload = { + "screen_depth_bottom": 2, + "screen_depth_top": 1, + "screen_desciption": "patched screen description", + "screen_type": "Steel", + } + response = client.patch(f"/thing/well-screen/{bad_id}", json=payload) + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"WellScreen with ID {bad_id} not found." From add9f717f557180692be97d59724e2a330adfe62 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 28 Aug 2025 13:32:18 -0600 Subject: [PATCH 21/21] feat: implement DELETE for all thing models --- api/thing.py | 58 +++++++++++++++++++++++++++++++++++++++++-- tests/conftest.py | 56 ++++++++++++++++++++++++++++++++++++++++++ tests/test_thing.py | 60 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 2 deletions(-) diff --git a/api/thing.py b/api/thing.py index 8b52fef31..58dcfaa66 100644 --- a/api/thing.py +++ b/api/thing.py @@ -17,7 +17,12 @@ from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select from sqlalchemy.exc import ProgrammingError -from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_409_CONFLICT +from starlette.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_409_CONFLICT, +) from api.pagination import CustomPage from core.dependencies import ( @@ -50,7 +55,7 @@ UpdateThingIdLink, UpdateWellScreen, ) -from services.crud_helper import model_patcher, model_adder +from services.crud_helper import model_patcher, model_adder, model_deleter from services.exceptions_helper import PydanticStyleException from services.query_helper import ( simple_get_by_id, @@ -479,4 +484,53 @@ async def update_well_screen( ) +# DELETE ======================================================================= + + +@router.delete( + "/{thing_id}", summary="Delete thing by ID", status_code=HTTP_204_NO_CONTENT +) +async def delete_thing( + thing_id: int, + session: session_dependency, + user: editor_dependency, +) -> None: + """ + Delete a thing by ID. + """ + return model_deleter(session, Thing, thing_id) + + +@router.delete( + "/well-screen/{well_screen_id}", + summary="Delete well screen by ID", + status_code=HTTP_204_NO_CONTENT, +) +async def delete_well_screen( + well_screen_id: int, + session: session_dependency, + user: editor_dependency, +) -> None: + """ + Delete a well screen by ID. + """ + return model_deleter(session, WellScreen, well_screen_id) + + +@router.delete( + "/id-link/{link_id}", + summary="Delete thing link by ID", + status_code=HTTP_204_NO_CONTENT, +) +async def delete_thing_id_link( + link_id: int, + session: session_dependency, + user: editor_dependency, +) -> None: + """ + Delete a thing link by ID. + """ + return model_deleter(session, ThingIdLink, link_id) + + # ============= EOF ============================================= diff --git a/tests/conftest.py b/tests/conftest.py index eaa2f762b..700f8637f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -71,6 +71,23 @@ def well_screen(water_well_thing): yield screen +@pytest.fixture(scope="function") +def second_well_screen(water_well_thing): + with session_ctx() as session: + screen = WellScreen( + thing_id=water_well_thing.id, + screen_depth_top=30.0, + screen_depth_bottom=40.0, + screen_type="PVC", + screen_description="Test well screen description", + ) + session.add(screen) + session.commit() + yield screen + session.delete(screen) + session.commit() + + @pytest.fixture(scope="session") def thing_id_link(water_well_thing): with session_ctx() as session: @@ -85,6 +102,22 @@ def thing_id_link(water_well_thing): yield id_link +@pytest.fixture(scope="function") +def second_thing_id_link(water_well_thing): + with session_ctx() as session: + id_link = ThingIdLink( + thing_id=water_well_thing.id, + relation="same_as", + alternate_id="4321-1234", + alternate_organization="USGS", + ) + session.add(id_link) + session.commit() + yield id_link + session.delete(id_link) + session.commit() + + @pytest.fixture(scope="session") def spring_thing(location): with session_ctx() as session: @@ -106,6 +139,29 @@ def spring_thing(location): yield spring +@pytest.fixture(scope="function") +def second_spring_thing(location): + with session_ctx() as session: + spring = Thing( + name="Second Test Spring", + thing_type="spring", + release_status="draft", + spring_type="Artesian", + ) + session.add(spring) + session.commit() + session.refresh(spring) + + assoc = LocationThingAssociation() + assoc.location_id = location.id + assoc.thing_id = spring.id + session.add(assoc) + session.commit() + yield spring + session.delete(spring) + session.commit() + + @pytest.fixture(scope="session") def sensor(): with session_ctx() as session: diff --git a/tests/test_thing.py b/tests/test_thing.py index 95873e765..a37ab3713 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -816,3 +816,63 @@ def test_patch_well_screen_404_not_found(): assert response.status_code == 404 data = response.json() assert data["detail"] == f"WellScreen with ID {bad_id} not found." + + +# DELETE tests ================================================================= + + +def test_delete_thing(second_spring_thing): + response = client.delete(f"/thing/{second_spring_thing.id}") + assert response.status_code == 204 + + # Verify the thing is deleted + response = client.get(f"/thing/spring/{second_spring_thing.id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Thing with ID {second_spring_thing.id} not found." + + +def test_delete_thing_404_not_found(): + bad_id = 9999 + response = client.delete(f"/thing/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Thing with ID {bad_id} not found." + + +def test_delete_well_screen(second_well_screen): + response = client.delete(f"/thing/well-screen/{second_well_screen.id}") + assert response.status_code == 204 + + # Verify the well screen is deleted + response = client.get(f"/thing/well-screen/{second_well_screen.id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"WellScreen with ID {second_well_screen.id} not found." + + +def test_delete_well_screen_404_not_found(): + bad_id = 9999 + response = client.delete(f"/thing/well-screen/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"WellScreen with ID {bad_id} not found." + + +def test_delete_thing_id_link(second_thing_id_link): + response = client.delete(f"/thing/id-link/{second_thing_id_link.id}") + assert response.status_code == 204 + + # Verify the thing ID link is deleted + response = client.get(f"/thing/id-link/{second_thing_id_link.id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"ThingIdLink with ID {second_thing_id_link.id} not found." + + +def test_delete_thing_id_link_404_not_found(second_thing_id_link): + bad_id = 9999 + response = client.delete(f"/thing/id-link/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"ThingIdLink with ID {bad_id} not found."