From 0e9b5af5dcd8ee9750fe3ddec48594435b5e1621 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 28 Mar 2026 17:46:48 -0600 Subject: [PATCH] feat: enhance GCS upload handling with async support and improved error logging --- api/thing.py | 24 +++++++ schemas/well_details.py | 22 +++++++ services/well_details_helper.py | 110 ++++++++++++++++++++++++++++++++ tests/test_thing.py | 108 +++++++++++++++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 schemas/well_details.py create mode 100644 services/well_details_helper.py diff --git a/api/thing.py b/api/thing.py index 367237f58..4a8be334e 100644 --- a/api/thing.py +++ b/api/thing.py @@ -51,6 +51,7 @@ UpdateThingIdLink, UpdateWellScreen, ) +from schemas.well_details import WellDetailsResponse from services.crud_helper import model_patcher, model_adder, model_deleter from services.exceptions_helper import PydanticStyleException from services.lexicon_helper import get_terms_by_category @@ -68,6 +69,7 @@ modify_well_descriptor_tables, WELL_DESCRIPTOR_MODEL_MAP, ) +from services.well_details_helper import get_well_details_payload router = APIRouter(prefix="/thing", tags=["thing"]) @@ -177,6 +179,28 @@ 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}/details", + summary="Get water well details payload", + status_code=HTTP_200_OK, +) +async def get_well_details( + user: viewer_dependency, + thing_id: int, + session: session_dependency, + request: Request, +) -> WellDetailsResponse: + """ + Retrieve the consolidated payload needed to render the well details page. + Hydrograph series and map layer loading are intentionally handled separately. + """ + return get_well_details_payload( + session=session, + request=request, + thing_id=thing_id, + ) + + @router.get( "/water-well/{thing_id}/well-screen", summary="Get well screens by water well ID", diff --git a/schemas/well_details.py b/schemas/well_details.py new file mode 100644 index 000000000..fa94f1548 --- /dev/null +++ b/schemas/well_details.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, ConfigDict, Field + +from schemas.contact import ContactResponse +from schemas.deployment import DeploymentResponse +from schemas.observation import GroundwaterLevelObservationResponse +from schemas.sample import SampleResponse +from schemas.sensor import SensorResponse +from schemas.thing import WellResponse, WellScreenResponse + + +class WellDetailsResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + well: WellResponse + contacts: list[ContactResponse] = Field(default_factory=list) + sensors: list[SensorResponse] = Field(default_factory=list) + deployments: list[DeploymentResponse] = Field(default_factory=list) + well_screens: list[WellScreenResponse] = Field(default_factory=list) + recent_groundwater_level_observations: list[GroundwaterLevelObservationResponse] = ( + Field(default_factory=list) + ) + latest_field_event_sample: SampleResponse | None = None diff --git a/services/well_details_helper.py b/services/well_details_helper.py new file mode 100644 index 000000000..25f100880 --- /dev/null +++ b/services/well_details_helper.py @@ -0,0 +1,110 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session, joinedload, selectinload + +from db import ( + Contact, + Deployment, + FieldActivity, + FieldEvent, + FieldEventParticipant, + Observation, + Parameter, + Sample, + Sensor, + ThingContactAssociation, + WellScreen, +) +from services.thing_helper import get_thing_of_a_thing_type_by_id + + +def get_well_details_payload( + session: Session, + request, + thing_id: int, + recent_observation_limit: int = 100, +): + well = get_thing_of_a_thing_type_by_id(session, request, thing_id) + + contacts = session.scalars( + select(Contact) + .join(ThingContactAssociation) + .where(ThingContactAssociation.thing_id == well.id) + .options( + selectinload(Contact.emails), + selectinload(Contact.phones), + selectinload(Contact.addresses), + selectinload(Contact.incomplete_nma_phones), + selectinload(Contact.thing_associations).selectinload( + ThingContactAssociation.thing + ), + ) + .order_by(Contact.id) + ).all() + + sensors = session.scalars( + select(Sensor) + .join(Deployment) + .where(Deployment.thing_id == well.id) + .distinct() + .order_by(Sensor.id) + ).all() + + deployments = session.scalars( + select(Deployment) + .where(Deployment.thing_id == well.id) + .options(selectinload(Deployment.sensor)) + .order_by(Deployment.installation_date.desc(), Deployment.id.desc()) + ).all() + + well_screens = session.scalars( + select(WellScreen) + .where(WellScreen.thing_id == well.id) + .order_by(WellScreen.screen_depth_top.asc(), WellScreen.id.asc()) + ).all() + + groundwater_parameter_id = ( + session.query(Parameter) + .filter(Parameter.parameter_name == "groundwater level") + .one() + .id + ) + + recent_groundwater_level_observations = session.scalars( + select(Observation) + .join(Sample) + .join(FieldActivity) + .join(FieldEvent) + .where( + FieldEvent.thing_id == well.id, + Observation.parameter_id == groundwater_parameter_id, + ) + .options(selectinload(Observation.parameter)) + .order_by(Observation.observation_datetime.desc(), Observation.id.desc()) + .limit(recent_observation_limit) + ).all() + + latest_field_event_sample = None + if recent_groundwater_level_observations: + latest_sample_id = recent_groundwater_level_observations[0].sample_id + latest_field_event_sample = session.scalar( + select(Sample) + .where(Sample.id == latest_sample_id) + .options( + joinedload(Sample.field_activity) + .joinedload(FieldActivity.field_event) + .joinedload(FieldEvent.thing), + joinedload(Sample.field_event_participant).joinedload( + FieldEventParticipant.participant + ), + ) + ) + + return { + "well": well, + "contacts": contacts, + "sensors": sensors, + "deployments": deployments, + "well_screens": well_screens, + "recent_groundwater_level_observations": recent_groundwater_level_observations, + "latest_field_event_sample": latest_field_event_sample, + } diff --git a/tests/test_thing.py b/tests/test_thing.py index c9dd7d168..10f8e05fd 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -560,6 +560,114 @@ def test_get_water_well_by_id(water_well_thing, location): assert data["current_location"] == expected_location +def test_get_water_well_details_payload( + water_well_thing, + field_event, + contact, + email, + phone, + address, + sensor, + sensor_to_water_well_thing_deployment, + thing_id_link, + well_screen, + groundwater_level_sample, + groundwater_level_observation, +): + response = client.get(f"/thing/water-well/{water_well_thing.id}/details") + + assert response.status_code == 200 + data = response.json() + + assert data["well"]["id"] == water_well_thing.id + assert data["well"]["alternate_ids"][0]["id"] == thing_id_link.id + assert data["contacts"][0]["id"] == contact.id + assert data["contacts"][0]["emails"][0]["id"] == email.id + assert data["contacts"][0]["phones"][0]["id"] == phone.id + assert data["contacts"][0]["addresses"][0]["id"] == address.id + assert data["sensors"][0]["id"] == sensor.id + assert data["deployments"][0]["id"] == sensor_to_water_well_thing_deployment.id + assert data["deployments"][0]["sensor"]["id"] == sensor.id + assert data["well_screens"][0]["id"] == well_screen.id + assert ( + data["recent_groundwater_level_observations"][0]["id"] + == groundwater_level_observation.id + ) + assert data["latest_field_event_sample"]["id"] == groundwater_level_sample.id + assert data["latest_field_event_sample"]["field_event"]["id"] == field_event.id + assert data["latest_field_event_sample"]["contact"]["id"] == contact.id + + +def test_get_water_well_details_payload_uses_latest_observation_sample( + water_well_thing, + groundwater_level_sample, + groundwater_level_observation, + field_event_participant, + sensor, +): + from db import Observation, Sample + + with session_ctx() as session: + later_sample = Sample( + field_activity_id=groundwater_level_sample.field_activity_id, + field_event_participant_id=field_event_participant.id, + sample_date="2025-01-02T12:00:00Z", + sample_name="later groundwater level sample", + sample_matrix="water", + sample_method="Steel-tape measurement", + qc_type="Normal", + notes="later sample", + release_status="draft", + ) + session.add(later_sample) + session.commit() + session.refresh(later_sample) + + later_observation = Observation( + observation_datetime="2025-01-02T00:04:00Z", + sample_id=later_sample.id, + sensor_id=sensor.id, + parameter_id=groundwater_level_observation.parameter_id, + release_status="draft", + value=9.0, + unit="ft", + measuring_point_height=5.0, + groundwater_level_reason="Water level not affected", + ) + session.add(later_observation) + session.commit() + session.refresh(later_observation) + later_sample_id = later_sample.id + later_observation_id = later_observation.id + + try: + response = client.get(f"/thing/water-well/{water_well_thing.id}/details") + + assert response.status_code == 200 + data = response.json() + assert data["latest_field_event_sample"]["id"] == later_sample_id + assert ( + data["recent_groundwater_level_observations"][0]["id"] + == later_observation_id + ) + finally: + with session_ctx() as session: + later_observation = session.get(Observation, later_observation_id) + if later_observation is not None: + session.delete(later_observation) + later_sample = session.get(Sample, later_sample_id) + if later_sample is not None: + session.delete(later_sample) + session.commit() + + +def test_get_water_well_details_payload_404_not_found(): + response = client.get("/thing/water-well/999999/details") + + assert response.status_code == 404 + assert response.json()["detail"] == "Thing with ID 999999 not found." + + 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}")