diff --git a/db/notes.py b/db/notes.py index 0e2e8ab8..3c238fbf 100644 --- a/db/notes.py +++ b/db/notes.py @@ -102,6 +102,7 @@ def notes(cls): cascade="all, delete-orphan", lazy="selectin", overlaps="notes", + order_by=Notes.created_at, ) def add_note( @@ -125,4 +126,5 @@ def add_note( ) def _get_notes(self, note_type: str) -> list[Notes]: - return [n for n in self.notes if n.note_type == note_type] + notes = [n for n in self.notes if n.note_type == note_type] + return sorted(notes, key=lambda n: n.created_at) diff --git a/db/thing.py b/db/thing.py index c3c7c02d..01aed09e 100644 --- a/db/thing.py +++ b/db/thing.py @@ -418,6 +418,18 @@ def current_location(self): else None ) + @property + def site_name(self) -> str | None: + nmbgmr_link = next( + ( + link + for link in sorted(self.links, key=lambda link: link.id) + if link.alternate_organization == "NMBGMR" + ), + None, + ) + return nmbgmr_link.alternate_id if nmbgmr_link is not None else None + @property def water_notes(self): return self._get_notes("Water") @@ -438,6 +450,14 @@ def construction_notes(self): def site_notes(self): return self._get_notes("Site Notes (legacy)") + @property + def historic_depth_to_water(self) -> list[str]: + return [note.content for note in self._get_notes("Historical")] + + @property + def well_location_note(self) -> list[str]: + return [note.content for note in self._get_notes("Access")] + @property def well_status(self) -> str | None: """ @@ -465,17 +485,23 @@ def monitoring_status(self) -> str | None: return latest_status.status_value if latest_status else None @property - def open_status(self) -> str | None: + def open_status(self) -> bool | None: """ - Returns the open status from the most recent status history entry - where status_type is "Open Status". + Returns the open status as a boolean derived from the most recent + "Open Status" history entry. Since status_history is eagerly loaded, this should not introduce N+1 query issues. """ latest_status = retrieve_latest_polymorphic_history_table_record( self, "status_history", "Open Status" ) - return latest_status.status_value if latest_status else None + if latest_status is None: + return None + if latest_status.status_value == "Open": + return True + if latest_status.status_value == "Closed": + return False + return None @property def datalogger_suitability_status(self) -> str | None: diff --git a/schemas/field.py b/schemas/field.py index b8152ffa..c4df70ef 100644 --- a/schemas/field.py +++ b/schemas/field.py @@ -2,6 +2,7 @@ from schemas import BaseResponseModel from core.enums import ActivityType +from schemas.contact import ContactResponse # RESPONSE --------------------------------------------------------------------- @@ -15,3 +16,10 @@ class FieldEventResponse(BaseResponseModel): thing_id: int event_date: AwareDatetime notes: str | None + + +class FieldEventParticipantResponse(BaseResponseModel): + field_event_id: int + contact_id: int + participant_role: str + participant: ContactResponse diff --git a/schemas/location.py b/schemas/location.py index 50fe28dd..e96a2474 100644 --- a/schemas/location.py +++ b/schemas/location.py @@ -22,6 +22,7 @@ from core.constants import SRID_WGS84, SRID_UTM_ZONE_13N from core.enums import ElevationMethod, CoordinateMethod +from core.enums import ReleaseStatus from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel from schemas.notes import NoteResponse, CreateNote, UpdateNote from services.util import convert_m_to_ft, transform_srid @@ -122,6 +123,7 @@ class GeoJSONProperties(BaseModel): class LocationGeoJSONResponse(BaseModel): type: str = "Feature" + release_status: ReleaseStatus geometry: GeoJSONGeometry properties: GeoJSONProperties diff --git a/schemas/thing.py b/schemas/thing.py index baa4ec6c..0423283b 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -205,6 +205,7 @@ class MonitoringFrequencyResponse(BaseModel): class BaseThingResponse(BaseResponseModel): name: str + site_name: str | None = None thing_type: str current_location: LocationGeoJSONResponse first_visit_date: PastOrTodayDate | None @@ -247,6 +248,7 @@ class WellResponse(BaseThingResponse): well_depth: float | None = None well_depth_unit: str = "ft" well_depth_source: str | None + historic_depth_to_water: list[str] = [] hole_depth: float | None = None hole_depth_unit: str = "ft" well_casing_diameter: float | None = None # in inches @@ -263,7 +265,7 @@ class WellResponse(BaseThingResponse): well_pump_depth: float | None well_pump_depth_unit: str = "ft" well_status: str | None - open_status: str | None + open_status: bool | None datalogger_suitability_status: str | None measuring_point_height: float | None measuring_point_height_unit: str = "ft" @@ -275,6 +277,7 @@ class WellResponse(BaseThingResponse): permissions: list[PermissionHistoryResponse] formation_completion_code: FormationCode | None nma_formation_zone: str | None + well_location_note: list[str] = [] @field_validator("well_purposes", mode="before") def populate_well_purposes_with_strings(cls, well_purposes): diff --git a/schemas/well_details.py b/schemas/well_details.py index fa94f154..e35ba5f4 100644 --- a/schemas/well_details.py +++ b/schemas/well_details.py @@ -4,6 +4,7 @@ from schemas.deployment import DeploymentResponse from schemas.observation import GroundwaterLevelObservationResponse from schemas.sample import SampleResponse +from schemas.field import FieldEventParticipantResponse from schemas.sensor import SensorResponse from schemas.thing import WellResponse, WellScreenResponse @@ -20,3 +21,6 @@ class WellDetailsResponse(BaseModel): Field(default_factory=list) ) latest_field_event_sample: SampleResponse | None = None + field_event_participants: list[FieldEventParticipantResponse] = Field( + default_factory=list + ) diff --git a/services/well_details_helper.py b/services/well_details_helper.py index 7408d15a..28d72068 100644 --- a/services/well_details_helper.py +++ b/services/well_details_helper.py @@ -159,6 +159,10 @@ def get_well_details_payload( 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 ), @@ -186,6 +190,11 @@ def get_well_details_payload( "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 [] + ), } diff --git a/tests/test_thing.py b/tests/test_thing.py index 2dde25fb..9201dfbe 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -26,7 +26,7 @@ viewer_function, amp_viewer_function, ) -from db import MeasuringPointHistory, Thing, ThingIdLink, WellScreen +from db import MeasuringPointHistory, StatusHistory, Thing, ThingIdLink, WellScreen from db.engine import session_ctx from main import app from schemas import DT_FMT @@ -563,6 +563,7 @@ def test_get_water_well_by_id(water_well_thing, location): def test_get_water_well_details_payload( water_well_thing, field_event, + field_event_participant, contact, email, phone, @@ -574,28 +575,87 @@ def test_get_water_well_details_payload( groundwater_level_sample, groundwater_level_observation, ): + with session_ctx() as session: + well = session.get(type(water_well_thing), water_well_thing.id) + session.add(well.add_note("historic depth to water: 12 ft", "Historical")) + session.add(well.add_note("historic depth to water: 18 ft", "Historical")) + session.add(well.add_note("turn left at the cattle guard", "Access")) + session.add(well.add_note("use the south gate", "Access")) + location = well.current_location + location.release_status = "private" + + second_contact = contact.__class__( + name="Second Participant", + organization=None, + role=contact.role, + contact_type=contact.contact_type, + release_status="draft", + ) + session.add(second_contact) + session.flush() + second_participant = field_event_participant.__class__( + field_event_id=field_event.id, + contact_id=second_contact.id, + participant_role="Lead", + ) + session.add(second_participant) + session.commit() + second_contact_id = second_contact.id + second_participant_id = second_participant.id + response = client.get(f"/thing/water-well/{water_well_thing.id}/details") - assert response.status_code == 200 - data = response.json() + try: + 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 + assert data["well"]["id"] == water_well_thing.id + assert data["well"]["alternate_ids"][0]["id"] == thing_id_link.id + assert data["well"]["historic_depth_to_water"] == [ + "historic depth to water: 12 ft", + "historic depth to water: 18 ft", + ] + assert data["well"]["well_location_note"] == [ + "turn left at the cattle guard", + "use the south gate", + ] + assert data["well"]["current_location"]["release_status"] == "private" + 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 + assert { + participant["id"] for participant in data["field_event_participants"] + } == { + field_event_participant.id, + second_participant_id, + } + assert { + participant["participant"]["id"] + for participant in data["field_event_participants"] + } == {contact.id, second_contact_id} + finally: + with session_ctx() as session: + second_participant = session.get( + field_event_participant.__class__, second_participant_id + ) + if second_participant is not None: + session.delete(second_participant) + second_contact = session.get(contact.__class__, second_contact_id) + if second_contact is not None: + session.delete(second_contact) + session.commit() def test_get_water_well_details_payload_uses_latest_observation_sample( @@ -681,6 +741,66 @@ def test_get_water_well_by_id_includes_location_properties( assert data["current_location"]["properties"]["quad_name"] == "Hillsboro Peak" +def test_get_water_well_by_id_includes_site_name_from_nmbgmr_link( + water_well_thing, + thing_id_link, +): + with session_ctx() as session: + site_name_link = ThingIdLink( + thing_id=water_well_thing.id, + relation="same_as", + alternate_id="John Smith Well", + alternate_organization="NMBGMR", + release_status="private", + ) + session.add(site_name_link) + session.commit() + site_name_link_id = site_name_link.id + + try: + response = client.get(f"/thing/water-well/{water_well_thing.id}") + + assert response.status_code == 200 + data = response.json() + assert data["site_name"] == "John Smith Well" + finally: + with session_ctx() as session: + site_name_link = session.get(ThingIdLink, site_name_link_id) + if site_name_link is not None: + session.delete(site_name_link) + session.commit() + + +def test_get_water_well_by_id_includes_open_status_boolean(water_well_thing): + with session_ctx() as session: + open_status = StatusHistory( + status_type="Open Status", + status_value="Open", + start_date=date(2025, 1, 1), + end_date=None, + reason="test open status", + target_id=water_well_thing.id, + target_table="thing", + release_status="draft", + ) + session.add(open_status) + session.commit() + open_status_id = open_status.id + + try: + response = client.get(f"/thing/water-well/{water_well_thing.id}") + + assert response.status_code == 200 + data = response.json() + assert data["open_status"] is True + finally: + with session_ctx() as session: + open_status = session.get(StatusHistory, open_status_id) + if open_status is not None: + session.delete(open_status) + session.commit() + + def test_get_water_wells_includes_contact_summary( water_well_thing, contact, diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 4e3be31b..918a9e1d 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -160,26 +160,20 @@ def test_well_inventory_db_contents_no_waterlevels(): assert thing.formation_completion_code is None assert thing.notes is not None - assert sorted(c.content for c in thing._get_notes("Access")) == sorted( - [file_content["specific_location_of_well"]] - ) - assert sorted(c.content for c in thing._get_notes("General")) == sorted( - [file_content["contact_special_requests_notes"]] - ) - assert sorted( - c.content for c in thing._get_notes("Sampling Procedure") - ) == sorted( - [ - file_content["well_measuring_notes"], - file_content["sampling_scenario_notes"], - f"Sample possible: {file_content['sample_possible']}", - ] - ) - assert sorted(c.content for c in thing._get_notes("Historical")) == sorted( - [ - f"historic depth to water: {float(file_content['historic_depth_to_water_ft'])} ft - source: {file_content['depth_source'].lower()}" - ] - ) + assert [c.content for c in thing._get_notes("Access")] == [ + file_content["specific_location_of_well"] + ] + assert [c.content for c in thing._get_notes("General")] == [ + file_content["contact_special_requests_notes"] + ] + assert [c.content for c in thing._get_notes("Sampling Procedure")] == [ + file_content["well_measuring_notes"], + file_content["sampling_scenario_notes"], + f"Sample possible: {file_content['sample_possible']}", + ] + assert [c.content for c in thing._get_notes("Historical")] == [ + f"historic depth to water: {float(file_content['historic_depth_to_water_ft'])} ft - source: {file_content['depth_source'].lower()}" + ] assert ( thing.measuring_point_description @@ -243,9 +237,9 @@ def test_well_inventory_db_contents_no_waterlevels(): else "Datalogger cannot be installed" ) assert ( - thing.open_status == "Open" + thing.open_status is True if file_content["is_open"].lower() == "true" - else "Closed" + else thing.open_status is False ) # LOCATION AND RELATED RECORDS