From d1a4b3430fb3abc5d0b48fc89270ecdc3e4a9351 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 10 Nov 2025 13:06:48 -0700 Subject: [PATCH 01/33] fix: import lexicon from db --- db/group.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/db/group.py b/db/group.py index a02eb3d34..c6a9d068e 100644 --- a/db/group.py +++ b/db/group.py @@ -22,8 +22,7 @@ from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy from constants import SRID_WGS84 -from db.base import Base, AutoBaseMixin, ReleaseMixin -from tests.conftest import lexicon_term +from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term if TYPE_CHECKING: from db.group import GroupThingAssociation @@ -37,10 +36,8 @@ class Group(Base, AutoBaseMixin, ReleaseMixin): project_area: Mapped[Optional[WKBElement]] = mapped_column( Geometry(geometry_type="MULTIPOLYGON", srid=SRID_WGS84, spatial_index=True) ) - group_type: Mapped[Optional[str]] = lexicon_term(String(50), nullable=True) - monitoring_frequency: Mapped[Optional[str]] = lexicon_term( - String(50), nullable=True - ) + group_type: Mapped[Optional[str]] = lexicon_term(nullable=True) + monitoring_frequency: Mapped[Optional[str]] = lexicon_term(nullable=True) # Foreign Keys parent_group_id: Mapped[Optional[int]] = mapped_column( From 84a2817660280ff20c312897530a217d66e661c4 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 10 Nov 2025 13:07:14 -0700 Subject: [PATCH 02/33] feat: make GroupType and MonitoringFrequency enums --- core/enums.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/enums.py b/core/enums.py index 52e37d805..32538ce2a 100644 --- a/core/enums.py +++ b/core/enums.py @@ -67,4 +67,8 @@ Vertical_datum: type[Enum] = build_enum_from_lexicon_category("vertical_datum") ScreenType: type[Enum] = build_enum_from_lexicon_category("screen_type") SensorType: type[Enum] = build_enum_from_lexicon_category("sensor_type") +GroupType: type[Enum] = build_enum_from_lexicon_category("group_type") +MonitoringFrequency: type[Enum] = build_enum_from_lexicon_category( + "monitoring_frequency" +) # ============= EOF ============================================= From d8f69c697740392831bb0e775f7845f1e477812c Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 10 Nov 2025 13:11:50 -0700 Subject: [PATCH 03/33] feat: update GroupResponse and add to ThingResponse --- schemas/group.py | 5 ++++- schemas/thing.py | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/schemas/group.py b/schemas/group.py index 49c3a25a4..3bffff976 100644 --- a/schemas/group.py +++ b/schemas/group.py @@ -18,6 +18,7 @@ from pydantic import BaseModel, field_validator, model_validator from typing_extensions import Self +from core.enums import GroupType, MonitoringFrequency from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel from services.validation.geospatial import validate_wkt_geometry @@ -53,8 +54,10 @@ class GroupResponse(BaseResponseModel): """ name: str - project_area: str | None description: str | None + project_area: str | None + group_type: GroupType | None + monitoring_frequency: MonitoringFrequency | None parent_group_id: int | None @model_validator(mode="before") diff --git a/schemas/thing.py b/schemas/thing.py index cd741c758..a8b807bef 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -20,6 +20,7 @@ from core.enums import WellPurpose, CasingMaterial, SpringType, ScreenType from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel from schemas.location import LocationResponse +from schemas.group import GroupResponse # -------- VALIDATE ---------- @@ -135,6 +136,7 @@ class BaseThingResponse(BaseResponseModel): thing_type: str current_location: LocationResponse | None first_visit_date: PastDate | None + groups: list[GroupResponse] = [] class WellResponse(BaseThingResponse): From 81d960f53e02e327ec33e1894ba0be1f03ce680e Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 10 Nov 2025 13:20:44 -0700 Subject: [PATCH 04/33] refactor: update bdd tests for updated group --- tests/features/environment.py | 18 ++++++++++++------ tests/features/steps/well-core-information.py | 4 ++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/features/environment.py b/tests/features/environment.py index 61ee82709..17c1c9c1b 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -122,7 +122,7 @@ def add_spring(context, session, location, name_num): @add_context_object_container("sensors") -def add_sensor(context, session, sid): +def add_sensor(context, session): sensor = Sensor( name="Test Sensor", sensor_type="Pressure Transducer", @@ -143,12 +143,16 @@ def add_sensor(context, session, sid): @add_context_object_container("groups") -def add_group(context, session, wells, gid): +def add_group(context, session, things): group = Group( - name="Collabnet", description="Healy Collaborative Network", project_area=None + name="Collabnet", + description="Healy Collaborative Network", + project_area=None, + group_type="Monitoring Plan", + monitoring_frequency="Quarterly", ) - for w in wells: - assoc = GroupThingAssociation(group=group, thing=w) + for thing in things: + assoc = GroupThingAssociation(group=group, thing=thing) session.add(assoc) session.add(group) @@ -258,7 +262,7 @@ def before_all(context): well_2 = add_well(context, session, loc_2, name_num=2) well_3 = add_well(context, session, loc_3, name_num=3) spring_4 = add_spring(context, session, loc_4, name_num=4) - sensor_1 = add_sensor(context, session, well_1.id) + sensor_1 = add_sensor(context, session) deployment = add_deployment(context, session, well_1.id, sensor_1.id) well_status_1 = add_status_history( @@ -336,6 +340,8 @@ def before_all(context): alternate_organization="NMBGMR", ) + group = add_group(context, session, [well_1, well_2]) + # parameter ID can be hardcoded because init_parameter always creates the same one parameter = session.get(Parameter, 1) add_obs = add_block(context, session, parameter) diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index 54c67ac47..c72ce245b 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -54,6 +54,10 @@ def step_impl(context): context.water_well_data["groups"][0]["project_area"] == context.objects["groups"][0].project_area ) + assert ( + context.water_well_data["groups"][0]["group_type"] + == context.objects["groups"][0].group_type + ) # ------------------------------------------------------------------------------ From d22f0da40b4be48c750e381774b95fccc6ce61b2 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 10 Nov 2025 13:34:43 -0700 Subject: [PATCH 05/33] feat: implement well purposes in behave tests --- tests/features/environment.py | 15 +++++++++++++++ tests/features/steps/well-core-information.py | 6 ++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/features/environment.py b/tests/features/environment.py index 17c1c9c1b..240baf151 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -31,6 +31,7 @@ TransducerObservationBlock, StatusHistory, ThingIdLink, + WellPurpose, ) from db.engine import session_ctx @@ -95,6 +96,17 @@ def add_well(context, session, location, name_num): return well +@add_context_object_container("well_purposes") +def add_well_purpose(context, session, well, purpose_term): + purpose = WellPurpose(thing=well, purpose=purpose_term) + session.add(purpose) + session.commit() + session.refresh(purpose) + + context.objects["well_purposes"].append(purpose) + return purpose + + @add_context_object_container("springs") def add_spring(context, session, location, name_num): spring = Thing( @@ -342,6 +354,9 @@ def before_all(context): group = add_group(context, session, [well_1, well_2]) + for purpose in ["Domestic", "Irrigation"]: + add_well_purpose(context, session, well_1, purpose) + # parameter ID can be hardcoded because init_parameter always creates the same one parameter = session.get(Parameter, 1) add_obs = add_block(context, session, parameter) diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index c72ce245b..80415bf06 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -67,16 +67,18 @@ def step_impl(context): @then("the response should include the purpose of the well (current use)") def step_impl(context): + assert "well_purposes" in context.water_well_data + assert "Domestic" in context.water_well_data["well_purposes"] assert "Irrigation" in context.water_well_data["well_purposes"] assert ( context.water_well_data["well_purposes"][0] - == context.objects.wells[0].well_purposes[0].purpose + == context.objects["wells"][0].well_purposes[0].purpose ) assert ( context.water_well_data["well_purposes"][1] - == context.objects.wells[0].well_purposes[1].purpose + == context.objects["wells"][0].well_purposes[1].purpose ) From 31c70704d184d4174988c0551927b1b01707473b Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 10 Nov 2025 14:12:56 -0700 Subject: [PATCH 06/33] refactor: make status_type and status_value lexicon terms --- db/status_history.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/db/status_history.py b/db/status_history.py index acfd20f5d..51a55b7cd 100644 --- a/db/status_history.py +++ b/db/status_history.py @@ -19,12 +19,12 @@ ) from sqlalchemy.orm import Mapped, mapped_column -from db.base import Base, AutoBaseMixin, ReleaseMixin +from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term class StatusHistory(Base, AutoBaseMixin, ReleaseMixin): - status_type: Mapped[str] = mapped_column(String(50), nullable=False) - status_value: Mapped[str] = mapped_column(String(50), nullable=False) + status_type: Mapped[str] = lexicon_term(nullable=False) + status_value: Mapped[str] = lexicon_term(nullable=False) start_date: Mapped[datetime.datetime] = mapped_column( DateTime(timezone=True), nullable=True ) From ba002e2354c82e9bf7eb855b7b25b8907114799e Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 10 Nov 2025 15:26:47 -0700 Subject: [PATCH 07/33] feat: add monitoring statuses to lexicon --- core/lexicon.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/lexicon.json b/core/lexicon.json index 0413f61b4..d8209d5b1 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -330,6 +330,8 @@ {"categories": ["status_value"], "term": "Active, pumping well", "definition": "This well is in use."}, {"categories": ["status_value"], "term": "Destroyed, exists but not usable", "definition": "The well structure is physically present but is damaged, collapsed, or otherwise compromised to the point that it is non-functional."}, {"categories": ["status_value"], "term": "Inactive, exists but not used", "definition": "The well is not currently in use but is believed to be in a usable condition; it has not been permanently decommissioned/abandoned."}, + {"categories": ["status_value"], "term": "Currently monitored", "definition": "The well is currently being monitored by AMMP."}, + {"categories": ["status_value"], "term": "Not currently monitored", "definition": "The well is not currently being monitored by AMMP."}, {"categories": ["sample_method"], "term": "Airline measurement", "definition": "Airline measurement"}, {"categories": ["sample_method"], "term": "Analog or graphic recorder", "definition": "Analog or graphic recorder"}, {"categories": ["sample_method"], "term": "Calibrated airline measurement", "definition": "Calibrated airline measurement"}, From eb5de1ebf69926a3d1f213481eb4353e3337ea0b Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 10 Nov 2025 15:27:18 -0700 Subject: [PATCH 08/33] feat: add well status to thing --- db/thing.py | 21 +++++++++++++++++++++ schemas/thing.py | 1 + 2 files changed, 22 insertions(+) diff --git a/db/thing.py b/db/thing.py index 3465fd54b..4a87ca7bd 100644 --- a/db/thing.py +++ b/db/thing.py @@ -274,6 +274,27 @@ def current_location(self): else None ) + @property + def well_status(self) -> str | None: + """ + Returns the well status from the most recent status history entry + where status_type is "well_status". + + Since status_history is eagerly loaded, this should not introduce N+1 query issues. + """ + status_entries = [ + status + for status in self.status_history + if status.status_type == "Well Status" and status.end_date is None + ] + if status_entries: + # Sort by start_date descending to get the most recent status out of the filtered entries + most_recent_status = sorted( + status_entries, key=lambda x: x.start_date, reverse=True + )[0] + return most_recent_status.status_value + return None + class ThingIdLink(Base, AutoBaseMixin, ReleaseMixin): """ diff --git a/schemas/thing.py b/schemas/thing.py index a8b807bef..6b187d2a8 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -155,6 +155,7 @@ class WellResponse(BaseThingResponse): well_casing_depth_unit: str = "ft" well_casing_materials: list[CasingMaterial] = [] well_construction_notes: str | None = None + well_status: str | None @field_validator("well_purposes", mode="before") def populate_well_purposes_with_strings(cls, well_purposes): From 33e478e14ff6de605fedec779f081264c81de2de Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 10 Nov 2025 15:27:47 -0700 Subject: [PATCH 09/33] feat: function to convert m to ft --- services/util.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/services/util.py b/services/util.py index cb3d8826c..84d0752ed 100644 --- a/services/util.py +++ b/services/util.py @@ -26,6 +26,10 @@ def transform_srid(geometry, source_srid, target_srid): return transform(transformer.transform, geometry) +def convert_m_to_ft(meters: float) -> float: + return meters * 3.28084 + + def get_tiger_data( lon: float, lat: float, layer: int, outfields: str = "*" ) -> dict | None: From dc33da4e2b81d2ca90594c1648774e0799fd580c Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 10 Nov 2025 15:28:08 -0700 Subject: [PATCH 10/33] feat: pass test for well status --- tests/features/environment.py | 27 ++++++++++--------- tests/features/steps/well-core-information.py | 6 ++++- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/tests/features/environment.py b/tests/features/environment.py index 240baf151..e4050e558 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -207,7 +207,7 @@ def add_block(context, session, parameter): return block -@add_context_object_container("status_histories") +@add_context_object_container("status_history") def add_status_history( context, session, @@ -233,7 +233,7 @@ def add_status_history( session.commit() session.refresh(status_history) - context.objects["status_histories"].append(status_history) + context.objects["status_history"].append(status_history) return status_history @@ -280,48 +280,48 @@ def before_all(context): well_status_1 = add_status_history( context, session, - status_type="well_status", + status_type="Well Status", status_value="Active, pumping well", start_date=datetime(2020, 1, 1), end_date=datetime(2021, 1, 1), reason="Initial status", - statusable_id=well_1.id, + statusable_id=context.objects["wells"][0].id, statusable_type="Thing", ) well_status_2 = add_status_history( context, session, - status_type="well_status", + status_type="Well Status", status_value="Destroyed, exists but not usable", start_date=datetime(2021, 1, 1), end_date=None, reason="Roving bovine", - statusable_id=well_1.id, + statusable_id=context.objects["wells"][0].id, statusable_type="Thing", ) monitoring_status_1 = add_status_history( context, session, - status_type="monitoring_status", - status_value="currently monitored", + status_type="Monitoring Status", + status_value="Currently monitored", start_date=datetime(2020, 1, 1), end_date=datetime(2021, 1, 1), reason="Initial monitoring status", - statusable_id=well_1.id, + statusable_id=context.objects["wells"][0].id, statusable_type="Thing", ) monitoring_status_2 = add_status_history( context, session, - status_type="monitoring_status", - status_value="not monitored", + status_type="Monitoring Status", + status_value="Not currently monitored", start_date=datetime(2021, 1, 1), end_date=None, reason="Roving bovine destroyed well", - statusable_id=well_1.id, + statusable_id=context.objects["wells"][0].id, statusable_type="Thing", ) @@ -371,6 +371,9 @@ def before_all(context): session.add(obs) session.commit() + # the well needs to be refreshed to get all the new relationships + session.refresh(well_1) + def after_all(context): with session_ctx() as session: diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index 80415bf06..90c29a7d9 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -90,7 +90,11 @@ def step_impl(context): assert "well_status" in context.water_well_data status_history = context.objects["wells"][0].status_history - well_status = [sh for sh in status_history if sh.status_type == "well_status"] + well_status = [ + sh + for sh in status_history + if sh.status_type == "Well Status" and sh.end_date is None + ] well_status_sorted = sorted(well_status, key=lambda sh: sh.start_date, reverse=True) assert context.water_well_data["well_status"] == well_status_sorted[0].status_value From f7c0ffba6fbe5a130d73eafb6515669884aff0a1 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 10 Nov 2025 15:57:26 -0700 Subject: [PATCH 11/33] feat: pass monitoring frequency bdd test --- tests/features/steps/well-core-information.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index 90c29a7d9..1d62fc2fd 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -37,7 +37,6 @@ def step_impl(context): assert context.water_well_data["name"] == context.objects["wells"][0].name -# TODO: model schema, and test data need to be udpated @then("the response should include the project(s) or group(s) associated with the well") def step_impl(context): assert "groups" in context.water_well_data @@ -82,7 +81,6 @@ def step_impl(context): ) -# TODO: this needs to be added to the ThingResponse and thing_helper via StatusHistory @then( "the response should include the well hole status of the well as the status of the hole in the ground (from previous Status field)" ) @@ -100,16 +98,14 @@ def step_impl(context): assert context.water_well_data["well_status"] == well_status_sorted[0].status_value -# TODO: this needs to be added to the model, schema, and test data -# TODO: the monitoring frequency field needs to be added to lexicon -# the monitoring status field from NM_Aquifer contains a multitude of information, like having three codes (6AC), so the transfer and model/schemas will need to take this into account -# could create descriptor table like WellPurpose and CasingMaterial @then("the response should include the monitoring frequency (new field)") def step_impl(context): for group in context.water_well_data["groups"]: assert "monitoring_frequency" in group - - assert context.water_well_data["monitoring_frequency"] == "Monthly" + assert ( + group["monitoring_frequency"] + == context.objects["groups"][0].monitoring_frequency + ) # TODO: this needs to be added to the model, schema, and test data From c3018cc91809ddcdc7a07c722c8b3d5b6f8abdb1 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 10 Nov 2025 16:05:34 -0700 Subject: [PATCH 12/33] feat: implement monitoring status --- db/thing.py | 23 ++++++++++++++++++- schemas/thing.py | 1 + tests/features/steps/well-core-information.py | 4 +++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/db/thing.py b/db/thing.py index 4a87ca7bd..b38ed836e 100644 --- a/db/thing.py +++ b/db/thing.py @@ -278,7 +278,7 @@ def current_location(self): def well_status(self) -> str | None: """ Returns the well status from the most recent status history entry - where status_type is "well_status". + where status_type is "Well Status". Since status_history is eagerly loaded, this should not introduce N+1 query issues. """ @@ -295,6 +295,27 @@ def well_status(self) -> str | None: return most_recent_status.status_value return None + @property + def monitoring_status(self) -> str | None: + """ + Returns the monitoring status from the most recent status history entry + where status_type is "Monitoring Status". + + Since status_history is eagerly loaded, this should not introduce N+1 query issues. + """ + status_entries = [ + status + for status in self.status_history + if status.status_type == "Monitoring Status" and status.end_date is None + ] + if status_entries: + # Sort by start_date descending to get the most recent status out of the filtered entries + most_recent_status = sorted( + status_entries, key=lambda x: x.start_date, reverse=True + )[0] + return most_recent_status.status_value + return None + class ThingIdLink(Base, AutoBaseMixin, ReleaseMixin): """ diff --git a/schemas/thing.py b/schemas/thing.py index 6b187d2a8..fe8bee6eb 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -137,6 +137,7 @@ class BaseThingResponse(BaseResponseModel): current_location: LocationResponse | None first_visit_date: PastDate | None groups: list[GroupResponse] = [] + monitoring_status: str | None class WellResponse(BaseThingResponse): diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index 1d62fc2fd..8122762e5 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -119,7 +119,9 @@ def step_impl(context): status_history = context.objects["wells"][0].status_history monitoring_status = [ - sh for sh in status_history if sh.status_type == "monitoring_status" + sh + for sh in status_history + if sh.status_type == "Monitoring Status" and sh.end_date is None ] monitoring_status_sorted = sorted( monitoring_status, key=lambda sh: sh.start_date, reverse=True From 91852290dbed5717e9c911562e4b256e33acb1e2 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 10 Nov 2025 16:08:23 -0700 Subject: [PATCH 13/33] refactor: remove outdated note --- tests/features/steps/well-core-information.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index 8122762e5..d0d41bcfa 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -108,9 +108,6 @@ def step_impl(context): ) -# TODO: this needs to be added to the model, schema, and test data -# the monitoring status field from NM_Aquifer contains a multitude of information, like having three codes (6AC), so the transfer and model/schemas will need to take this into account -# could create descriptor table like WellPurpose and CasingMaterial @then( "the response should include whether the well is currently being monitored with status text if applicable (from previous MonitoringStatus field)" ) From e7636ddf4252975cc2e4a48613ecd60649029a61 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 11 Nov 2025 09:38:36 -0700 Subject: [PATCH 14/33] refactor: return GeoJSON for current_location --- db/thing.py | 5 + schemas/location.py | 95 ++++++++++++++++++- schemas/thing.py | 15 +-- tests/features/environment.py | 2 + tests/features/steps/well-core-information.py | 15 +-- 5 files changed, 114 insertions(+), 18 deletions(-) diff --git a/db/thing.py b/db/thing.py index b38ed836e..00c8ba9e7 100644 --- a/db/thing.py +++ b/db/thing.py @@ -101,6 +101,11 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix well_construction_notes: Mapped[str] = mapped_column(Text, nullable=True) + measuring_point_height: Mapped[float] = mapped_column( + Float, nullable=True, info={"unit": "feet above ground surface"} + ) + measuring_point_description: Mapped[str] = mapped_column(String, nullable=True) + # Spring-related columns spring_type: Mapped[str] = lexicon_term( nullable=True, diff --git a/schemas/location.py b/schemas/location.py index 7b2d5420f..5e8bd5a7d 100644 --- a/schemas/location.py +++ b/schemas/location.py @@ -15,11 +15,14 @@ # =============================================================================== from geoalchemy2 import WKBElement from geoalchemy2.shape import to_shape -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, model_validator, field_validator, Field, ConfigDict +from typing import Any +from constants import SRID_WGS84, SRID_UTM_ZONE_13N from core.enums import ElevationMethod, CoordinateMethod from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel from services.validation.geospatial import validate_wkt_geometry +from services.util import convert_m_to_ft, transform_srid # -------- VALIDATE -------- @@ -60,6 +63,96 @@ class CreateGroupThing(BaseModel): # -------- RESPONSE ---------- + + +class GeoJSONGeometry(BaseModel): + type: str = "Point" + coordinates: list = Field( + max_length=3, + min_length=3, + description="Coordinates in [longitude, latitude, elevation] format", + ) + + model_config = ConfigDict( + from_attributes=True, + populate_by_name=True, + ) + + +class GeoJSONUTMCoordinates(BaseModel): + easting: float + northing: float + utm_zone: int + horizontal_datum: str + + model_config = ConfigDict( + from_attributes=True, + populate_by_name=True, + ) + + +class GeoJSONProperties(BaseModel): + elevation: float + elevation_unit: str + vertical_datum: str + elevation_method: ElevationMethod | None + utm_coordinates: GeoJSONUTMCoordinates = Field( + default_factory=GeoJSONUTMCoordinates + ) + + model_config = ConfigDict( + from_attributes=True, + populate_by_name=True, + ) + + +class LocationGeoJSONResponse(BaseModel): + type: str = "Feature" + geometry: GeoJSONGeometry + properties: GeoJSONProperties + + model_config = ConfigDict( + from_attributes=True, + populate_by_name=True, + ) + + @model_validator(mode="before") + @classmethod + def populate_fields(cls, data: Any) -> Any: + # convert row to dictionary + if not isinstance(data, dict): + data_dict = {c.name: getattr(data, c.name) for c in data.__table__.columns} + + # add empty fields as necessary + data_dict["geometry"] = {} + data_dict["properties"] = {} + data_dict["properties"]["utm_coordinates"] = {} + + # populate coordinates + point_wkb = data_dict.get("point") + point_wgs84_wkt = to_shape(point_wkb) + elevation_m = data_dict.get("elevation") + coordinates = [point_wgs84_wkt.x, point_wgs84_wkt.y, elevation_m] + data_dict["geometry"]["coordinates"] = coordinates + + # populate properties + data_dict["properties"]["elevation"] = convert_m_to_ft(elevation_m) + data_dict["properties"]["elevation_unit"] = "ft" + data_dict["properties"]["elevation_method"] = data_dict.get("elevation_method") + data_dict["properties"]["vertical_datum"] = "NAVD88" + + # populate UTM coordinates + point_utm_zone_13n = transform_srid( + point_wgs84_wkt, SRID_WGS84, SRID_UTM_ZONE_13N + ) + data_dict["properties"]["utm_coordinates"]["easting"] = point_utm_zone_13n.x + data_dict["properties"]["utm_coordinates"]["northing"] = point_utm_zone_13n.y + data_dict["properties"]["utm_coordinates"]["utm_zone"] = 13 + data_dict["properties"]["utm_coordinates"]["horizontal_datum"] = "NAD83" + + return data_dict + + class LocationResponse(BaseResponseModel): """ Response schema for sample location details. diff --git a/schemas/thing.py b/schemas/thing.py index fe8bee6eb..455151b2d 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -19,7 +19,7 @@ from core.enums import WellPurpose, CasingMaterial, SpringType, ScreenType from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel -from schemas.location import LocationResponse +from schemas.location import LocationGeoJSONResponse from schemas.group import GroupResponse @@ -134,7 +134,7 @@ def check_depths(self): class BaseThingResponse(BaseResponseModel): name: str thing_type: str - current_location: LocationResponse | None + current_location: LocationGeoJSONResponse | None first_visit_date: PastDate | None groups: list[GroupResponse] = [] monitoring_status: str | None @@ -157,6 +157,9 @@ class WellResponse(BaseThingResponse): well_casing_materials: list[CasingMaterial] = [] well_construction_notes: str | None = None well_status: str | None + measuring_point_height: float + measuring_point_height_unit: str = "ft" + measuring_point_description: str | None @field_validator("well_purposes", mode="before") def populate_well_purposes_with_strings(cls, well_purposes): @@ -198,14 +201,6 @@ class ThingIdLinkResponse(BaseResponseModel): alternate_organization: str -class LocationWellResponse(LocationResponse): - """ - Response schema for sample location with well details. - """ - - well: List[WellResponse] = [] # List of wells associated with the sample location - - class WellScreenResponse(BaseResponseModel): """ Response schema for well screen details. diff --git a/tests/features/environment.py b/tests/features/environment.py index e4050e558..9cfb80ac2 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -81,6 +81,8 @@ def add_well(context, session, location, name_num): well_construction_notes="Test well construction notes", well_casing_diameter=5.0, well_casing_depth=10.0, + measuring_point_height=3.0, + measuring_point_description="Test measuring point description", ) session.add(well) session.commit() diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index d0d41bcfa..c979f8984 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -1,6 +1,8 @@ from constants import SRID_WGS84, SRID_UTM_ZONE_13N -from services.util import transform_srid +from services.util import transform_srid, convert_m_to_ft + from behave import when, then +from geoalchemy2.shape import to_shape # TODO: move to commonly used step definitions @@ -216,8 +218,6 @@ def step_impl(context): # Location Information # GeoJSON spec format RFC 7946 (Aug 2016) requires coordinates to be decimal degrees in WGS84 # ------------------------------------------------------------------------------ - - @then( "the response should include location information in GeoJSON spec format RFC 7946" ) @@ -232,13 +232,14 @@ def step_impl(context): assert context.water_well_data["current_location"]["type"] == "Feature" -# TODO: the LocationResponse schema needs to be updated @then( 'the response should include a geometry object with type "Point" and coordinates array [longitude, latitude, elevation]' ) def step_impl(context): - latitude = context.objects["locations"][0].point.y - longitude = context.objects["locations"][0].point.x + point_wkb = context.objects["locations"][0].point + point_wkt = to_shape(point_wkb) + latitude = point_wkt.y + longitude = point_wkt.x elevation_m = context.objects["locations"][0].elevation assert context.water_well_data["current_location"]["geometry"] == { @@ -257,7 +258,7 @@ def step_impl(context): assert "elevation_unit" in context.water_well_data["current_location"]["properties"] assert "vertical_datum" in context.water_well_data["current_location"]["properties"] - elevation_ft = context.objects["locations"][0].elevation * 3.28084 + elevation_ft = convert_m_to_ft(context.objects["locations"][0].elevation) assert ( context.water_well_data["current_location"]["properties"]["elevation"] From bdeb21054f72875496b78c115d48ac3f7e2e9633 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 11 Nov 2025 09:41:58 -0700 Subject: [PATCH 15/33] fix: transform wkb to wkt for tests --- schemas/location.py | 12 ++++-------- tests/features/steps/well-core-information.py | 8 +++----- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/schemas/location.py b/schemas/location.py index 5e8bd5a7d..bd970310d 100644 --- a/schemas/location.py +++ b/schemas/location.py @@ -82,8 +82,8 @@ class GeoJSONGeometry(BaseModel): class GeoJSONUTMCoordinates(BaseModel): easting: float northing: float - utm_zone: int - horizontal_datum: str + utm_zone: int = 13 + horizontal_datum: str = "NAD83" model_config = ConfigDict( from_attributes=True, @@ -93,8 +93,8 @@ class GeoJSONUTMCoordinates(BaseModel): class GeoJSONProperties(BaseModel): elevation: float - elevation_unit: str - vertical_datum: str + elevation_unit: str = "ft" + vertical_datum: str = "NAVD88" elevation_method: ElevationMethod | None utm_coordinates: GeoJSONUTMCoordinates = Field( default_factory=GeoJSONUTMCoordinates @@ -137,9 +137,7 @@ def populate_fields(cls, data: Any) -> Any: # populate properties data_dict["properties"]["elevation"] = convert_m_to_ft(elevation_m) - data_dict["properties"]["elevation_unit"] = "ft" data_dict["properties"]["elevation_method"] = data_dict.get("elevation_method") - data_dict["properties"]["vertical_datum"] = "NAVD88" # populate UTM coordinates point_utm_zone_13n = transform_srid( @@ -147,8 +145,6 @@ def populate_fields(cls, data: Any) -> Any: ) data_dict["properties"]["utm_coordinates"]["easting"] = point_utm_zone_13n.x data_dict["properties"]["utm_coordinates"]["northing"] = point_utm_zone_13n.y - data_dict["properties"]["utm_coordinates"]["utm_zone"] = 13 - data_dict["properties"]["utm_coordinates"]["horizontal_datum"] = "NAD83" return data_dict diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index c979f8984..c3841ec4f 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -248,8 +248,6 @@ def step_impl(context): } -# TODO: elevation should be returned in ft, not meters, conversion should occur in schema -# TODO: add elevation_unit: str = "ft" to LocationResponse schema @then( "the response should include the elevation in feet with vertical datum NAVD88 in the properties" ) @@ -297,9 +295,9 @@ def step_impl(context): "utm_coordinates" in context.water_well_data["current_location"]["properties"] ) - point_utm_zone_13 = transform_srid( - context.objects["locations"][0].point, SRID_WGS84, SRID_UTM_ZONE_13N - ) + point_wkb = context.objects["locations"][0].point + point_wkt = to_shape(point_wkb) + point_utm_zone_13 = transform_srid(point_wkt, SRID_WGS84, SRID_UTM_ZONE_13N) assert context.water_well_data["current_location"]["properties"][ "utm_coordinates" From 79e73d5a601cdb807e85e68499230d6aeb820809 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 11 Nov 2025 09:42:50 -0700 Subject: [PATCH 16/33] fix: transform wkb to wkt for tests --- schemas/location.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/schemas/location.py b/schemas/location.py index bd970310d..195562084 100644 --- a/schemas/location.py +++ b/schemas/location.py @@ -129,8 +129,8 @@ def populate_fields(cls, data: Any) -> Any: data_dict["properties"]["utm_coordinates"] = {} # populate coordinates - point_wkb = data_dict.get("point") - point_wgs84_wkt = to_shape(point_wkb) + point_wgs84_wkb = data_dict.get("point") + point_wgs84_wkt = to_shape(point_wgs84_wkb) elevation_m = data_dict.get("elevation") coordinates = [point_wgs84_wkt.x, point_wgs84_wkt.y, elevation_m] data_dict["geometry"]["coordinates"] = coordinates @@ -140,11 +140,13 @@ def populate_fields(cls, data: Any) -> Any: data_dict["properties"]["elevation_method"] = data_dict.get("elevation_method") # populate UTM coordinates - point_utm_zone_13n = transform_srid( + point_utm_zone_13n_wkt = transform_srid( point_wgs84_wkt, SRID_WGS84, SRID_UTM_ZONE_13N ) - data_dict["properties"]["utm_coordinates"]["easting"] = point_utm_zone_13n.x - data_dict["properties"]["utm_coordinates"]["northing"] = point_utm_zone_13n.y + data_dict["properties"]["utm_coordinates"]["easting"] = point_utm_zone_13n_wkt.x + data_dict["properties"]["utm_coordinates"][ + "northing" + ] = point_utm_zone_13n_wkt.y return data_dict From 505a64e2753bab342be7d41a51d614aac0db0a39 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 11 Nov 2025 09:43:17 -0700 Subject: [PATCH 17/33] notes: remove outdated TODO --- tests/features/steps/well-core-information.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index c3841ec4f..7f37b9e53 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -285,7 +285,6 @@ def step_impl(context): ) -# TODO: this needs to be added to the LocationResponse schema @then( "the response should include the UTM coordinates with datum NAD83 in the properties" ) From 12998f8f4d44f7a50ced0f42158856ee429e2804 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 11 Nov 2025 10:06:49 -0700 Subject: [PATCH 18/33] feat: add alternate ids to ThingResponse --- db/thing.py | 1 + schemas/thing.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/db/thing.py b/db/thing.py index 00c8ba9e7..1003a878b 100644 --- a/db/thing.py +++ b/db/thing.py @@ -233,6 +233,7 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix back_populates="thing", cascade="all, delete-orphan", passive_deletes=True, + lazy="joined", ) # --- Association Proxies --- diff --git a/schemas/thing.py b/schemas/thing.py index 455151b2d..a71cf2b02 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -131,6 +131,13 @@ def check_depths(self): # ------ RESPONSE ---------- +class ThingIdLinkResponse(BaseResponseModel): + thing_id: int + relation: str + alternate_id: str + alternate_organization: str + + class BaseThingResponse(BaseResponseModel): name: str thing_type: str @@ -138,6 +145,7 @@ class BaseThingResponse(BaseResponseModel): first_visit_date: PastDate | None groups: list[GroupResponse] = [] monitoring_status: str | None + links: list[ThingIdLinkResponse] = Field(default=[], alias="alternate_ids") class WellResponse(BaseThingResponse): @@ -193,14 +201,6 @@ class ThingResponse(WellResponse, SpringResponse): pass -class ThingIdLinkResponse(BaseResponseModel): - thing_id: int - thing: ThingResponse - relation: str - alternate_id: str - alternate_organization: str - - class WellScreenResponse(BaseResponseModel): """ Response schema for well screen details. From a74168f54ea8dc5ccd0668fd61cf92b28909eb04 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 11 Nov 2025 10:21:51 -0700 Subject: [PATCH 19/33] refactor: use Organiation enum for alternate organization --- schemas/thing.py | 4 ++-- tests/features/steps/well-core-information.py | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/schemas/thing.py b/schemas/thing.py index a71cf2b02..14d797604 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -17,7 +17,7 @@ from pydantic import BaseModel, model_validator, PastDate, Field, field_validator -from core.enums import WellPurpose, CasingMaterial, SpringType, ScreenType +from core.enums import WellPurpose, CasingMaterial, SpringType, ScreenType, Organization from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel from schemas.location import LocationGeoJSONResponse from schemas.group import GroupResponse @@ -135,7 +135,7 @@ class ThingIdLinkResponse(BaseResponseModel): thing_id: int relation: str alternate_id: str - alternate_organization: str + alternate_organization: Organization class BaseThingResponse(BaseResponseModel): diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index 7f37b9e53..833ed98c0 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -190,7 +190,6 @@ def step_impl(context): # ------------------------------------------------------------------------------ -# TODO: this needs to be added to the model, schema, and test data @then("the response should include the description of the measuring point") def step_impl(context): assert "measuring_point_description" in context.water_well_data @@ -201,7 +200,6 @@ def step_impl(context): ) -# TODO: this needs to be added to the model, schema, and test data @then("the response should include the measuring point height in feet") def step_impl(context): assert "measuring_point_height" in context.water_well_data @@ -313,8 +311,6 @@ def step_impl(context): # ------------------------------------------------------------------------------ -# TODO: This needs to be added to the test data -# TODO: id link schema needs to use lexicon enums for relation and alternate_organization @then( "the response should include any alternate IDs for the well like the NMBGMR site_name (i.e. John Smith Well), USGS site number, or the OSE well ID and OSE well tag ID" ) From 2ba1271665e0da6f0d342194bf8d0b993368355c Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 11 Nov 2025 11:04:12 -0700 Subject: [PATCH 20/33] fix: current_location is not nullable --- schemas/thing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schemas/thing.py b/schemas/thing.py index 14d797604..b700ac9cd 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -141,7 +141,7 @@ class ThingIdLinkResponse(BaseResponseModel): class BaseThingResponse(BaseResponseModel): name: str thing_type: str - current_location: LocationGeoJSONResponse | None + current_location: LocationGeoJSONResponse first_visit_date: PastDate | None groups: list[GroupResponse] = [] monitoring_status: str | None From 5581ce29feabf79eb7d2373faaddf3e4593bdc82 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 12 Nov 2025 11:32:24 -0700 Subject: [PATCH 21/33] feat: add PLSS as an organization to lexicon --- core/lexicon.json | 1 + 1 file changed, 1 insertion(+) diff --git a/core/lexicon.json b/core/lexicon.json index d8209d5b1..2ba161456 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -568,6 +568,7 @@ {"categories": ["organization"], "term": "Winter Brothers", "definition": "Winter Brothers"}, {"categories": ["organization"], "term": "Yates Petroleum Corporation", "definition": "Yates Petroleum Corporation"}, {"categories": ["organization"], "term": "Zamora Accounting Services", "definition": "Zamora Accounting Services"}, + {"categories": ["organization"], "term": "PLSS", "definition": "Public Land Survey System"}, {"categories": ["collection_method"], "term": "manual", "definition": "manual sampling"}, {"categories": ["collection_method"], "term": "continuous", "definition": "continuous sampling"}, {"categories": ["role"], "term": "Owner", "definition": "Owner"}, From cafbb92324936ecf8fdf51d9633dced10a066212 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 12 Nov 2025 11:41:24 -0700 Subject: [PATCH 22/33] refactor: round m and ft conversion to 6 decimal places --- services/util.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/services/util.py b/services/util.py index 84d0752ed..03d1ec8fc 100644 --- a/services/util.py +++ b/services/util.py @@ -7,6 +7,7 @@ from constants import SRID_WGS84 TRANSFORMERS = {} +METERS_TO_FEET = 3.28084 def transform_srid(geometry, source_srid, target_srid): @@ -26,8 +27,18 @@ def transform_srid(geometry, source_srid, target_srid): return transform(transformer.transform, geometry) -def convert_m_to_ft(meters: float) -> float: - return meters * 3.28084 +def convert_m_to_ft(meters: float | None) -> float | None: + """Convert a length from meters to feet.""" + if meters is None: + return None + return round(meters * METERS_TO_FEET, 6) + + +def convert_ft_to_m(feet: float | None) -> float | None: + """Convert a length from feet to meters.""" + if feet is None: + return None + return round(feet / METERS_TO_FEET, 6) def get_tiger_data( From 3e1203c49e2117a62c781b668e25cd3fa76bbcba Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 12 Nov 2025 11:48:19 -0700 Subject: [PATCH 23/33] refactor: set start/end date to date not datetime --- db/status_history.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/db/status_history.py b/db/status_history.py index 51a55b7cd..4f3f3ad57 100644 --- a/db/status_history.py +++ b/db/status_history.py @@ -9,12 +9,11 @@ mixin to establish a One-to-Many relationship TO this table. """ -import datetime +from datetime import date from sqlalchemy import ( Integer, String, - DateTime, Text, ) from sqlalchemy.orm import Mapped, mapped_column @@ -25,12 +24,8 @@ class StatusHistory(Base, AutoBaseMixin, ReleaseMixin): status_type: Mapped[str] = lexicon_term(nullable=False) status_value: Mapped[str] = lexicon_term(nullable=False) - start_date: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), nullable=True - ) - end_date: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), nullable=True - ) + start_date: Mapped[date] = mapped_column(nullable=False) + end_date: Mapped[date] = mapped_column(nullable=True) reason: Mapped[str] = mapped_column(Text, nullable=True) # Polymorphic relationship columns From 2201ec1bd104e97be25f21a3bd2dd729aca8bf68 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 12 Nov 2025 11:54:25 -0700 Subject: [PATCH 24/33] refactor: use target_id and target_table in status_history --- db/base.py | 4 ++-- db/status_history.py | 4 ++-- tests/features/environment.py | 24 ++++++++++++------------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/db/base.py b/db/base.py index ba2a45be8..18b82217f 100644 --- a/db/base.py +++ b/db/base.py @@ -184,8 +184,8 @@ def status_history(self): # One-to-Many polymorphic relationship return relationship( "StatusHistory", - primaryjoin=f"and_({self.__name__}.id==foreign(StatusHistory.statusable_id), " - f"StatusHistory.statusable_type=='{self.__name__}')", + primaryjoin=f"and_({self.__name__}.id==foreign(StatusHistory.target_id), " + f"StatusHistory.target_table=='{self.__name__}')", cascade="all, delete-orphan", lazy="selectin", ) diff --git a/db/status_history.py b/db/status_history.py index 4f3f3ad57..5b878e6b8 100644 --- a/db/status_history.py +++ b/db/status_history.py @@ -29,5 +29,5 @@ class StatusHistory(Base, AutoBaseMixin, ReleaseMixin): reason: Mapped[str] = mapped_column(Text, nullable=True) # Polymorphic relationship columns - statusable_id: Mapped[int] = mapped_column(Integer, nullable=False) - statusable_type: Mapped[str] = mapped_column(String(50), nullable=False) + target_id: Mapped[int] = mapped_column(Integer, nullable=False) + target_table: Mapped[str] = mapped_column(String(50), nullable=False) diff --git a/tests/features/environment.py b/tests/features/environment.py index 9cfb80ac2..08ed37010 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -218,8 +218,8 @@ def add_status_history( start_date, end_date, reason, - statusable_id, - statusable_type, + target_id, + target_table, ): status_history = StatusHistory( status_type=status_type, @@ -227,8 +227,8 @@ def add_status_history( start_date=start_date, end_date=end_date, reason=reason, - statusable_id=statusable_id, - statusable_type=statusable_type, + target_id=target_id, + target_table=target_table, ) session.add(status_history) @@ -287,8 +287,8 @@ def before_all(context): start_date=datetime(2020, 1, 1), end_date=datetime(2021, 1, 1), reason="Initial status", - statusable_id=context.objects["wells"][0].id, - statusable_type="Thing", + target_id=context.objects["wells"][0].id, + target_table="Thing", ) well_status_2 = add_status_history( @@ -299,8 +299,8 @@ def before_all(context): start_date=datetime(2021, 1, 1), end_date=None, reason="Roving bovine", - statusable_id=context.objects["wells"][0].id, - statusable_type="Thing", + target_id=context.objects["wells"][0].id, + target_table="Thing", ) monitoring_status_1 = add_status_history( @@ -311,8 +311,8 @@ def before_all(context): start_date=datetime(2020, 1, 1), end_date=datetime(2021, 1, 1), reason="Initial monitoring status", - statusable_id=context.objects["wells"][0].id, - statusable_type="Thing", + target_id=context.objects["wells"][0].id, + target_table="Thing", ) monitoring_status_2 = add_status_history( @@ -323,8 +323,8 @@ def before_all(context): start_date=datetime(2021, 1, 1), end_date=None, reason="Roving bovine destroyed well", - statusable_id=context.objects["wells"][0].id, - statusable_type="Thing", + target_id=context.objects["wells"][0].id, + target_table="Thing", ) id_link_1 = add_id_link( From 1b87a3c46ed82e72c80c38fcc09799f100c1ae43 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 12 Nov 2025 13:00:29 -0700 Subject: [PATCH 25/33] refactor: use cls for status history mixin --- db/base.py | 17 ----------------- db/status_history.py | 25 +++++++++++++++++++++++-- db/thing.py | 2 +- tests/__init__.py | 5 +++++ tests/features/environment.py | 8 ++++---- 5 files changed, 33 insertions(+), 24 deletions(-) diff --git a/db/base.py b/db/base.py index 18b82217f..5f9dd6516 100644 --- a/db/base.py +++ b/db/base.py @@ -172,23 +172,6 @@ def properties(self): # ============= Polymorphic Helper Mixins ============================================= -class StatusHistoryMixin: - """ - Mixin for models that can have a status history (e.g., Thing, Location). - It automatically creates a polymorphic One-to-Many relationship to the - StatusHistory table. - """ - - @declared_attr - def status_history(self): - # One-to-Many polymorphic relationship - return relationship( - "StatusHistory", - primaryjoin=f"and_({self.__name__}.id==foreign(StatusHistory.target_id), " - f"StatusHistory.target_table=='{self.__name__}')", - cascade="all, delete-orphan", - lazy="selectin", - ) class PermissionMixin: diff --git a/db/status_history.py b/db/status_history.py index 5b878e6b8..8b3ee2321 100644 --- a/db/status_history.py +++ b/db/status_history.py @@ -15,10 +15,11 @@ Integer, String, Text, + and_, ) -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, declared_attr, relationship, foreign -from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term +from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term, pascal_to_snake class StatusHistory(Base, AutoBaseMixin, ReleaseMixin): @@ -31,3 +32,23 @@ class StatusHistory(Base, AutoBaseMixin, ReleaseMixin): # Polymorphic relationship columns target_id: Mapped[int] = mapped_column(Integer, nullable=False) target_table: Mapped[str] = mapped_column(String(50), nullable=False) + + +class StatusHistoryMixin: + """ + Mixin for models that can have a status history (e.g., Thing, Location). + It automatically creates a polymorphic One-to-Many relationship to the + StatusHistory table. + """ + + @declared_attr + def status_history(cls): + return relationship( + "StatusHistory", + primaryjoin=and_( + cls.id == foreign(StatusHistory.target_id), + StatusHistory.target_table == pascal_to_snake(cls.__name__), + ), + cascade="all, delete-orphan", + lazy="selectin", + ) diff --git a/db/thing.py b/db/thing.py index 1003a878b..5822ea161 100644 --- a/db/thing.py +++ b/db/thing.py @@ -26,9 +26,9 @@ AutoBaseMixin, Base, ReleaseMixin, - StatusHistoryMixin, PermissionMixin, ) +from db.status_history import StatusHistoryMixin if TYPE_CHECKING: from db.location import Location diff --git a/tests/__init__.py b/tests/__init__.py index ed7fe4ea8..cb25edfd1 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -133,6 +133,11 @@ def retrieve_latest_polymorphic_table_record( The specific type of the polymorphic record to retrieve (e.g., 'Use Status' or 'Monitoring Status' for StatusHistory). latest : bool, optional If True, retrieves the latest record based on start_date. Defaults to True. + + Returns + ------- + Base + The latest record from the specified polymorphic table. """ if polymorphic_relationship == "permissions": type_field = "permission_type" diff --git a/tests/features/environment.py b/tests/features/environment.py index 08ed37010..627248bd3 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -288,7 +288,7 @@ def before_all(context): end_date=datetime(2021, 1, 1), reason="Initial status", target_id=context.objects["wells"][0].id, - target_table="Thing", + target_table="thing", ) well_status_2 = add_status_history( @@ -300,7 +300,7 @@ def before_all(context): end_date=None, reason="Roving bovine", target_id=context.objects["wells"][0].id, - target_table="Thing", + target_table="thing", ) monitoring_status_1 = add_status_history( @@ -312,7 +312,7 @@ def before_all(context): end_date=datetime(2021, 1, 1), reason="Initial monitoring status", target_id=context.objects["wells"][0].id, - target_table="Thing", + target_table="thing", ) monitoring_status_2 = add_status_history( @@ -324,7 +324,7 @@ def before_all(context): end_date=None, reason="Roving bovine destroyed well", target_id=context.objects["wells"][0].id, - target_table="Thing", + target_table="thing", ) id_link_1 = add_id_link( From bcfff8f838927b9b1befcf578d75245aa143f19f Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 12 Nov 2025 13:06:45 -0700 Subject: [PATCH 26/33] feat: eagerly load measuring point history records --- db/thing.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/db/thing.py b/db/thing.py index 35f7e31c8..dd117b757 100644 --- a/db/thing.py +++ b/db/thing.py @@ -102,11 +102,6 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix well_construction_notes: Mapped[str] = mapped_column(Text, nullable=True) - measuring_point_height: Mapped[float] = mapped_column( - Float, nullable=True, info={"unit": "feet above ground surface"} - ) - measuring_point_description: Mapped[str] = mapped_column(String, nullable=True) - # Spring-related columns spring_type: Mapped[str] = lexicon_term( nullable=True, @@ -243,14 +238,7 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix back_populates="thing", cascade="all, delete-orphan", passive_deletes=True, - ) - - # One-To-Many: A Thing (well) can have multiple measuring points over time. - measuring_points: Mapped[List["MeasuringPointHistory"]] = relationship( - "MeasuringPointHistory", - back_populates="thing", - cascade="all, delete-orphan", - passive_deletes=True, + lazy="joined", ) # --- Association Proxies --- From 7658fb5967bda2cbb974816f44487ad1098f9a9e Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 12 Nov 2025 13:21:08 -0700 Subject: [PATCH 27/33] feat: get mp height/description from latest record --- db/thing.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/db/thing.py b/db/thing.py index dd117b757..06ed4882d 100644 --- a/db/thing.py +++ b/db/thing.py @@ -327,6 +327,38 @@ def monitoring_status(self) -> str | None: return most_recent_status.status_value return None + @property + def measuring_point_height(self) -> int | None: + """ + Returns the most recent measuring point height from the measuring point history + table. This assumes that every well has a measuring point + + Since measuring_point_history is eagerly loaded, this should not introduce N+1 query issues. + """ + if self.thing_type == "water well": + sorted_measuring_point_history = sorted( + self.measuring_points, key=lambda x: x.start_date, reverse=True + ) + return sorted_measuring_point_history[0].measuring_point_height + else: + return None + + @property + def measuring_point_description(self) -> str | None: + """ + Returns the most recent measuring point description from the measuring point history + table. This assumes that every well has a measuring point. + + Since measuring_point_history is eagerly loaded, this should not introduce N+1 query issues. + """ + if self.thing_type == "water well": + sorted_measuring_point_history = sorted( + self.measuring_points, key=lambda x: x.start_date, reverse=True + ) + return sorted_measuring_point_history[0].measuring_point_description + else: + return None + class ThingIdLink(Base, AutoBaseMixin, ReleaseMixin): """ From 2b5d48951d8ab11ad27c343ae91e094ea49f6c6f Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 12 Nov 2025 13:27:15 -0700 Subject: [PATCH 28/33] refactor: use MeasuringPointHistory table for mp data --- db/__init__.py | 1 + tests/features/environment.py | 25 +++++++++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/db/__init__.py b/db/__init__.py index efb23a418..9771aaa1e 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -40,6 +40,7 @@ from db.status_history import * from db.thing import * from db.transducer import * +from db.measuring_point_history import * from sqlalchemy import ( func, diff --git a/tests/features/environment.py b/tests/features/environment.py index 627248bd3..cb38ec34c 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -32,6 +32,7 @@ StatusHistory, ThingIdLink, WellPurpose, + MeasuringPointHistory, ) from db.engine import session_ctx @@ -81,8 +82,6 @@ def add_well(context, session, location, name_num): well_construction_notes="Test well construction notes", well_casing_diameter=5.0, well_casing_depth=10.0, - measuring_point_height=3.0, - measuring_point_description="Test measuring point description", ) session.add(well) session.commit() @@ -109,6 +108,24 @@ def add_well_purpose(context, session, well, purpose_term): return purpose +@add_context_object_container("measuring_point_histories") +def add_measuring_point_history(context, session, well): + mph = MeasuringPointHistory( + thing=well, + measuring_point_height=2, + measuring_point_description="test description", + start_date="2024-01-01", + end_date=None, + reason="Initial measuring point record", + ) + session.add(mph) + session.commit() + session.refresh(mph) + + context.objects["measuring_point_histories"].append(mph) + return mph + + @add_context_object_container("springs") def add_spring(context, session, location, name_num): spring = Thing( @@ -327,6 +344,10 @@ def before_all(context): target_table="thing", ) + measuring_point_history_1 = add_measuring_point_history( + context, session, well=well_1 + ) + id_link_1 = add_id_link( context, session, From f130c4290e2f0cc302b8e6d1048b4a46c2c1f72f Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 12 Nov 2025 13:50:02 -0700 Subject: [PATCH 29/33] feat: implement MonitoringFrequencyHistory table --- db/thing.py | 27 +++++++++++++- schemas/thing.py | 21 +++++++++++ tests/features/environment.py | 37 +++++++++++++++++++ tests/features/steps/well-core-information.py | 14 ++++--- 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/db/thing.py b/db/thing.py index 06ed4882d..f859b0045 100644 --- a/db/thing.py +++ b/db/thing.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== from typing import List, TYPE_CHECKING - +from datetime import date from sqlalchemy import Integer, ForeignKey, String, Column, Float, Text, Date from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy from sqlalchemy.orm import relationship, mapped_column, Mapped @@ -241,6 +241,14 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix lazy="joined", ) + monitoring_frequencies: Mapped[List["MonitoringFrequencyHistory"]] = relationship( + "MonitoringFrequencyHistory", + back_populates="thing", + cascade="all, delete-orphan", + passive_deletes=True, + lazy="joined", + ) + # --- Association Proxies --- assets: AssociationProxy[list["Asset"]] = association_proxy( "asset_associations", "asset" @@ -435,6 +443,23 @@ class WellCasingMaterial(Base, AutoBaseMixin, ReleaseMixin): ) +class MonitoringFrequencyHistory(Base, AutoBaseMixin, ReleaseMixin): + """ + Represents the monitoring frequency history for a Thing. + """ + + thing_id: Mapped[int] = mapped_column( + Integer, ForeignKey("thing.id", ondelete="CASCADE"), nullable=False + ) + monitoring_frequency: Mapped[str] = lexicon_term(nullable=False) + start_date: Mapped[date] = mapped_column(Date, nullable=False) + end_date: Mapped[date] = mapped_column(Date, nullable=True) + + thing: Mapped["Thing"] = relationship( + "Thing", back_populates="monitoring_frequencies" + ) + + # TODO: this could be the model used to handle AMP monitoring # class FieldSamplingAdministation(Base, AutoBaseMixin): # # the thing being monitored diff --git a/schemas/thing.py b/schemas/thing.py index b700ac9cd..e9fc739c9 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -138,6 +138,12 @@ class ThingIdLinkResponse(BaseResponseModel): alternate_organization: Organization +class MonitoringFrequencyResponse(BaseModel): + monitoring_frequency: str + start_date: PastDate + end_date: PastDate | None + + class BaseThingResponse(BaseResponseModel): name: str thing_type: str @@ -146,6 +152,21 @@ class BaseThingResponse(BaseResponseModel): groups: list[GroupResponse] = [] monitoring_status: str | None links: list[ThingIdLinkResponse] = Field(default=[], alias="alternate_ids") + monitoring_frequencies: list[MonitoringFrequencyResponse] = [] + + @field_validator("monitoring_frequencies", mode="before") + def remove_records_with_end_date(cls, monitoring_frequencies): + if monitoring_frequencies is not None: + active_frequencies = [ + { + "monitoring_frequency": freq.monitoring_frequency, + "start_date": freq.start_date.isoformat(), + "end_date": None, + } + for freq in monitoring_frequencies + if freq.end_date is None + ] + return active_frequencies class WellResponse(BaseThingResponse): diff --git a/tests/features/environment.py b/tests/features/environment.py index cb38ec34c..ee52d73d7 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -33,6 +33,7 @@ ThingIdLink, WellPurpose, MeasuringPointHistory, + MonitoringFrequencyHistory, ) from db.engine import session_ctx @@ -126,6 +127,24 @@ def add_measuring_point_history(context, session, well): return mph +@add_context_object_container("monitoring_frequency_histories") +def add_monitoring_frequency_history( + context, session, well, monitoring_frequency, start_date, end_date +): + mfh = MonitoringFrequencyHistory( + thing=well, + monitoring_frequency=monitoring_frequency, + start_date=start_date, + end_date=end_date, + ) + session.add(mfh) + session.commit() + session.refresh(mfh) + + context.objects["monitoring_frequency_histories"].append(mfh) + return mfh + + @add_context_object_container("springs") def add_spring(context, session, location, name_num): spring = Thing( @@ -348,6 +367,24 @@ def before_all(context): context, session, well=well_1 ) + monitoring_frequency_history_1 = add_monitoring_frequency_history( + context, + session, + well=well_1, + monitoring_frequency="Monthly", + start_date="2020-01-01", + end_date="2021-01-01", + ) + + monitoring_frequency_history_2 = add_monitoring_frequency_history( + context, + session, + well=well_1, + monitoring_frequency="Annual", + start_date="2020-01-01", + end_date=None, + ) + id_link_1 = add_id_link( context, session, diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index 833ed98c0..566c66a33 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -102,12 +102,14 @@ def step_impl(context): @then("the response should include the monitoring frequency (new field)") def step_impl(context): - for group in context.water_well_data["groups"]: - assert "monitoring_frequency" in group - assert ( - group["monitoring_frequency"] - == context.objects["groups"][0].monitoring_frequency - ) + assert "monitoring_frequencies" in context.water_well_data + + assert len(context.water_well_data["monitoring_frequencies"]) == 1 + assert context.water_well_data["monitoring_frequencies"][0] == { + "monitoring_frequency": "Annual", + "start_date": "2020-01-01", + "end_date": None, + } @then( From 494486b0cd55558efa861ba3d545400266355eea Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 12 Nov 2025 13:51:35 -0700 Subject: [PATCH 30/33] refactor: remove monitoring frequency from group --- db/group.py | 1 - schemas/group.py | 3 +-- schemas/thing.py | 11 +++++++++-- tests/features/environment.py | 1 - 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/db/group.py b/db/group.py index c6a9d068e..2669e70f7 100644 --- a/db/group.py +++ b/db/group.py @@ -37,7 +37,6 @@ class Group(Base, AutoBaseMixin, ReleaseMixin): Geometry(geometry_type="MULTIPOLYGON", srid=SRID_WGS84, spatial_index=True) ) group_type: Mapped[Optional[str]] = lexicon_term(nullable=True) - monitoring_frequency: Mapped[Optional[str]] = lexicon_term(nullable=True) # Foreign Keys parent_group_id: Mapped[Optional[int]] = mapped_column( diff --git a/schemas/group.py b/schemas/group.py index 3bffff976..e3cc7488c 100644 --- a/schemas/group.py +++ b/schemas/group.py @@ -18,7 +18,7 @@ from pydantic import BaseModel, field_validator, model_validator from typing_extensions import Self -from core.enums import GroupType, MonitoringFrequency +from core.enums import GroupType from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel from services.validation.geospatial import validate_wkt_geometry @@ -57,7 +57,6 @@ class GroupResponse(BaseResponseModel): description: str | None project_area: str | None group_type: GroupType | None - monitoring_frequency: MonitoringFrequency | None parent_group_id: int | None @model_validator(mode="before") diff --git a/schemas/thing.py b/schemas/thing.py index e9fc739c9..39f5c15f6 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -17,7 +17,14 @@ from pydantic import BaseModel, model_validator, PastDate, Field, field_validator -from core.enums import WellPurpose, CasingMaterial, SpringType, ScreenType, Organization +from core.enums import ( + WellPurpose, + CasingMaterial, + SpringType, + ScreenType, + Organization, + MonitoringFrequency, +) from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel from schemas.location import LocationGeoJSONResponse from schemas.group import GroupResponse @@ -139,7 +146,7 @@ class ThingIdLinkResponse(BaseResponseModel): class MonitoringFrequencyResponse(BaseModel): - monitoring_frequency: str + monitoring_frequency: MonitoringFrequency start_date: PastDate end_date: PastDate | None diff --git a/tests/features/environment.py b/tests/features/environment.py index ee52d73d7..c130e3f48 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -199,7 +199,6 @@ def add_group(context, session, things): description="Healy Collaborative Network", project_area=None, group_type="Monitoring Plan", - monitoring_frequency="Quarterly", ) for thing in things: assoc = GroupThingAssociation(group=group, thing=thing) From 9feb596de6858eedc14526b84590b8f08e60f2e4 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 12 Nov 2025 16:27:11 -0700 Subject: [PATCH 31/33] refactor: use function to retrieve polymorphic records --- db/thing.py | 33 +++++++++----------------------- services/util.py | 48 +++++++++++++++++++++++++++++++++++++++++++++++ tests/__init__.py | 7 +++++-- 3 files changed, 62 insertions(+), 26 deletions(-) diff --git a/db/thing.py b/db/thing.py index f859b0045..7b33d8e6d 100644 --- a/db/thing.py +++ b/db/thing.py @@ -30,6 +30,7 @@ ) from db.status_history import StatusHistoryMixin from db.measuring_point_history import MeasuringPointHistory +from services.util import retrieve_latest_polymorphic_table_record if TYPE_CHECKING: from db.location import Location @@ -301,18 +302,10 @@ def well_status(self) -> str | None: Since status_history is eagerly loaded, this should not introduce N+1 query issues. """ - status_entries = [ - status - for status in self.status_history - if status.status_type == "Well Status" and status.end_date is None - ] - if status_entries: - # Sort by start_date descending to get the most recent status out of the filtered entries - most_recent_status = sorted( - status_entries, key=lambda x: x.start_date, reverse=True - )[0] - return most_recent_status.status_value - return None + latest_status = retrieve_latest_polymorphic_table_record( + self, "status_history", "Well Status" + ) + return latest_status.status_value if latest_status else None @property def monitoring_status(self) -> str | None: @@ -322,18 +315,10 @@ def monitoring_status(self) -> str | None: Since status_history is eagerly loaded, this should not introduce N+1 query issues. """ - status_entries = [ - status - for status in self.status_history - if status.status_type == "Monitoring Status" and status.end_date is None - ] - if status_entries: - # Sort by start_date descending to get the most recent status out of the filtered entries - most_recent_status = sorted( - status_entries, key=lambda x: x.start_date, reverse=True - )[0] - return most_recent_status.status_value - return None + latest_status = retrieve_latest_polymorphic_table_record( + self, "status_history", "Monitoring Status" + ) + return latest_status.status_value if latest_status else None @property def measuring_point_height(self) -> int | None: diff --git a/services/util.py b/services/util.py index 03d1ec8fc..c5edee30e 100644 --- a/services/util.py +++ b/services/util.py @@ -3,9 +3,11 @@ from shapely.ops import transform import pyproj import httpx +from sqlalchemy.orm import DeclarativeBase from constants import SRID_WGS84 + TRANSFORMERS = {} METERS_TO_FEET = 3.28084 @@ -130,6 +132,52 @@ def get_epqs_elevation_from_point(lon: float, lat: float) -> float | None: return data["value"] +def retrieve_latest_polymorphic_table_record( + target_record: DeclarativeBase, + polymorphic_relationship: str, + polymorphic_type: str, +) -> DeclarativeBase | None: + """ + Retrieve the latest record from a polymorphic table. This function assumes that the + parent class has the correct mixin to support retrieval via an attribute. This + requires end_date to be None + + Parameters: + ---------- + target_record : DeclarativeBase + The parent record from which to retrieve the polymorphic child record. + polymorphic_relationship : str + The name of the relationship attribute on the parent record that corresponds to the polymorphic table. + polymorphic_type : str + The specific type of the polymorphic record to retrieve (e.g., 'Use Status' or 'Monitoring Status' for StatusHistory). + latest : bool, optional + If True, retrieves the latest record based on start_date. Defaults to True. + + Returns + ------- + DeclarativeBase | None + The latest record from the specified polymorphic table with the defined type if it exists. + """ + if polymorphic_relationship == "permissions": + type_field = "permission_type" + elif polymorphic_relationship == "status_history": + type_field = "status_type" + + polymorphic_records = getattr(target_record, polymorphic_relationship) + type_polymorphic_records = [ + r + for r in polymorphic_records + if getattr(r, type_field) == polymorphic_type and r.end_date is None + ] + sorted_type_polymorphic_records = sorted( + type_polymorphic_records, key=lambda r: r.start_date, reverse=True + ) + if sorted_type_polymorphic_records: + return sorted_type_polymorphic_records[0] + else: + return None + + if __name__ == "__main__": x = -106.904107 y = 34.068198 diff --git a/tests/__init__.py b/tests/__init__.py index cb25edfd1..e8a09db8e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -117,7 +117,7 @@ def retrieve_latest_polymorphic_table_record( target_record: Base, polymorphic_relationship: str, polymorphic_type: str, -) -> Base: +) -> Base | None: """ Retrieve the latest record from a polymorphic table. This function assumes that the parent class has the correct mixin to support retrieval via an attribute. This @@ -153,7 +153,10 @@ def retrieve_latest_polymorphic_table_record( sorted_type_polymorphic_records = sorted( type_polymorphic_records, key=lambda r: r.start_date, reverse=True ) - return sorted_type_polymorphic_records[0] + if sorted_type_polymorphic_records: + return sorted_type_polymorphic_records[0] + else: + return None # ============= EOF ============================================= From 3de8553db756697d8ccc9818b3373265b877007d Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 12 Nov 2025 16:30:11 -0700 Subject: [PATCH 32/33] fix: remove polymorphic record retrieval from tests --- tests/__init__.py | 46 ---------------------------------------------- 1 file changed, 46 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index e8a09db8e..5f06309dc 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -113,50 +113,4 @@ def cleanup_patch_test(model: Base, payload: dict, original_data: Base) -> None: session.commit() -def retrieve_latest_polymorphic_table_record( - target_record: Base, - polymorphic_relationship: str, - polymorphic_type: str, -) -> Base | None: - """ - Retrieve the latest record from a polymorphic table. This function assumes that the - parent class has the correct mixin to support retrieval via an attribute. This - requires end_date to be None - - Parameters: - ---------- - target_record : Base - The parent record from which to retrieve the polymorphic child record. - polymorphic_relationship : str - The name of the relationship attribute on the parent record that corresponds to the polymorphic table. - polymorphic_type : str - The specific type of the polymorphic record to retrieve (e.g., 'Use Status' or 'Monitoring Status' for StatusHistory). - latest : bool, optional - If True, retrieves the latest record based on start_date. Defaults to True. - - Returns - ------- - Base - The latest record from the specified polymorphic table. - """ - if polymorphic_relationship == "permissions": - type_field = "permission_type" - elif polymorphic_relationship == "status_history": - type_field = "status_type" - - polymorphic_records = getattr(target_record, polymorphic_relationship) - type_polymorphic_records = [ - r - for r in polymorphic_records - if getattr(r, type_field) == polymorphic_type and r.end_date is None - ] - sorted_type_polymorphic_records = sorted( - type_polymorphic_records, key=lambda r: r.start_date, reverse=True - ) - if sorted_type_polymorphic_records: - return sorted_type_polymorphic_records[0] - else: - return None - - # ============= EOF ============================================= From 49b3a8c345a06554568b8df6ac94ede346c3fcab Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 12 Nov 2025 16:33:32 -0700 Subject: [PATCH 33/33] refactor: use function to retrieve polymorphic records --- tests/features/steps/well-core-information.py | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index 566c66a33..c4f235135 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -1,5 +1,9 @@ from constants import SRID_WGS84, SRID_UTM_ZONE_13N -from services.util import transform_srid, convert_m_to_ft +from services.util import ( + transform_srid, + convert_m_to_ft, + retrieve_latest_polymorphic_table_record, +) from behave import when, then from geoalchemy2.shape import to_shape @@ -89,15 +93,10 @@ def step_impl(context): def step_impl(context): assert "well_status" in context.water_well_data - status_history = context.objects["wells"][0].status_history - well_status = [ - sh - for sh in status_history - if sh.status_type == "Well Status" and sh.end_date is None - ] - well_status_sorted = sorted(well_status, key=lambda sh: sh.start_date, reverse=True) - - assert context.water_well_data["well_status"] == well_status_sorted[0].status_value + well_status_record = retrieve_latest_polymorphic_table_record( + context.objects["wells"][0], "status_history", "Well Status" + ) + assert context.water_well_data["well_status"] == well_status_record.status_value @then("the response should include the monitoring frequency (new field)") @@ -118,19 +117,12 @@ def step_impl(context): def step_impl(context): assert "monitoring_status" in context.water_well_data - status_history = context.objects["wells"][0].status_history - monitoring_status = [ - sh - for sh in status_history - if sh.status_type == "Monitoring Status" and sh.end_date is None - ] - monitoring_status_sorted = sorted( - monitoring_status, key=lambda sh: sh.start_date, reverse=True + monitoring_status_record = retrieve_latest_polymorphic_table_record( + context.objects["wells"][0], "status_history", "Monitoring Status" ) - assert ( context.water_well_data["monitoring_status"] - == monitoring_status_sorted[0].status_value + == monitoring_status_record.status_value )