diff --git a/.gitignore b/.gitignore index a6a2981b7..92ab7e91d 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ cli/logs # deployment files app.yaml docs/ + +#Codex +.codex \ No newline at end of file diff --git a/admin/__init__.py b/admin/__init__.py index ece0358e6..2816d3891 100644 --- a/admin/__init__.py +++ b/admin/__init__.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -Starlette Admin package for NMSampleLocations. +Starlette Admin package for OcotilloAPI. Provides web-based administrative interface for managing database records. """ diff --git a/admin/auth.py b/admin/auth.py index 334588c32..903068ab7 100644 --- a/admin/auth.py +++ b/admin/auth.py @@ -17,26 +17,22 @@ Admin authentication provider integrating with existing Authentik OIDC auth. This module provides a Starlette Admin AuthProvider that integrates with the -existing Authentik-based authentication system used by the NMSampleLocations API. +existing Authentik-based authentication system used by the OcotilloAPI API. """ +import base64 +import hashlib import os import secrets -from typing import Optional -from urllib.parse import urlencode - -import hashlib -import base64 - +from core.permissions import _get_token_payload, verify_token from dataclasses import dataclass -from typing import List - from starlette.requests import Request from starlette.responses import RedirectResponse from starlette_admin.auth import AdminUser, AuthProvider from starlette_admin.exceptions import LoginFailed - -from core.permissions import _get_token_payload, verify_token +from typing import List +from typing import Optional +from urllib.parse import urlencode @dataclass diff --git a/admin/config.py b/admin/config.py index 1c3bb14f0..e559fef92 100644 --- a/admin/config.py +++ b/admin/config.py @@ -16,11 +16,9 @@ """ Starlette Admin configuration and initialization. -This module creates and configures the admin interface for NMSampleLocations. +This module creates and configures the admin interface for OcotilloAPI. """ -from starlette_admin.contrib.sqla import Admin - from admin.auth import NMSampleLocationsAuthProvider from admin.views import ( AquiferSystemAdmin, @@ -93,6 +91,7 @@ from db.sensor import Sensor from db.thing import Thing from db.transducer import TransducerObservation +from starlette_admin.contrib.sqla import Admin def create_admin(app): diff --git a/admin/views/__init__.py b/admin/views/__init__.py index 285d5ef5f..c8d0f5ad2 100644 --- a/admin/views/__init__.py +++ b/admin/views/__init__.py @@ -14,15 +14,15 @@ # limitations under the License. # =============================================================================== """ -Admin views package for NMSampleLocations. +Admin views package for OcotilloAPI. Provides MS Access-like interface for CRUD operations on database models. """ -from admin.views.asset import AssetAdmin -from admin.views.associated_data import AssociatedDataAdmin from admin.views.aquifer_system import AquiferSystemAdmin from admin.views.aquifer_type import AquiferTypeAdmin +from admin.views.asset import AssetAdmin +from admin.views.associated_data import AssociatedDataAdmin from admin.views.chemistry_sampleinfo import ChemistrySampleInfoAdmin from admin.views.contact import ContactAdmin from admin.views.data_provenance import DataProvenanceAdmin @@ -55,8 +55,8 @@ from admin.views.waterlevelscontinuous_pressure_daily import ( WaterLevelsContinuousPressureDailyAdmin, ) -from admin.views.weather_photos import WeatherPhotosAdmin from admin.views.weather_data import WeatherDataAdmin +from admin.views.weather_photos import WeatherPhotosAdmin __all__ = [ "AssetAdmin", diff --git a/admin/views/aquifer_system.py b/admin/views/aquifer_system.py index 85f79ddc9..9b384e098 100644 --- a/admin/views/aquifer_system.py +++ b/admin/views/aquifer_system.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -AquiferSystemAdmin view for NMSampleLocations. +AquiferSystemAdmin view for OcotilloAPI. """ from admin.fields import WKTField diff --git a/admin/views/aquifer_type.py b/admin/views/aquifer_type.py index 41281f8b6..ad319b6d3 100644 --- a/admin/views/aquifer_type.py +++ b/admin/views/aquifer_type.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -AquiferTypeAdmin view for NMSampleLocations. +AquiferTypeAdmin view for OcotilloAPI. """ from admin.views.base import OcotilloModelView diff --git a/admin/views/asset.py b/admin/views/asset.py index 7a1a5e96b..acec3bb80 100644 --- a/admin/views/asset.py +++ b/admin/views/asset.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -AssetAdmin view for NMSampleLocations. +AssetAdmin view for OcotilloAPI. Provides MS Access-like interface for CRUD operations on Asset model. """ diff --git a/admin/views/contact.py b/admin/views/contact.py index 7614687c0..36bea8ee4 100644 --- a/admin/views/contact.py +++ b/admin/views/contact.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -ContactAdmin view for NMSampleLocations. +ContactAdmin view for OcotilloAPI. Provides MS Access-like interface for CRUD operations on Contact (Owners) model. """ diff --git a/admin/views/data_provenance.py b/admin/views/data_provenance.py index 4f313953b..c1a91551f 100644 --- a/admin/views/data_provenance.py +++ b/admin/views/data_provenance.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -DataProvenanceAdmin view for NMSampleLocations. +DataProvenanceAdmin view for OcotilloAPI. """ from admin.views.base import OcotilloModelView diff --git a/admin/views/deployment.py b/admin/views/deployment.py index 511b69356..ccdf535da 100644 --- a/admin/views/deployment.py +++ b/admin/views/deployment.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -DeploymentAdmin view for NMSampleLocations. +DeploymentAdmin view for OcotilloAPI. Provides MS Access-like interface for CRUD operations on Deployment model. """ diff --git a/admin/views/field.py b/admin/views/field.py index 7d10598d0..43a7b2cb5 100644 --- a/admin/views/field.py +++ b/admin/views/field.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -Field admin views for NMSampleLocations. +Field admin views for OcotilloAPI. """ from admin.views.base import OcotilloModelView diff --git a/admin/views/geologic_formation.py b/admin/views/geologic_formation.py index 8e8803046..bb6212026 100644 --- a/admin/views/geologic_formation.py +++ b/admin/views/geologic_formation.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -GeologicFormationAdmin view for NMSampleLocations. +GeologicFormationAdmin view for OcotilloAPI. """ from admin.fields import WKTField diff --git a/admin/views/group.py b/admin/views/group.py index ddf9b0a83..f06a9ab76 100644 --- a/admin/views/group.py +++ b/admin/views/group.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -GroupAdmin view for NMSampleLocations. +GroupAdmin view for OcotilloAPI. """ from admin.fields import WKTField diff --git a/admin/views/lexicon.py b/admin/views/lexicon.py index 900a22c12..57cafa6a5 100644 --- a/admin/views/lexicon.py +++ b/admin/views/lexicon.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -Lexicon admin views for NMSampleLocations. +Lexicon admin views for OcotilloAPI. """ from admin.views.base import OcotilloModelView diff --git a/admin/views/location.py b/admin/views/location.py index 8921eec59..2ec2f2616 100644 --- a/admin/views/location.py +++ b/admin/views/location.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -LocationAdmin view for NMSampleLocations. +LocationAdmin view for OcotilloAPI. Provides MS Access-like interface for CRUD operations on Location model. """ diff --git a/admin/views/notes.py b/admin/views/notes.py index 2ce0f9191..6be42f912 100644 --- a/admin/views/notes.py +++ b/admin/views/notes.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -NotesAdmin view for NMSampleLocations. +NotesAdmin view for OcotilloAPI. """ from admin.views.base import OcotilloModelView diff --git a/admin/views/observation.py b/admin/views/observation.py index 3c5e8c4d6..d2e206e36 100644 --- a/admin/views/observation.py +++ b/admin/views/observation.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -ObservationAdmin view for NMSampleLocations. +ObservationAdmin view for OcotilloAPI. Provides MS Access-like interface for CRUD operations on Observation (Water Levels) model. """ diff --git a/admin/views/parameter.py b/admin/views/parameter.py index 3c9eed502..50eb674a8 100644 --- a/admin/views/parameter.py +++ b/admin/views/parameter.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -ParameterAdmin view for NMSampleLocations. +ParameterAdmin view for OcotilloAPI. """ from admin.views.base import OcotilloModelView diff --git a/admin/views/sample.py b/admin/views/sample.py index 3617fc882..b5247a913 100644 --- a/admin/views/sample.py +++ b/admin/views/sample.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -SampleAdmin view for NMSampleLocations. +SampleAdmin view for OcotilloAPI. """ from admin.views.base import OcotilloModelView diff --git a/admin/views/sensor.py b/admin/views/sensor.py index 9f81a338b..28d41e44e 100644 --- a/admin/views/sensor.py +++ b/admin/views/sensor.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -SensorAdmin view for NMSampleLocations. +SensorAdmin view for OcotilloAPI. Provides MS Access-like interface for CRUD operations on Sensor (Equipment) model. """ diff --git a/admin/views/surface_water.py b/admin/views/surface_water.py index ede5522c0..be6da860d 100644 --- a/admin/views/surface_water.py +++ b/admin/views/surface_water.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -SurfaceWaterDataAdmin view for NMSampleLocations. +SurfaceWaterDataAdmin view for OcotilloAPI. """ from admin.views.base import OcotilloModelView diff --git a/admin/views/thing.py b/admin/views/thing.py index d74e0b9df..da6d7acbb 100644 --- a/admin/views/thing.py +++ b/admin/views/thing.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -ThingAdmin view for NMSampleLocations. +ThingAdmin view for OcotilloAPI. Provides MS Access-like interface for CRUD operations on Thing (Wells/Springs) model. """ diff --git a/api/thing.py b/api/thing.py index 8ba57c76a..6beb474e6 100644 --- a/api/thing.py +++ b/api/thing.py @@ -197,6 +197,7 @@ def get_well_details( thing_id: int, session: session_dependency, request: Request, + field_event_limit: int = Query(default=25, ge=1, le=100), ) -> WellDetailsResponse: """ Retrieve the consolidated payload needed to render the well details page. @@ -206,6 +207,7 @@ def get_well_details( session=session, request=request, thing_id=thing_id, + field_event_limit=field_event_limit, ) diff --git a/features/admin/README.md b/features/admin/README.md index 39d02cceb..536a714f8 100644 --- a/features/admin/README.md +++ b/features/admin/README.md @@ -66,8 +66,8 @@ Documents Location admin CRUD operations and business rules: ### Run Tests ```bash -# From NMSampleLocations directory -cd /path/to/NMSampleLocations +# From OcotilloAPI directory +cd /path/to/OcotilloAPI # Run all admin feature tests behave features/admin/ diff --git a/schemas/thing.py b/schemas/thing.py index 0423283b5..bb2b051eb 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -349,13 +349,8 @@ class ThingResponse(WellResponse, SpringResponse): measuring_point_height: float | None -class WellScreenResponse(BaseResponseModel): - """ - Response schema for well screen details. - """ - +class WellScreenBaseResponse(BaseResponseModel): thing_id: int - thing: WellResponse aquifer_system_id: int | None = None aquifer_system: str | None = None aquifer_type: str | None = None @@ -387,6 +382,14 @@ def populate_geologic_formation_with_code(cls, geologic_formation): return None +class WellScreenResponse(WellScreenBaseResponse): + """ + Response schema for well screen details. + """ + + thing: WellResponse + + class GeoJSONGeometry(BaseModel): """ Geometry schema for GeoJSON response. diff --git a/schemas/well_details.py b/schemas/well_details.py index e35ba5f42..2d058cd2a 100644 --- a/schemas/well_details.py +++ b/schemas/well_details.py @@ -1,12 +1,45 @@ from pydantic import BaseModel, ConfigDict, Field +from core.enums import ActivityType, SampleMatrix, SampleMethod, SampleQcType +from schemas import BaseResponseModel, UTCAwareDatetime from schemas.contact import ContactResponse from schemas.deployment import DeploymentResponse -from schemas.observation import GroundwaterLevelObservationResponse -from schemas.sample import SampleResponse from schemas.field import FieldEventParticipantResponse +from schemas.observation import ObservationResponse from schemas.sensor import SensorResponse -from schemas.thing import WellResponse, WellScreenResponse +from schemas.thing import WellResponse, WellScreenBaseResponse + + +class WellDetailsFieldEventSampleResponse(BaseResponseModel): + contact: ContactResponse | None = None + sample_date: UTCAwareDatetime + sample_name: str + sample_matrix: SampleMatrix + sample_method: SampleMethod + qc_type: SampleQcType + notes: str | None = None + depth_top: float | None = None + depth_bottom: float | None = None + observations: list[ObservationResponse] = Field(default_factory=list) + + +class WellDetailsFieldActivityResponse(BaseResponseModel): + field_event_id: int + activity_type: ActivityType + notes: str | None = None + samples: list[WellDetailsFieldEventSampleResponse] = Field(default_factory=list) + + +class WellDetailsFieldEventResponse(BaseResponseModel): + thing_id: int + event_date: UTCAwareDatetime + notes: str | None = None + field_event_participants: list[FieldEventParticipantResponse] = Field( + default_factory=list + ) + field_activities: list[WellDetailsFieldActivityResponse] = Field( + default_factory=list + ) class WellDetailsResponse(BaseModel): @@ -16,11 +49,5 @@ class WellDetailsResponse(BaseModel): 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 - field_event_participants: list[FieldEventParticipantResponse] = Field( - default_factory=list - ) + well_screens: list[WellScreenBaseResponse] = Field(default_factory=list) + field_events: list[WellDetailsFieldEventResponse] = Field(default_factory=list) diff --git a/services/well_details_helper.py b/services/well_details_helper.py index 28d720682..1ce6f8fd3 100644 --- a/services/well_details_helper.py +++ b/services/well_details_helper.py @@ -1,8 +1,9 @@ import logging import time +from contextlib import contextmanager from sqlalchemy import select -from sqlalchemy.orm import Session, joinedload, selectinload +from sqlalchemy.orm import Session, selectinload from db import ( Contact, @@ -11,7 +12,6 @@ FieldEvent, FieldEventParticipant, Observation, - Parameter, Sample, Sensor, ThingContactAssociation, @@ -47,155 +47,99 @@ def _log_payload_stage(payload_name: str, stage: str, thing_id: int, started_at: ) +@contextmanager +def _payload_stage_timer(payload_name: str, stage: str, thing_id: int): + started_at = time.perf_counter() + try: + yield + finally: + _log_payload_stage(payload_name, stage, thing_id, started_at) + + def get_well_details_payload( session: Session, request, thing_id: int, - recent_observation_limit: int = 100, + field_event_limit: int = 25, ): - payload_started_at = time.perf_counter() - stage_started_at = time.perf_counter() - well = get_thing_of_a_thing_type_by_id(session, request, thing_id) - _log_payload_stage("well_details", "load_well", thing_id, stage_started_at) - - stage_started_at = time.perf_counter() - 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() - _log_payload_stage("well_details", "load_contacts", thing_id, stage_started_at) - - stage_started_at = time.perf_counter() - sensors = session.scalars( - select(Sensor) - .join(Deployment) - .where(Deployment.thing_id == well.id) - .distinct() - .order_by(Sensor.id) - ).all() - _log_payload_stage("well_details", "load_sensors", thing_id, stage_started_at) - - stage_started_at = time.perf_counter() - 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() - _log_payload_stage( - "well_details", - "load_deployments", - thing_id, - stage_started_at, - ) - - stage_started_at = time.perf_counter() - well_screens = session.scalars( - select(WellScreen) - .where(WellScreen.thing_id == well.id) - .order_by(WellScreen.screen_depth_top.asc(), WellScreen.id.asc()) - ).all() - _log_payload_stage( - "well_details", - "load_well_screens", - thing_id, - stage_started_at, - ) - - stage_started_at = time.perf_counter() - groundwater_parameter_id = ( - session.query(Parameter) - .filter(Parameter.parameter_name == "groundwater level") - .one() - .id - ) - _log_payload_stage( - "well_details", - "resolve_groundwater_parameter", - thing_id, - stage_started_at, - ) - - stage_started_at = time.perf_counter() - 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() - _log_payload_stage( - "well_details", - "load_recent_groundwater_level_observations", - thing_id, - stage_started_at, - ) - - latest_field_event_sample = None - if recent_groundwater_level_observations: - latest_sample_id = recent_groundwater_level_observations[0].sample_id - stage_started_at = time.perf_counter() - 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_activity) - .joinedload(FieldActivity.field_event) - .selectinload(FieldEvent.field_event_participants) - .selectinload(FieldEventParticipant.participant), - joinedload(Sample.field_event_participant).joinedload( - FieldEventParticipant.participant - ), - ) - ) - _log_payload_stage( - "well_details", - "load_latest_field_event_sample", - thing_id, - stage_started_at, - ) - - _log_payload_stage( - "well_details", - "payload_total", - thing_id, - payload_started_at, - ) - - 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, - "field_event_participants": ( - latest_field_event_sample.field_event.field_event_participants - if latest_field_event_sample is not None - else [] - ), - } + with _payload_stage_timer("well_details", "payload_total", thing_id): + with _payload_stage_timer("well_details", "load_well", thing_id): + well = get_thing_of_a_thing_type_by_id(session, request, thing_id) + + with _payload_stage_timer("well_details", "load_contacts", 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() + + with _payload_stage_timer("well_details", "load_sensors", thing_id): + sensors = session.scalars( + select(Sensor) + .join(Deployment) + .where(Deployment.thing_id == well.id) + .distinct() + .order_by(Sensor.id) + ).all() + + with _payload_stage_timer("well_details", "load_deployments", thing_id): + 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() + + with _payload_stage_timer("well_details", "load_well_screens", thing_id): + well_screens = session.scalars( + select(WellScreen) + .where(WellScreen.thing_id == well.id) + .options( + selectinload(WellScreen.aquifer_system), + selectinload(WellScreen.geologic_formation), + ) + .order_by(WellScreen.screen_depth_top.asc(), WellScreen.id.asc()) + ).all() + + with _payload_stage_timer("well_details", "load_field_events", thing_id): + field_events = session.scalars( + select(FieldEvent) + .where(FieldEvent.thing_id == well.id) + .options( + selectinload(FieldEvent.field_event_participants).selectinload( + FieldEventParticipant.participant + ), + selectinload(FieldEvent.field_activities) + .selectinload(FieldActivity.samples) + .selectinload(Sample.field_event_participant) + .selectinload(FieldEventParticipant.participant), + selectinload(FieldEvent.field_activities) + .selectinload(FieldActivity.samples) + .selectinload(Sample.observations) + .selectinload(Observation.parameter), + ) + .order_by(FieldEvent.event_date.desc(), FieldEvent.id.desc()) + .limit(field_event_limit) + ).all() + + return { + "well": well, + "contacts": contacts, + "sensors": sensors, + "deployments": deployments, + "well_screens": well_screens, + "field_events": field_events, + } def get_well_export_payload( diff --git a/tests/test_thing.py b/tests/test_thing.py index fd9c86d94..e36792ee8 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -13,11 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from datetime import date, timezone - import pytest -from sqlalchemy import delete - from core.dependencies import ( admin_function, editor_function, @@ -26,6 +22,7 @@ viewer_function, amp_viewer_function, ) +from datetime import date, timezone from db import MeasuringPointHistory, StatusHistory, Thing, ThingIdLink, WellScreen from db.engine import session_ctx from main import app @@ -33,6 +30,7 @@ from schemas.location import LocationResponse from schemas.thing import UpdateWell, ValidateWell from services.water_level_csv import bulk_upload_water_levels +from sqlalchemy import delete from tests import ( client, override_authentication, @@ -629,22 +627,31 @@ def test_get_water_well_details_payload( 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 "thing" not in data["well_screens"][0] + assert len(data["field_events"]) == 1 + assert data["field_events"][0]["id"] == field_event.id + assert data["field_events"][0]["field_activities"][0]["id"] == ( + groundwater_level_sample.field_activity_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 + assert data["field_events"][0]["field_activities"][0]["samples"][0]["id"] == ( + groundwater_level_sample.id + ) + assert { + observation["id"] + for observation in data["field_events"][0]["field_activities"][0][ + "samples" + ][0]["observations"] + } == {groundwater_level_observation.id} assert { - participant["id"] for participant in data["field_event_participants"] + participant["id"] + for participant in data["field_events"][0]["field_event_participants"] } == { field_event_participant.id, second_participant_id, } assert { participant["participant"]["id"] - for participant in data["field_event_participants"] + for participant in data["field_events"][0]["field_event_participants"] } == {contact.id, second_contact_id} finally: with session_ctx() as session: @@ -706,11 +713,15 @@ def test_get_water_well_details_payload_uses_latest_observation_sample( 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 + activity_samples = data["field_events"][0]["field_activities"][0]["samples"] + matching_sample = next( + (sample for sample in activity_samples if sample["id"] == later_sample_id), + None, ) + assert matching_sample is not None, "Expected later sample in field event" + assert { + observation["id"] for observation in matching_sample["observations"] + } == {later_observation_id} finally: with session_ctx() as session: later_observation = session.get(Observation, later_observation_id) @@ -722,6 +733,41 @@ def test_get_water_well_details_payload_uses_latest_observation_sample( session.commit() +def test_get_water_well_details_payload_limits_field_events( + water_well_thing, + field_event, +): + from db import FieldEvent + + with session_ctx() as session: + later_field_event = FieldEvent( + thing_id=water_well_thing.id, + event_date="2025-01-02T00:00:00Z", + notes="later field event", + release_status="draft", + ) + session.add(later_field_event) + session.commit() + session.refresh(later_field_event) + later_field_event_id = later_field_event.id + + try: + response = client.get( + f"/thing/water-well/{water_well_thing.id}/details?field_event_limit=1" + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["field_events"]) == 1 + assert data["field_events"][0]["id"] == later_field_event_id + finally: + with session_ctx() as session: + later_field_event = session.get(FieldEvent, later_field_event_id) + if later_field_event is not None: + session.delete(later_field_event) + session.commit() + + def test_get_water_well_details_payload_includes_imported_water_level_staff( water_well_thing, ): @@ -772,10 +818,12 @@ def test_get_water_well_details_payload_includes_imported_water_level_staff( assert response.status_code == 200 data = response.json() - assert data["latest_field_event_sample"]["contact"]["name"] == "A Lopez" + activity_samples = data["field_events"][0]["field_activities"][0]["samples"] + assert len(activity_samples) == 1 + assert activity_samples[0]["contact"]["name"] == "A Lopez" assert { participant["participant"]["name"] - for participant in data["field_event_participants"] + for participant in data["field_events"][0]["field_event_participants"] } == {"A Lopez", "B Chen"} diff --git a/tests/test_transfer_legacy_dates.py b/tests/test_transfer_legacy_dates.py index 32732b971..f1f246f99 100644 --- a/tests/test_transfer_legacy_dates.py +++ b/tests/test_transfer_legacy_dates.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -Unit tests for AMPAPI date field population during AMPAPI → NMSampleLocations migration. +Unit tests for AMPAPI date field population during AMPAPI → OcotilloAPI migration. These tests verify that: 1. Location.nma_date_created is populated from CSV DateCreated (read-only post-migration) @@ -22,16 +22,14 @@ """ import datetime -from unittest.mock import patch - import numpy as np import pandas as pd import pytest - from db import Sample -from transfers.well_transfer import _normalize_completion_date from transfers.util import make_location from transfers.waterlevels_transfer import WaterLevelTransferer +from transfers.well_transfer import _normalize_completion_date +from unittest.mock import patch # ============================================================================ # FIXTURES