From 13f2a45f8cdb2297c4eacb5bd33016a363fbb6f8 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 13 Nov 2025 09:25:38 -0700 Subject: [PATCH 01/28] refactor: rename Permission -> PermissionHistory | move mixin to same file --- db/base.py | 19 -------- db/permission.py | 82 ---------------------------------- db/permission_history.py | 96 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 101 deletions(-) delete mode 100644 db/permission.py create mode 100644 db/permission_history.py diff --git a/db/base.py b/db/base.py index ba2a45be8..65d8de0dc 100644 --- a/db/base.py +++ b/db/base.py @@ -191,25 +191,6 @@ def status_history(self): ) -class PermissionMixin: - """ - Mixin for models that can have permissions (e.g., Thing, Location). - It automatically creates a polymorphic One-to-Many relationship to the - Permission table. - """ - - @declared_attr - def permissions(self): - # One-to-Many polymorphic relationship - return relationship( - "Permission", - primaryjoin=f"and_({self.__name__}.id==foreign(Permission.permissible_id), " - f"Permission.permissible_type=='{self.__name__}')", - lazy="selectin", - viewonly=True, - ) - - class User(Base): """Represents a user in the system.""" diff --git a/db/permission.py b/db/permission.py deleted file mode 100644 index 340e587f7..000000000 --- a/db/permission.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -models/permission.py - -This model defines the `Permission` table, a polymorphic table that tracks -all legal and administrative agreements related to site access and activity. -Its purpose is to track who granted permission, what activities they authorized, -which entity the permission applies to, and for what period of time. -""" - -from typing import TYPE_CHECKING - -from sqlalchemy import ( - Integer, - ForeignKey, - String, - Boolean, - Date, - Text, -) -from sqlalchemy.orm import relationship, Mapped, mapped_column - -from db.base import Base, AutoBaseMixin, ReleaseMixin - - -if TYPE_CHECKING: - from db.contact import Contact - from db.thing import Thing - from db.location import Location - - -class Permission(Base, AutoBaseMixin, ReleaseMixin): - """ - Represents a specific grant of permission from a Contact for a - specific entity (e.g., a Thing or Location). - """ - - # --- Foreign Keys --- - contact_id: Mapped[int] = mapped_column( - Integer, ForeignKey("contact.id"), nullable=False - ) - - # --- Columns --- - allow_sampling: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - allow_installation: Mapped[bool] = mapped_column( - Boolean, nullable=False, default=False - ) - start_date: Mapped[Date] = mapped_column(Date, nullable=True) - end_date: Mapped[Date] = mapped_column(Date, nullable=True) - notes: Mapped[str] = mapped_column(Text, nullable=True) - - # --- Polymorphic Columns --- - permissible_id: Mapped[int] = mapped_column(Integer, nullable=False) - permissible_type: Mapped[str] = mapped_column(String(50), nullable=False) - - # --- Relationships --- - # Many-To-One: A Permission is granted by one Contact. - contact: Mapped["Contact"] = relationship("Contact", back_populates="permissions") - - # --- Polymorphic Parent Relationships (Internal) --- - # These are view-only relationships used by the 'target' property below. - # They tell SQLAlchemy exactly how to find the specific parent record for a given child. - _thing_target: Mapped["Thing"] = relationship( - "Thing", - primaryjoin="and_(foreign(Permission.permissible_id) == Thing.id, " - "Permission.permissible_type == 'Thing')", - viewonly=True, - ) - _location_target: Mapped["Location"] = relationship( - "Location", - primaryjoin="and_(foreign(Permission.permissible_id) == Location.id, " - "Permission.permissible_type == 'Location')", - viewonly=True, - ) - - @property - def target(self): - """ - A generic property to get the parent object (Thing, Location, etc.). - This is useful for simplifying application code by providing a single, - consistent way to access the parent of a polymorphic record. - """ - return getattr(self, f"_{self.permissible_type.lower()}_target") diff --git a/db/permission_history.py b/db/permission_history.py new file mode 100644 index 000000000..61c93eaae --- /dev/null +++ b/db/permission_history.py @@ -0,0 +1,96 @@ +""" +models/permission.py + +This model defines the `Permission` table, a polymorphic table that tracks +all legal and administrative agreements related to site access and activity. +Its purpose is to track who granted permission, what activities they authorized, +which entity the permission applies to, and for what period of time. +""" + +from typing import TYPE_CHECKING +from datetime import date +from sqlalchemy import Integer, ForeignKey, String, and_ +from sqlalchemy.orm import relationship, Mapped, mapped_column, declared_attr, foreign + +from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term, pascal_to_snake + + +if TYPE_CHECKING: + from db.contact import Contact + from db.thing import Thing + from db.location import Location + + +class PermissionHistory(Base, AutoBaseMixin, ReleaseMixin): + """ + Represents a specific grant of permission from a Contact for a + specific entity (e.g., a Thing or Location). + """ + + # --- Foreign Keys --- + contact_id: Mapped[int] = mapped_column( + Integer, ForeignKey("contact.id"), nullable=False + ) + + # --- Columns --- + permission_type: Mapped[str] = lexicon_term(nullable=False) + permission_allowed: Mapped[bool] = mapped_column(nullable=False, default=False) + start_date: Mapped[date] = mapped_column(nullable=False) + end_date: Mapped[date] = mapped_column(nullable=True) + notes: Mapped[str] = mapped_column(nullable=True) + + # --- Polymorphic Columns --- + target_id: Mapped[int] = mapped_column(nullable=False) + target_table: Mapped[str] = mapped_column(String(50), nullable=False) + + # --- Relationships --- + # Many-To-One: A Permission is granted by one Contact. + contact: Mapped["Contact"] = relationship("Contact", back_populates="permissions") + + # --- Polymorphic Parent Relationships (Internal) --- + # These are view-only relationships used by the 'target' property below. + # They tell SQLAlchemy exactly how to find the specific parent record for a given child. + _thing_target: Mapped["Thing"] = relationship( + "Thing", + primaryjoin="and_(foreign(PermissionHistory.target_id) == thing.id, " + "PermissionHistory.target_table == 'thing')", + viewonly=True, + ) + _location_target: Mapped["Location"] = relationship( + "Location", + primaryjoin="and_(foreign(PermissionHistory.target_id) == location.id, " + "PermissionHistory.target_table == 'location')", + viewonly=True, + ) + + @property + def target(self): + """ + A generic property to get the parent object (Thing, Location, etc.). + This is useful for simplifying application code by providing a single, + consistent way to access the parent of a polymorphic record. + """ + return getattr(self, f"_{self.target_table}_target") + + +class PermissionHistoryMixin: + """ + Mixin for models that can have permissions (e.g., Thing, Location). + It automatically creates a polymorphic One-to-Many relationship to the + Permission table. + """ + + @declared_attr + def permissions(self): + # One-to-Many polymorphic relationship + return relationship( + "PermissionHistory", + primaryjoin=( + and_( + self.__name__.id == foreign(PermissionHistory.target_id), + PermissionHistory.target_table == pascal_to_snake(self.__name__), + ) + ), + lazy="selectin", + viewonly=True, + ) From 6e036e5fb3b55276396a535c1e3fb739f00b66fc Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 13 Nov 2025 09:26:03 -0700 Subject: [PATCH 02/28] feat: add permission types to lexicon --- core/lexicon.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/lexicon.json b/core/lexicon.json index d23272a4d..e1e87a316 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -49,6 +49,7 @@ {"name": "well_purpose", "description": null}, {"name": "well_status", "description": null}, {"name": "well_pump_type", "description": null} + {"name": "permission_type", "description": null} ], "terms": [ {"categories": ["review_status"], "term": "approved", "definition": "approved"}, @@ -679,5 +680,8 @@ {"categories": ["well_pump_type"], "term": "Jet Pump", "definition": "Jet Pump"}, {"categories": ["well_pump_type"], "term": "Line Shaft", "definition": "Line Shaft"}, {"categories": ["well_pump_type"], "term": "Hand Pump", "definition": "Hand Pump"} + {"categories": ["permission_type"], "term": "Water Level Sample", "definition": "Permissions for taking water level samples"}, + {"categories": ["permission_type"], "term": "Chemistry Sample", "definition": "Permissions for taking chemistry samples"}, + {"categories": ["permission_type"], "term": "Datalogger Installation", "definition": "Permissions for installing dataloggers"} ] } \ No newline at end of file From 091637c2adbd1922c0f9527acad71933a19ca556 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 13 Nov 2025 09:29:29 -0700 Subject: [PATCH 03/28] refactor: import PermissionHistoryMixin from correct dir --- db/thing.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/db/thing.py b/db/thing.py index 25506cb95..9dd0d6de3 100644 --- a/db/thing.py +++ b/db/thing.py @@ -27,8 +27,8 @@ Base, ReleaseMixin, StatusHistoryMixin, - PermissionMixin, ) +from db.permission_history import PermissionHistoryMixin if TYPE_CHECKING: from db.location import Location @@ -39,7 +39,9 @@ from db.group import Group, GroupThingAssociation -class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMixin): +class Thing( + Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionHistoryMixin +): """ Represents a physical object of interest being monitored (e.g., a well). Stores static, core attributes of the physical installation. From 71d8534a73e58c34b0164d96e2e5910089d2b9b5 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 13 Nov 2025 09:32:05 -0700 Subject: [PATCH 04/28] fix: fix imports for newly renamed PermissionHistory --- db/__init__.py | 2 +- db/contact.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/db/__init__.py b/db/__init__.py index efb23a418..f61a39dca 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -32,7 +32,7 @@ from db.location import * from db.observation import * from db.parameter import * -from db.permission import * +from db.permission_history import * from db.publication import * from db.regulatory_limit import * from db.sample import * diff --git a/db/contact.py b/db/contact.py index 7855814fb..eb15af848 100644 --- a/db/contact.py +++ b/db/contact.py @@ -26,7 +26,7 @@ from db.field import FieldEventParticipant, FieldEvent from db.thing import Thing from db.publication import Author, AuthorContactAssociation - from db.permission import Permission + from db.permission_history import PermissionHistory class ThingContactAssociation(Base, AutoBaseMixin): @@ -74,7 +74,7 @@ class Contact(Base, AutoBaseMixin, ReleaseMixin): ) # One-To-Many: A Contact can grant many Permissions. - permissions: Mapped[List["Permission"]] = relationship( + permissions: Mapped[List["PermissionHistory"]] = relationship( "Permission", back_populates="contact", cascade="all, delete, delete-orphan" ) # One-To-Many: A Contact can be associated with many Authors (in Publications). From 825ec4a145d77a2eeb5ec4b85982e7e028217620 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 13 Nov 2025 13:02:30 -0700 Subject: [PATCH 05/28] feat: add permission_type to lexicon --- core/enums.py | 1 + core/lexicon.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/core/enums.py b/core/enums.py index 5833d97bc..25c6d746b 100644 --- a/core/enums.py +++ b/core/enums.py @@ -70,4 +70,5 @@ ScreenType: type[Enum] = build_enum_from_lexicon_category("screen_type") SensorType: type[Enum] = build_enum_from_lexicon_category("sensor_type") WellPumpType: type[Enum] = build_enum_from_lexicon_category("well_pump_type") +PermissionType: type[Enum] = build_enum_from_lexicon_category("permission_type") # ============= EOF ============================================= diff --git a/core/lexicon.json b/core/lexicon.json index e1e87a316..1256aeca9 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -48,7 +48,7 @@ {"name": "vertical_datum", "description": null}, {"name": "well_purpose", "description": null}, {"name": "well_status", "description": null}, - {"name": "well_pump_type", "description": null} + {"name": "well_pump_type", "description": null}, {"name": "permission_type", "description": null} ], "terms": [ @@ -679,7 +679,7 @@ {"categories": ["well_pump_type"], "term": "Submersible", "definition": "Submersible"}, {"categories": ["well_pump_type"], "term": "Jet Pump", "definition": "Jet Pump"}, {"categories": ["well_pump_type"], "term": "Line Shaft", "definition": "Line Shaft"}, - {"categories": ["well_pump_type"], "term": "Hand Pump", "definition": "Hand Pump"} + {"categories": ["well_pump_type"], "term": "Hand Pump", "definition": "Hand Pump"}, {"categories": ["permission_type"], "term": "Water Level Sample", "definition": "Permissions for taking water level samples"}, {"categories": ["permission_type"], "term": "Chemistry Sample", "definition": "Permissions for taking chemistry samples"}, {"categories": ["permission_type"], "term": "Datalogger Installation", "definition": "Permissions for installing dataloggers"} From 8ed0ce444c2a1e1e810d4aaf7f6103b490c95649 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 13 Nov 2025 13:08:34 -0700 Subject: [PATCH 06/28] feat: update util to correspond with bdms 221 --- tests/__init__.py | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index ed7fe4ea8..5f06309dc 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -113,42 +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: - """ - 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. - """ - 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 - ) - return sorted_type_polymorphic_records[0] - - # ============= EOF ============================================= From 3b36c49afd1d398cce764f87d48457163e3beb9d Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 13 Nov 2025 13:35:49 -0700 Subject: [PATCH 07/28] fix: sync bdms 221/227 for util functions --- core/lexicon.json | 2 +- db/contact.py | 4 +- db/permission_history.py | 6 +- services/util.py | 51 ++++++++--- tests/features/environment.py | 88 +++++++++++++++++++ .../steps/well-additional-information.py | 19 ++-- 6 files changed, 138 insertions(+), 32 deletions(-) diff --git a/core/lexicon.json b/core/lexicon.json index 1256aeca9..153dacded 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -681,7 +681,7 @@ {"categories": ["well_pump_type"], "term": "Line Shaft", "definition": "Line Shaft"}, {"categories": ["well_pump_type"], "term": "Hand Pump", "definition": "Hand Pump"}, {"categories": ["permission_type"], "term": "Water Level Sample", "definition": "Permissions for taking water level samples"}, - {"categories": ["permission_type"], "term": "Chemistry Sample", "definition": "Permissions for taking chemistry samples"}, + {"categories": ["permission_type"], "term": "Water Chemistry Sample", "definition": "Permissions for water taking chemistry samples"}, {"categories": ["permission_type"], "term": "Datalogger Installation", "definition": "Permissions for installing dataloggers"} ] } \ No newline at end of file diff --git a/db/contact.py b/db/contact.py index eb15af848..558724df9 100644 --- a/db/contact.py +++ b/db/contact.py @@ -75,7 +75,9 @@ class Contact(Base, AutoBaseMixin, ReleaseMixin): # One-To-Many: A Contact can grant many Permissions. permissions: Mapped[List["PermissionHistory"]] = relationship( - "Permission", back_populates="contact", cascade="all, delete, delete-orphan" + "PermissionHistory", + back_populates="contact", + cascade="all, delete, delete-orphan", ) # One-To-Many: A Contact can be associated with many Authors (in Publications). author_associations: Mapped[List["AuthorContactAssociation"]] = relationship( diff --git a/db/permission_history.py b/db/permission_history.py index 61c93eaae..5faa3e3fa 100644 --- a/db/permission_history.py +++ b/db/permission_history.py @@ -81,14 +81,14 @@ class PermissionHistoryMixin: """ @declared_attr - def permissions(self): + def permissions(cls): # One-to-Many polymorphic relationship return relationship( "PermissionHistory", primaryjoin=( and_( - self.__name__.id == foreign(PermissionHistory.target_id), - PermissionHistory.target_table == pascal_to_snake(self.__name__), + cls.id == foreign(PermissionHistory.target_id), + PermissionHistory.target_table == pascal_to_snake(cls.__name__), ) ), lazy="selectin", diff --git a/services/util.py b/services/util.py index 36c1bf7a6..c5edee30e 100644 --- a/services/util.py +++ b/services/util.py @@ -3,11 +3,13 @@ from shapely.ops import transform import pyproj import httpx +from sqlalchemy.orm import DeclarativeBase from constants import SRID_WGS84 -from db import Base + TRANSFORMERS = {} +METERS_TO_FEET = 3.28084 def transform_srid(geometry, source_srid, target_srid): @@ -27,6 +29,20 @@ def transform_srid(geometry, source_srid, target_srid): return transform(transformer.transform, geometry) +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( lon: float, lat: float, layer: int, outfields: str = "*" ) -> dict | None: @@ -116,29 +132,31 @@ def get_epqs_elevation_from_point(lon: float, lat: float) -> float | None: return data["value"] -def retrieve_polymorphic_table_record( - target_record: Base, +def retrieve_latest_polymorphic_table_record( + target_record: DeclarativeBase, polymorphic_relationship: str, polymorphic_type: str, - latest=True, -) -> Base: +) -> DeclarativeBase | None: """ - Retrieve a record from a polymorphic table. This function assumes that the - parent class has the correct mixin to support retrieval via an attribute. + 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 + 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" @@ -147,12 +165,17 @@ def retrieve_polymorphic_table_record( polymorphic_records = getattr(target_record, polymorphic_relationship) type_polymorphic_records = [ - r for r in polymorphic_records if getattr(r, type_field) == polymorphic_type + 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=latest + 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 if __name__ == "__main__": diff --git a/tests/features/environment.py b/tests/features/environment.py index effd332d1..b27391736 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -30,6 +30,8 @@ Deployment, TransducerObservationBlock, WellCasingMaterial, + PermissionHistory, + Contact, ) from db.engine import session_ctx @@ -139,6 +141,54 @@ def add_spring(context, session, location, name_num): return spring +@add_context_object_container("contacts") +def add_contact(context, session): + contact = Contact( + name="Test Contact", + role="Field Technician", + organization="NMBGMR", + release_status="draft", + contact_type="Primary", + ) + session.add(contact) + session.commit() + session.refresh(contact) + + context.objects["contacts"].append(contact) + return contact + + +@add_context_object_container("permission_histories") +def add_permission_history( + context, + session, + contact_id, + permission_type, + permission_allowed, + start_date, + end_date, + notes, + target_id, + target_table, +): + permission_history = PermissionHistory( + contact_id=contact_id, + permission_type=permission_type, + permission_allowed=permission_allowed, + start_date=start_date, + end_date=end_date, + notes=notes, + target_id=target_id, + target_table=target_table, + ) + session.add(permission_history) + session.commit() + session.refresh(permission_history) + + context.objects["permission_histories"].append(permission_history) + return permission_history + + @add_context_object_container("sensors") def add_sensor(context, session, sid): sensor = Sensor( @@ -231,6 +281,44 @@ def before_all(context): add_well_casing_material(context, session, well_1) + add_contact(context, session) + add_permission_history( + context, + session, + contact_id=context.objects["contacts"][0].id, + permission_type="Datalogger Installation", + permission_allowed=True, + start_date=datetime(2025, 1, 1).date(), + end_date=None, + notes="Permission granted for datalogger installation.", + target_id=well_1.id, + target_table="thing", + ) + add_permission_history( + context, + session, + contact_id=context.objects["contacts"][0].id, + permission_type="Water Level Sample", + permission_allowed=True, + start_date=datetime(2025, 1, 1).date(), + end_date=None, + notes="Permission granted for water level sampling.", + target_id=well_1.id, + target_table="thing", + ) + add_permission_history( + context, + session, + contact_id=context.objects["contacts"][0].id, + permission_type="Chemistry Sample", + permission_allowed=False, + start_date=datetime(2025, 1, 1).date(), + end_date=None, + notes="Permission granted for chemistry sampling.", + target_id=well_1.id, + target_table="thing", + ) + # 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-additional-information.py b/tests/features/steps/well-additional-information.py index e606d95ff..e57705553 100644 --- a/tests/features/steps/well-additional-information.py +++ b/tests/features/steps/well-additional-information.py @@ -22,12 +22,6 @@ def step_impl(context): # ------------------------------------------------------------------------------ # Permissions / Operational OK flags # ------------------------------------------------------------------------------ -# TODO: the API needs to be updated to include Permissions -# TODO: the schema and test data need to be updated -# TODO: should the testing data and tests contain multiple permissions, one that has expired? -# TODO: what are the permission_types that will be used? after they have been determined update these tests - - @then( "the response should include whether repeat measurement permission is granted for the well" ) @@ -35,11 +29,11 @@ def step_impl(context): assert "permissions" in context.data permission_record = retrieve_polymorphic_table_record( - context.well, "permissions", "allow_water_level_measurements", latest=True + context.well, "permissions", "Water Level Sample", latest=True ) assert ( - context.data["permissions"]["allow_water_level_measurements"] + context.data["permissions"]["allow_water_level_samples"] == permission_record.permission_allowed ) @@ -49,16 +43,15 @@ def step_impl(context): assert "permissions" in context.data permission_record = retrieve_polymorphic_table_record( - context.well, "permissions", "allow_water_chemistry_sample", latest=True + context.well, "permissions", "Water Chemistry Sample", latest=True ) assert ( - context.data["permissions"]["allow_sampling"] + context.data["permissions"]["allow_water_chemistry_samples"] == permission_record.permission_allowed ) -# TODO: should this be datalogger specific? @then( "the response should include whether datalogger installation permission is granted for the well" ) @@ -66,11 +59,11 @@ def step_impl(context): assert "permissions" in context.data permission_record = retrieve_polymorphic_table_record( - context.well, "permissions", "allow_data_logger_installation", latest=True + context.well, "permissions", "Datalogger Installation", latest=True ) assert ( - context.data["permissions"]["allow_data_logger_installation"] + context.data["permissions"]["allow_datalogger_installation"] == permission_record.permission_allowed ) From 1657bb9f1730c2dcea8571e12350c889dd904594 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 13 Nov 2025 15:27:31 -0700 Subject: [PATCH 08/28] feat: implement permissions --- db/permission_history.py | 8 ++--- db/thing.py | 31 +++++++++++++++++++ schemas/thing.py | 3 ++ services/util.py | 3 +- tests/features/environment.py | 14 +++++---- .../steps/well-additional-information.py | 28 ++++++++--------- 6 files changed, 60 insertions(+), 27 deletions(-) diff --git a/db/permission_history.py b/db/permission_history.py index 5faa3e3fa..7c9c37159 100644 --- a/db/permission_history.py +++ b/db/permission_history.py @@ -29,7 +29,7 @@ class PermissionHistory(Base, AutoBaseMixin, ReleaseMixin): # --- Foreign Keys --- contact_id: Mapped[int] = mapped_column( - Integer, ForeignKey("contact.id"), nullable=False + Integer, ForeignKey("contact.id", ondelete="CASCADE"), nullable=False ) # --- Columns --- @@ -52,13 +52,13 @@ class PermissionHistory(Base, AutoBaseMixin, ReleaseMixin): # They tell SQLAlchemy exactly how to find the specific parent record for a given child. _thing_target: Mapped["Thing"] = relationship( "Thing", - primaryjoin="and_(foreign(PermissionHistory.target_id) == thing.id, " + primaryjoin="and_(foreign(PermissionHistory.target_id) == Thing.id, " "PermissionHistory.target_table == 'thing')", viewonly=True, ) _location_target: Mapped["Location"] = relationship( "Location", - primaryjoin="and_(foreign(PermissionHistory.target_id) == location.id, " + primaryjoin="and_(foreign(PermissionHistory.target_id) == Location.id, " "PermissionHistory.target_table == 'location')", viewonly=True, ) @@ -81,7 +81,7 @@ class PermissionHistoryMixin: """ @declared_attr - def permissions(cls): + def permission_history(cls): # One-to-Many polymorphic relationship return relationship( "PermissionHistory", diff --git a/db/thing.py b/db/thing.py index 9dd0d6de3..a6eab4aa5 100644 --- a/db/thing.py +++ b/db/thing.py @@ -29,6 +29,7 @@ StatusHistoryMixin, ) from db.permission_history import PermissionHistoryMixin +from services.util import retrieve_latest_polymorphic_table_record if TYPE_CHECKING: from db.location import Location @@ -291,6 +292,36 @@ def current_location(self): else None ) + @property + def allow_water_level_samples(self): + """ + Returns the current permissions for the Thing. + """ + permission_record = retrieve_latest_polymorphic_table_record( + self, "permission_history", "Water Level Sample" + ) + return permission_record.permission_allowed if permission_record else None + + @property + def allow_water_chemistry_samples(self): + """ + Returns the current permissions for the Thing. + """ + permission_record = retrieve_latest_polymorphic_table_record( + self, "permission_history", "Water Chemistry Sample" + ) + return permission_record.permission_allowed if permission_record else None + + @property + def allow_datalogger_installation(self): + """ + Returns the current permissions for the Thing. + """ + permission_record = retrieve_latest_polymorphic_table_record( + self, "permission_history", "Datalogger Installation" + ) + return permission_record.permission_allowed if permission_record else None + class ThingIdLink(Base, AutoBaseMixin, ReleaseMixin): """ diff --git a/schemas/thing.py b/schemas/thing.py index d87fd299f..dea04430a 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -166,6 +166,9 @@ class WellResponse(BaseThingResponse): well_pump_type: WellPumpType | None well_pump_depth: float | None well_pump_depth_unit: str = "ft" + allow_water_level_samples: bool | None + allow_water_chemistry_samples: bool | None + allow_datalogger_installation: bool | None @field_validator("well_purposes", mode="before") def populate_well_purposes_with_strings(cls, well_purposes): diff --git a/services/util.py b/services/util.py index c5edee30e..0d618dfff 100644 --- a/services/util.py +++ b/services/util.py @@ -158,11 +158,10 @@ def retrieve_latest_polymorphic_table_record( DeclarativeBase | None The latest record from the specified polymorphic table with the defined type if it exists. """ - if polymorphic_relationship == "permissions": + if polymorphic_relationship == "permission_history": type_field = "permission_type" elif polymorphic_relationship == "status_history": type_field = "status_type" - polymorphic_records = getattr(target_record, polymorphic_relationship) type_polymorphic_records = [ r diff --git a/tests/features/environment.py b/tests/features/environment.py index b27391736..3fcc4930e 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -145,7 +145,7 @@ def add_spring(context, session, location, name_num): def add_contact(context, session): contact = Contact( name="Test Contact", - role="Field Technician", + role="Software Developer", organization="NMBGMR", release_status="draft", contact_type="Primary", @@ -281,11 +281,11 @@ def before_all(context): add_well_casing_material(context, session, well_1) - add_contact(context, session) + contact = add_contact(context, session) add_permission_history( context, session, - contact_id=context.objects["contacts"][0].id, + contact_id=contact.id, permission_type="Datalogger Installation", permission_allowed=True, start_date=datetime(2025, 1, 1).date(), @@ -310,7 +310,7 @@ def before_all(context): context, session, contact_id=context.objects["contacts"][0].id, - permission_type="Chemistry Sample", + permission_type="Water Chemistry Sample", permission_allowed=False, start_date=datetime(2025, 1, 1).date(), end_date=None, @@ -338,8 +338,10 @@ def before_all(context): def after_all(context): with session_ctx() as session: for table in context.objects.values(): - for obj in table: - session.delete(obj) + for record in table: + obj = session.get(record.__class__, record.id) + if obj: + session.delete(obj) session.commit() diff --git a/tests/features/steps/well-additional-information.py b/tests/features/steps/well-additional-information.py index e57705553..80d6389da 100644 --- a/tests/features/steps/well-additional-information.py +++ b/tests/features/steps/well-additional-information.py @@ -1,6 +1,6 @@ from behave import when, then -from services.util import retrieve_polymorphic_table_record +from services.util import retrieve_latest_polymorphic_table_record @when("the user retrieves the well by ID via path parameter") @@ -26,28 +26,26 @@ def step_impl(context): "the response should include whether repeat measurement permission is granted for the well" ) def step_impl(context): - assert "permissions" in context.data - - permission_record = retrieve_polymorphic_table_record( - context.well, "permissions", "Water Level Sample", latest=True + assert "allow_water_level_samples" in context.data + permission_record = retrieve_latest_polymorphic_table_record( + context.well, "permission_history", "Water Level Sample" ) - assert ( - context.data["permissions"]["allow_water_level_samples"] + context.data["allow_water_level_samples"] == permission_record.permission_allowed ) @then("the response should include whether sampling permission is granted for the well") def step_impl(context): - assert "permissions" in context.data + assert "allow_water_chemistry_samples" in context.data - permission_record = retrieve_polymorphic_table_record( - context.well, "permissions", "Water Chemistry Sample", latest=True + permission_record = retrieve_latest_polymorphic_table_record( + context.well, "permission_history", "Water Chemistry Sample" ) assert ( - context.data["permissions"]["allow_water_chemistry_samples"] + context.data["allow_water_chemistry_samples"] == permission_record.permission_allowed ) @@ -56,14 +54,14 @@ def step_impl(context): "the response should include whether datalogger installation permission is granted for the well" ) def step_impl(context): - assert "permissions" in context.data + assert "allow_datalogger_installation" in context.data - permission_record = retrieve_polymorphic_table_record( - context.well, "permissions", "Datalogger Installation", latest=True + permission_record = retrieve_latest_polymorphic_table_record( + context.well, "permission_history", "Datalogger Installation" ) assert ( - context.data["permissions"]["allow_datalogger_installation"] + context.data["allow_datalogger_installation"] == permission_record.permission_allowed ) From 307f47cb5092fc76ffa24ce3610f2db1095698f4 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 13 Nov 2025 16:18:53 -0700 Subject: [PATCH 09/28] feat: add is_suitable_for_datalogger to thing --- db/thing.py | 5 +++++ schemas/thing.py | 1 + tests/features/environment.py | 1 + tests/features/steps/well-additional-information.py | 9 +++++---- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/db/thing.py b/db/thing.py index a6eab4aa5..22f106999 100644 --- a/db/thing.py +++ b/db/thing.py @@ -118,6 +118,11 @@ class Thing( info={"unit": "feet below ground surface"}, comment="Depth of the well pump from ground surface to the pump intake (in feet).", ) + # TODO: should this be required for every well in the database? AMMP review + is_suitable_for_datalogger: Mapped[bool] = mapped_column( + nullable=True, + comment="Indicates if the well is suitable for datalogger installation.", + ) # Spring-related columns spring_type: Mapped[str] = lexicon_term( diff --git a/schemas/thing.py b/schemas/thing.py index dea04430a..3e8c07e68 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -169,6 +169,7 @@ class WellResponse(BaseThingResponse): allow_water_level_samples: bool | None allow_water_chemistry_samples: bool | None allow_datalogger_installation: bool | None + is_suitable_for_datalogger: bool | None @field_validator("well_purposes", mode="before") def populate_well_purposes_with_strings(cls, well_purposes): diff --git a/tests/features/environment.py b/tests/features/environment.py index 3fcc4930e..73db99810 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -86,6 +86,7 @@ def add_well(context, session, location, name_num): well_construction_method="Driven", well_pump_type="Submersible", well_pump_depth=8, + is_suitable_for_datalogger=True, ) session.add(well) session.commit() diff --git a/tests/features/steps/well-additional-information.py b/tests/features/steps/well-additional-information.py index 80d6389da..c4b071fd5 100644 --- a/tests/features/steps/well-additional-information.py +++ b/tests/features/steps/well-additional-information.py @@ -158,14 +158,15 @@ def step_impl(context): assert context.data["well_pump_depth_unit"] == "ft" -# TODO: needs to be added to model, schemas, test data @then( "the response should include whether the well is open and suitable for a datalogger" ) def step_impl(context): - data = context.response.json() - assert data["well_open"] is True - assert data["well_suitable_for_datalogger"] is True + assert "is_suitable_for_datalogger" in context.data + assert ( + context.data["is_suitable_for_datalogger"] + == context.well.is_suitable_for_datalogger + ) # ------------------------------------------------------------------------------ From b4c8beb604bf53c36a69da80c315932544fa3f8b Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 19 Nov 2025 11:50:19 -0700 Subject: [PATCH 10/28] fix: fix artifacts from merge conflicts --- db/thing.py | 8 +- tests/features/environment.py | 3 +- .../steps/well-additional-information.py | 74 +++++++++++-------- 3 files changed, 50 insertions(+), 35 deletions(-) diff --git a/db/thing.py b/db/thing.py index 53cb88df0..7ee3c9cba 100644 --- a/db/thing.py +++ b/db/thing.py @@ -28,7 +28,7 @@ ReleaseMixin, ) from db.permission_history import PermissionHistoryMixin -from services.util import retrieve_latest_polymorphic_table_record +from services.util import retrieve_latest_polymorphic_history_table_record from db.status_history import StatusHistoryMixin from db.measuring_point_history import MeasuringPointHistory from db.data_provenance import DataProvenanceMixin @@ -403,7 +403,7 @@ def allow_water_level_samples(self): """ Returns the current permissions for the Thing. """ - permission_record = retrieve_latest_polymorphic_table_record( + permission_record = retrieve_latest_polymorphic_history_table_record( self, "permission_history", "Water Level Sample" ) return permission_record.permission_allowed if permission_record else None @@ -413,7 +413,7 @@ def allow_water_chemistry_samples(self): """ Returns the current permissions for the Thing. """ - permission_record = retrieve_latest_polymorphic_table_record( + permission_record = retrieve_latest_polymorphic_history_table_record( self, "permission_history", "Water Chemistry Sample" ) return permission_record.permission_allowed if permission_record else None @@ -423,7 +423,7 @@ def allow_datalogger_installation(self): """ Returns the current permissions for the Thing. """ - permission_record = retrieve_latest_polymorphic_table_record( + permission_record = retrieve_latest_polymorphic_history_table_record( self, "permission_history", "Datalogger Installation" ) return permission_record.permission_allowed if permission_record else None diff --git a/tests/features/environment.py b/tests/features/environment.py index c56bb0e86..289d0b0f6 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -448,7 +448,7 @@ def before_all(context): add_permission_history( context, session, - contact_id=contact.id, + contact_id=context.objects["contacts"][0].id, permission_type="Datalogger Installation", permission_allowed=True, start_date=datetime(2025, 1, 1).date(), @@ -618,7 +618,6 @@ def before_all(context): ) session.commit() - session.refresh(well_1) # the following needs to be refreshed to get all the new relationships session.refresh(well_1) diff --git a/tests/features/steps/well-additional-information.py b/tests/features/steps/well-additional-information.py index 2baa32db6..1dc5c3518 100644 --- a/tests/features/steps/well-additional-information.py +++ b/tests/features/steps/well-additional-information.py @@ -12,7 +12,7 @@ def step_impl(context): assert "allow_water_level_samples" in context.water_well_data permission_record = retrieve_latest_polymorphic_history_table_record( - context.well, "permission_history", "Water Level Sample" + context.objects["wells"][0], "permission_history", "Water Level Sample" ) assert ( context.water_well_data["allow_water_level_samples"] @@ -25,7 +25,7 @@ def step_impl(context): assert "allow_water_chemistry_samples" in context.water_well_data permission_record = retrieve_latest_polymorphic_history_table_record( - context.well, "permission_history", "Water Chemistry Sample" + context.objects["wells"][0], "permission_history", "Water Chemistry Sample" ) assert ( @@ -41,11 +41,11 @@ def step_impl(context): assert "allow_datalogger_installation" in context.water_well_data permission_record = retrieve_latest_polymorphic_history_table_record( - context.well, "permission_history", "Datalogger Installation" + context.objects["wells"][0], "permission_history", "Datalogger Installation" ) assert ( - context.water_well_data["permissions"]["allow_data_logger_installation"] + context.water_well_data["allow_datalogger_installation"] == permission_record.permission_allowed ) @@ -57,10 +57,10 @@ def step_impl(context): @then("the response should include the completion date of the well") def step_impl(context): - assert "completion_date" in context.water_well_data - assert context.water_well_data[ - "completion_date" - ] == context.well.completion_date.strftime("%Y-%m-%d") + assert "well_completion_date" in context.water_well_data + assert context.water_well_data["well_completion_date"] == context.objects["wells"][ + 0 + ].well_completion_date.strftime("%Y-%m-%d") # TODO: needs to be added to model, schemas, test data @@ -69,22 +69,25 @@ def step_impl(context): assert "completion_info_source" in context.water_well_data assert ( context.water_well_data["completion_info_source"] - == context.well.completion_info_source + == context.objects["wells"][0].completion_info_source ) @then("the response should include the driller name") def step_impl(context): - assert "driller_name" in context.water_well_data - assert context.water_well_data["driller_name"] == context.well.driller_name + assert "well_driller_name" in context.water_well_data + assert ( + context.water_well_data["well_driller_name"] + == context.objects["wells"][0].well_driller_name + ) @then("the response should include the construction method") def step_impl(context): - assert "construction_method" in context.water_well_data + assert "well_construction_method" in context.water_well_data assert ( - context.water_well_data["construction_method"] - == context.well.construction_method + context.water_well_data["well_construction_method"] + == context.objects["wells"][0].well_construction_method ) @@ -94,7 +97,7 @@ def step_impl(context): assert "construction_info_source" in context.water_well_data assert ( context.water_well_data["construction_info_source"] - == context.well.construction_info_source + == context.objects["wells"][0].construction_info_source ) @@ -105,11 +108,14 @@ def step_impl(context): @then("the response should include the casing diameter in inches") def step_impl(context): - assert "casing_diameter" in context.water_well_data - assert "casing_diameter_unit" in context.water_well_data + assert "well_casing_diameter" in context.water_well_data + assert "well_casing_diameter_unit" in context.water_well_data - assert context.water_well_data["casing_diameter"] == context.well.casing_diameter - assert context.water_well_data["casing_diameter_unit"] == "in" + assert ( + context.water_well_data["well_casing_diameter"] + == context.objects["wells"][0].well_casing_diameter + ) + assert context.water_well_data["well_casing_diameter_unit"] == "in" @then("the response should include the casing depth in feet below ground surface") @@ -118,7 +124,8 @@ def step_impl(context): assert "well_casing_depth_unit" in context.water_well_data assert ( - context.water_well_data["well_casing_depth"] == context.well.well_casing_depth + context.water_well_data["well_casing_depth"] + == context.objects["wells"][0].well_casing_depth ) assert context.water_well_data["well_casing_depth_unit"] == "ft" @@ -127,16 +134,18 @@ def step_impl(context): @then("the response should include the casing materials") def step_impl(context): assert "well_casing_materials" in context.water_well_data - assert ( - context.water_well_data["well_casing_materials"] - == context.well.well_casing_materials + assert sorted(context.water_well_data["well_casing_materials"]) == sorted( + [m.material for m in context.objects["wells"][0].well_casing_materials] ) @then("the response should include the well pump type (previously well_type field)") def step_impl(context): assert "well_pump_type" in context.water_well_data - assert context.water_well_data["well_pump_type"] == context.well.well_pump_type + assert ( + context.water_well_data["well_pump_type"] + == context.objects["wells"][0].well_pump_type + ) @then("the response should include the well pump depth in feet (new field)") @@ -144,7 +153,10 @@ def step_impl(context): assert "well_pump_depth" in context.water_well_data assert "well_pump_depth_unit" in context.water_well_data - assert context.water_well_data["well_pump_depth"] == context.well.well_pump_depth + assert ( + context.water_well_data["well_pump_depth"] + == context.objects["wells"][0].well_pump_depth + ) assert context.water_well_data["well_pump_depth_unit"] == "ft" @@ -155,7 +167,7 @@ def step_impl(context): assert "is_suitable_for_datalogger" in context.water_well_data assert ( context.water_well_data["is_suitable_for_datalogger"] - == context.well.is_suitable_for_datalogger + == context.objects["wells"][0].is_suitable_for_datalogger ) @@ -170,7 +182,7 @@ def step_impl(context): ) def step_impl(context): assert "formation" in context.water_well_data - assert context.water_well_data["formation"] == context.well.formation + assert context.water_well_data["formation"] == context.objects["wells"][0].formation # TODO: needs to be added to model, schemas, test data, lexicon @@ -180,7 +192,8 @@ def step_impl(context): def step_impl(context): assert "aquifer_class_code" in context.water_well_data assert ( - context.water_well_data["aquifer_class_code"] == context.well.aquifer_class_code + context.water_well_data["aquifer_class_code"] + == context.objects["wells"][0].aquifer_class_code ) @@ -191,4 +204,7 @@ def step_impl(context): ) def step_impl(context): assert "aquifer_type" in context.water_well_data - assert context.water_well_data["aquifer_type"] == context.well.aquifer_type + assert ( + context.water_well_data["aquifer_type"] + == context.objects["wells"][0].aquifer_type + ) From 2c33a7900cacab2c01910f2ecc4fd5e9a288be4f Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 19 Nov 2025 13:28:16 -0700 Subject: [PATCH 11/28] feat: implement data source tests for well additional information --- db/thing.py | 12 ++++++++++++ run_bdd.sh | 4 ++-- schemas/thing.py | 2 ++ tests/features/environment.py | 18 ++++++++++++++++++ .../steps/well-additional-information.py | 13 +++++++------ 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/db/thing.py b/db/thing.py index 7ee3c9cba..1ed1cbbc3 100644 --- a/db/thing.py +++ b/db/thing.py @@ -398,6 +398,18 @@ def measuring_point_description(self) -> str | None: def well_depth_source(self) -> str | None: return self._get_data_provenance_attribute("well_depth", "origin_source") + @property + def well_completion_date_source(self) -> str | None: + return self._get_data_provenance_attribute( + "well_completion_date", "origin_source" + ) + + @property + def well_construction_method_source(self) -> str | None: + return self._get_data_provenance_attribute( + "well_construction_method", "origin_source" + ) + @property def allow_water_level_samples(self): """ diff --git a/run_bdd.sh b/run_bdd.sh index 29d0be47d..cd05769e4 100755 --- a/run_bdd.sh +++ b/run_bdd.sh @@ -66,7 +66,7 @@ export BASE_URL=${BASE_URL:-http://localhost:8000} # tests/features/thing-query-parameters.feature #uv run behave tests/features/well-inventory-csv.feature -uv run behave tests/features/well-additional-information.feature --capture -# uv run behave tests/features --tags="@backend and @production" --capture +# uv run behave tests/features/well-additional-information.feature --capture +uv run behave tests/features --tags="@backend and @production" --capture echo "✅ BDD test run complete." diff --git a/schemas/thing.py b/schemas/thing.py index e980e4e76..50f56e7c4 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -223,8 +223,10 @@ class WellResponse(BaseThingResponse): well_casing_materials: list[CasingMaterial] = [] well_construction_notes: str | None = None well_completion_date: PastOrTodayDate | None + well_completion_date_source: str | None well_driller_name: str | None well_construction_method: WellConstructionMethod | None + well_construction_method_source: str | None well_pump_type: WellPumpType | None well_pump_depth: float | None well_pump_depth_unit: str = "ft" diff --git a/tests/features/environment.py b/tests/features/environment.py index 289d0b0f6..2dc410517 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -606,6 +606,24 @@ def before_all(context): origin_source="Other", ) + well_completion_date_source = add_data_provenance( + context, + session, + target_id=well_1.id, + target_table="thing", + field_name="well_completion_date", + origin_source="Data Portal", + ) + + well_construction_method_source = add_data_provenance( + context, + session, + target_id=well_1.id, + target_table="thing", + field_name="well_construction_method", + origin_source="Data Portal", + ) + for purpose in ["Domestic", "Irrigation"]: add_well_purpose(context, session, well_1, purpose) diff --git a/tests/features/steps/well-additional-information.py b/tests/features/steps/well-additional-information.py index 1dc5c3518..eaf83e1ce 100644 --- a/tests/features/steps/well-additional-information.py +++ b/tests/features/steps/well-additional-information.py @@ -66,10 +66,11 @@ def step_impl(context): # TODO: needs to be added to model, schemas, test data @then("the response should include the source of the completion information") def step_impl(context): - assert "completion_info_source" in context.water_well_data + assert "well_completion_date_source" in context.water_well_data + assert ( - context.water_well_data["completion_info_source"] - == context.objects["wells"][0].completion_info_source + context.water_well_data["well_completion_date_source"] + == context.objects["wells"][0].well_completion_date_source ) @@ -94,10 +95,10 @@ def step_impl(context): # TODO: needs to be added to model, schemas, test data @then("the response should include the source of the construction information") def step_impl(context): - assert "construction_info_source" in context.water_well_data + assert "well_construction_method_source" in context.water_well_data assert ( - context.water_well_data["construction_info_source"] - == context.objects["wells"][0].construction_info_source + context.water_well_data["well_construction_method_source"] + == context.objects["wells"][0].well_construction_method_source ) From 8ef2592b0994fe5598840e610752bde4b4ff0d47 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 19 Nov 2025 13:29:22 -0700 Subject: [PATCH 12/28] fix: remove outdated notes --- tests/features/steps/well-additional-information.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/features/steps/well-additional-information.py b/tests/features/steps/well-additional-information.py index eaf83e1ce..d7d3b768c 100644 --- a/tests/features/steps/well-additional-information.py +++ b/tests/features/steps/well-additional-information.py @@ -63,7 +63,6 @@ def step_impl(context): ].well_completion_date.strftime("%Y-%m-%d") -# TODO: needs to be added to model, schemas, test data @then("the response should include the source of the completion information") def step_impl(context): assert "well_completion_date_source" in context.water_well_data @@ -92,7 +91,6 @@ def step_impl(context): ) -# TODO: needs to be added to model, schemas, test data @then("the response should include the source of the construction information") def step_impl(context): assert "well_construction_method_source" in context.water_well_data @@ -131,7 +129,6 @@ def step_impl(context): assert context.water_well_data["well_casing_depth_unit"] == "ft" -# TODO: needs to be added to model, schemas, test data @then("the response should include the casing materials") def step_impl(context): assert "well_casing_materials" in context.water_well_data From 722425fae83c6adce0cca7eb9f9651d0a867d4c1 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 20 Nov 2025 13:07:31 -0700 Subject: [PATCH 13/28] feat: implement origin_type --- db/data_provenance.py | 8 ++++++-- db/thing.py | 4 ++-- tests/features/environment.py | 10 ++++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/db/data_provenance.py b/db/data_provenance.py index 06c468c8d..14cfdc5aa 100644 --- a/db/data_provenance.py +++ b/db/data_provenance.py @@ -53,9 +53,13 @@ class DataProvenance(AutoBaseMixin, ReleaseMixin, Base): ) # Values from the following NMAquifer tables are included as `origin_source` terms in the lexicon: # 'LU_DataSource', 'LU_Depth_CompletionSource'. - origin_source: Mapped[str] = lexicon_term( + origin_type: Mapped[str] = lexicon_term( nullable=True, - comment="Indicates the origin source of the data (e.g'Driller's Log', 'Well Report'.", + comment="Indicates the type of origin the data (e.g'Driller's Log', 'Well Report'.", + ) + origin_source: Mapped[str] = mapped_column( + nullable=True, + comment="The specific source of the data (e.g., 'J. Brown Thesis, \"I like APIs\", Pomona College, 1994').", ) # Values from the following NMAquifer tables are included as `collection_method` terms in the lexicon: # 'LU_AltitudeMethod','LU_CoordinateMethod'. diff --git a/db/thing.py b/db/thing.py index 1ed1cbbc3..b9c2c54a0 100644 --- a/db/thing.py +++ b/db/thing.py @@ -396,12 +396,12 @@ def measuring_point_description(self) -> str | None: @property def well_depth_source(self) -> str | None: - return self._get_data_provenance_attribute("well_depth", "origin_source") + return self._get_data_provenance_attribute("well_depth", "origin_type") @property def well_completion_date_source(self) -> str | None: return self._get_data_provenance_attribute( - "well_completion_date", "origin_source" + "well_completion_date", "origin_type" ) @property diff --git a/tests/features/environment.py b/tests/features/environment.py index 2dc410517..ac223b530 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -385,7 +385,8 @@ def add_data_provenance( target_id, target_table, field_name, - origin_source, + origin_type=None, + origin_source=None, collection_method=None, accuracy_value=None, accuracy_unit=None, @@ -395,6 +396,7 @@ def add_data_provenance( collection_method=collection_method, target_id=target_id, target_table=target_table, + origin_type=origin_type, origin_source=origin_source, accuracy_value=accuracy_value, accuracy_unit=accuracy_unit, @@ -603,7 +605,7 @@ def before_all(context): target_id=well_1.id, target_table="thing", field_name="well_depth", - origin_source="Other", + origin_type="Other", ) well_completion_date_source = add_data_provenance( @@ -612,7 +614,7 @@ def before_all(context): target_id=well_1.id, target_table="thing", field_name="well_completion_date", - origin_source="Data Portal", + origin_type="Data Portal", ) well_construction_method_source = add_data_provenance( @@ -621,7 +623,7 @@ def before_all(context): target_id=well_1.id, target_table="thing", field_name="well_construction_method", - origin_source="Data Portal", + origin_source="Jacob's 2013 Thesis", ) for purpose in ["Domestic", "Irrigation"]: From 1342b8d30852d9cb47a845b29c55cd3179d403fc Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 24 Nov 2025 12:54:53 -0700 Subject: [PATCH 14/28] fix: fix __table_args__ for aquifer and geology --- db/aquifer_system.py | 2 +- db/geologic_formation.py | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/db/aquifer_system.py b/db/aquifer_system.py index 53086f983..6a1681561 100644 --- a/db/aquifer_system.py +++ b/db/aquifer_system.py @@ -81,4 +81,4 @@ class AquiferSystem(Base, AutoBaseMixin, ReleaseMixin): ) # --- Table Arguments --- - __table_args__ = Index("ix_aquifersystem_name", "name") + __table_args__ = (Index("ix_aquifersystem_name", "name"),) diff --git a/db/geologic_formation.py b/db/geologic_formation.py index 130ed8d45..a9483b501 100644 --- a/db/geologic_formation.py +++ b/db/geologic_formation.py @@ -79,7 +79,4 @@ class GeologicFormation(Base, AutoBaseMixin, ReleaseMixin): things: AssociationProxy["Thing"] = association_proxy("thing_associations", "thing") # --- Table Arguments --- - __table_args__ = ( - Index("ix_geologicformation_name", "name"), - Index("ix_geologicformation_code", "code"), - ) + __table_args__ = (Index("ix_geologicformation_formation_code", "formation_code"),) From 88419b7911bf38070fb899c54dae25ddaba247ed Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 24 Nov 2025 13:00:59 -0700 Subject: [PATCH 15/28] fix: add geologic_formation relation to WellScreen This was missing on the WellScreen side of the relationship --- db/geologic_formation.py | 2 +- db/thing.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/db/geologic_formation.py b/db/geologic_formation.py index a9483b501..2379f50f4 100644 --- a/db/geologic_formation.py +++ b/db/geologic_formation.py @@ -70,7 +70,7 @@ class GeologicFormation(Base, AutoBaseMixin, ReleaseMixin): ) ) # One-To-Many: A GeologicFormation can have many physical WellScreens installed in it. - screens: Mapped[List["WellScreen"]] = relationship( + well_screens: Mapped[List["WellScreen"]] = relationship( "WellScreen", back_populates="geologic_formation", passive_deletes=True ) diff --git a/db/thing.py b/db/thing.py index bf7e30db1..3c6ecf3db 100644 --- a/db/thing.py +++ b/db/thing.py @@ -528,6 +528,10 @@ class WellScreen(Base, AutoBaseMixin, ReleaseMixin): "AquiferSystem", back_populates="well_screens", passive_deletes=True ) + geologic_formation: Mapped["GeologicFormation"] = relationship( + "GeologicFormation", back_populates="well_screens", passive_deletes=True + ) + class WellPurpose(Base, AutoBaseMixin, ReleaseMixin): """ From 1a23dff0790e991edf6366db27379fc99788d9be Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 24 Nov 2025 13:04:35 -0700 Subject: [PATCH 16/28] fix: add missing comma --- core/lexicon.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/lexicon.json b/core/lexicon.json index ec195b6b1..44a89d2aa 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -55,7 +55,7 @@ {"name": "status_value", "description": null}, {"name": "origin_source", "description": null}, {"name": "well_pump_type", "description": null}, - {"name": "permission_type", "description": null} + {"name": "permission_type", "description": null}, {"name": "formation_code", "description": null}, {"name": "lithology", "description": null} ], From d82b16373700afac658f35ffb542645b273d58f8 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 24 Nov 2025 13:33:11 -0700 Subject: [PATCH 17/28] fix: use BaseResponseModel for GeoJSONProperties in location schema --- schemas/location.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schemas/location.py b/schemas/location.py index e911e3359..81c1ddd2d 100644 --- a/schemas/location.py +++ b/schemas/location.py @@ -97,7 +97,7 @@ class GeoJSONUTMCoordinates(BaseModel): ) -class GeoJSONProperties(BaseModel): +class GeoJSONProperties(BaseResponseModel): elevation: float elevation_unit: str = "ft" vertical_datum: str = "NAVD88" From 20af430d00ceade9a6beecf63404058598c32be8 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 24 Nov 2025 14:35:25 -0700 Subject: [PATCH 18/28] fix: include baseresponseinfo in location geojson properties --- schemas/location.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/schemas/location.py b/schemas/location.py index 81c1ddd2d..218790496 100644 --- a/schemas/location.py +++ b/schemas/location.py @@ -147,6 +147,9 @@ def populate_fields(cls, data: Any) -> Any: data_dict["geometry"]["coordinates"] = coordinates # populate properties + data_dict["properties"]["id"] = data_dict.get("id") + data_dict["properties"]["created_at"] = data_dict.get("created_at") + data_dict["properties"]["release_status"] = data_dict.get("release_status") data_dict["properties"]["notes"] = data_dict.get("notes") data_dict["properties"]["elevation"] = convert_m_to_ft(elevation_m) data_dict["properties"]["elevation_method"] = data_dict.get("elevation_method") From 5778b741a607810b4133080e59f34e31718ed082 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 24 Nov 2025 14:36:35 -0700 Subject: [PATCH 19/28] feat: implement aquifer_systems in thing response --- db/thing.py | 2 +- schemas/thing.py | 23 ++++++++++++++----- tests/features/environment.py | 23 ++++++++++++++++++- .../steps/well-additional-information.py | 12 +++------- 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/db/thing.py b/db/thing.py index 3c6ecf3db..340d8b0e9 100644 --- a/db/thing.py +++ b/db/thing.py @@ -332,7 +332,7 @@ class Thing( ) # Proxy to directly access AquiferSystems associated with this Thing - aquifers: AssociationProxy[List["AquiferSystem"]] = association_proxy( + aquifer_systems: AssociationProxy[List["AquiferSystem"]] = association_proxy( "aquifer_associations", "aquifer_system" ) diff --git a/schemas/thing.py b/schemas/thing.py index aecccd3e0..47be1021f 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -33,10 +33,11 @@ from schemas.group import GroupResponse from schemas.location import LocationGeoJSONResponse from schemas.notes import NoteResponse, CreateNote -from schemas.aquifer_system import AquiferSystemResponse -from schemas.geologic_formation import ( - GeologicFormationResponse, -) +from schemas.aquifer_system import AquiferSystemGeoJSONResponse + +# from schemas.geologic_formation import ( +# GeologicFormationResponse, +# ) # -------- VALIDATE ---------- @@ -245,6 +246,7 @@ class WellResponse(BaseThingResponse): measuring_point_height: float measuring_point_height_unit: str = "ft" measuring_point_description: str | None + aquifer_systems: list[str] = [] water_notes: list[NoteResponse] | None = None measuring_notes: list[NoteResponse] | None = None @@ -269,6 +271,14 @@ def populate_well_casing_materials_with_strings(cls, well_casing_materials): materials = [] return materials + @field_validator("aquifer_systems", mode="before") + def populate_aquifer_types_with_strings(cls, aquifer_systems): + if aquifer_systems is not None: + systems = [aquifer_system.name for aquifer_system in aquifer_systems] + else: + systems = [] + return systems + class SpringResponse(BaseThingResponse): """ @@ -291,9 +301,10 @@ class WellScreenResponse(BaseResponseModel): thing_id: int thing: WellResponse aquifer_system_id: int | None = None - aquifer_system: AquiferSystemResponse | None = None + aquifer_system: AquiferSystemGeoJSONResponse | None = None + aquifer_type: str | None = None geologic_formation_id: int | None = None - geologic_formation: GeologicFormationResponse | None = None + # geologic_formation: GeologicFormationResponse | None = None screen_depth_bottom: float screen_depth_bottom_unit: str = "ft" screen_depth_top: float diff --git a/tests/features/environment.py b/tests/features/environment.py index ec5070bcc..0b2ca9d9f 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -37,6 +37,7 @@ MeasuringPointHistory, MonitoringFrequencyHistory, DataProvenance, + AquiferSystem, ) from db.engine import session_ctx @@ -423,9 +424,26 @@ def add_transducer_observation(context, session, block, deployment_id, value): return obs +@add_context_object_container("aquifer_systems") +def add_aquifer_system(context, session, name, well): + aquifer_system = AquiferSystem( + name=name, + description="this is a test aquifer", + primary_aquifer_type="Artesian", + geographic_scale="Major", + boundary="MULTIPOLYGON(((0 0, 1 1, 2 2, 3 3, 1 2, 0 0)))", + ) + session.add(aquifer_system) + session.commit() + session.refresh(aquifer_system) + + context.objects["aquifer_systems"].append(aquifer_system) + return aquifer_system + + def before_all(context): context.objects = {} - rebuild = False + rebuild = True # rebuild = True if rebuild: erase_and_rebuild_db() @@ -631,6 +649,9 @@ def before_all(context): for purpose in ["Domestic", "Irrigation"]: add_well_purpose(context, session, well_1, purpose) + for name in ["Aquifer A", "Aquifer B"]: + add_aquifer_system(context, session, name, well_1) + # parameter ID can be hardcoded because init_parameter always creates the same one parameter = session.get(Parameter, 1) block = add_block(context, session, parameter) diff --git a/tests/features/steps/well-additional-information.py b/tests/features/steps/well-additional-information.py index 0824a0246..a9d6833f5 100644 --- a/tests/features/steps/well-additional-information.py +++ b/tests/features/steps/well-additional-information.py @@ -55,7 +55,6 @@ def step_impl(context): # ------------------------------------------------------------------------------ -# TODO: needs to be added to model, schemas, test data @then("the response should include the completion date of the well") def step_impl(context): assert "well_completion_date" in context.water_well_data @@ -74,7 +73,6 @@ def step_impl(context): ) -# TODO: needs to be added to model, schemas, test data @then("the response should include the driller name") def step_impl(context): assert "well_driller_name" in context.water_well_data @@ -107,7 +105,6 @@ def step_impl(context): # ------------------------------------------------------------------------------ -# TODO: the transfer script needs to convert ft to in @then("the response should include the casing diameter in inches") def step_impl(context): assert "well_casing_diameter" in context.water_well_data @@ -140,8 +137,6 @@ def step_impl(context): ) -# TODO: needs to be added to model, schemas, test data -# TODO: needs to be added to lexicon and an enum should be created @then("the response should include the well pump type (previously well_type field)") def step_impl(context): assert "well_pump_type" in context.water_well_data @@ -151,7 +146,6 @@ def step_impl(context): ) -# TODO: needs to be added to model, schemas, test data @then("the response should include the well pump depth in feet (new field)") def step_impl(context): assert "well_pump_depth" in context.water_well_data @@ -194,10 +188,10 @@ def step_impl(context): "the response should include the aquifer class code to classify the aquifer into aquifer system." ) def step_impl(context): - assert "aquifer_class_code" in context.water_well_data + assert "aquifer_systems" in context.water_well_data assert ( - context.water_well_data["aquifer_class_code"] - == context.objects["wells"][0].aquifer_class_code + context.water_well_data["aquifer_systems"] + == context.objects["wells"][0].aquifer_systems ) From c4f9de0dfc9f5a41811a0dbdda15319db86d95df Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 24 Nov 2025 14:50:55 -0700 Subject: [PATCH 20/28] feat: fix aquifer systems test --- tests/features/environment.py | 5 +++++ tests/features/steps/well-additional-information.py | 6 ++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/features/environment.py b/tests/features/environment.py index 0b2ca9d9f..8c683fac8 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -38,6 +38,7 @@ MonitoringFrequencyHistory, DataProvenance, AquiferSystem, + ThingAquiferAssociation, ) from db.engine import session_ctx @@ -437,6 +438,10 @@ def add_aquifer_system(context, session, name, well): session.commit() session.refresh(aquifer_system) + association = ThingAquiferAssociation(thing=well, aquifer_system=aquifer_system) + session.add(association) + session.commit() + context.objects["aquifer_systems"].append(aquifer_system) return aquifer_system diff --git a/tests/features/steps/well-additional-information.py b/tests/features/steps/well-additional-information.py index a9d6833f5..dbaaba0ed 100644 --- a/tests/features/steps/well-additional-information.py +++ b/tests/features/steps/well-additional-information.py @@ -183,15 +183,13 @@ def step_impl(context): assert context.water_well_data["formation"] == context.objects["wells"][0].formation -# TODO: needs to be added to model, schemas, test data, lexicon @then( "the response should include the aquifer class code to classify the aquifer into aquifer system." ) def step_impl(context): assert "aquifer_systems" in context.water_well_data - assert ( - context.water_well_data["aquifer_systems"] - == context.objects["wells"][0].aquifer_systems + assert sorted(context.water_well_data["aquifer_systems"]) == sorted( + [system.name for system in context.objects["aquifer_systems"]] ) From d3abd833144c832800f48d69746c34c3e9adfd81 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 24 Nov 2025 15:45:07 -0700 Subject: [PATCH 21/28] fix: return aquifer system and types in dict this allows us to couple types to a system incase a well is in multiple aquifers --- db/thing.py | 17 +++++++++ schemas/thing.py | 11 +----- tests/features/environment.py | 35 ++++++++++++++++--- .../steps/well-additional-information.py | 25 +++++++------ 4 files changed, 63 insertions(+), 25 deletions(-) diff --git a/db/thing.py b/db/thing.py index 340d8b0e9..35c61b631 100644 --- a/db/thing.py +++ b/db/thing.py @@ -476,6 +476,23 @@ def allow_datalogger_installation(self): ) return permission_record.permission_allowed if permission_record else None + @property + def aquifers(self) -> List[dict]: + """ + Returns a list of aquifer systems and their associated types for this Thing. + Each aquifer system is represented as a dictionary with its name and a list of types. + """ + aquifer_list = [] + for association in self.aquifer_associations: + aquifer_info = { + "aquifer_system": association.aquifer_system.name, + "aquifer_types": [ + atype.aquifer_type for atype in association.aquifer_types + ], + } + aquifer_list.append(aquifer_info) + return aquifer_list + class ThingIdLink(Base, AutoBaseMixin, ReleaseMixin): """ diff --git a/schemas/thing.py b/schemas/thing.py index 47be1021f..0ff765d76 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -246,8 +246,7 @@ class WellResponse(BaseThingResponse): measuring_point_height: float measuring_point_height_unit: str = "ft" measuring_point_description: str | None - aquifer_systems: list[str] = [] - + aquifers: list[dict] = [] water_notes: list[NoteResponse] | None = None measuring_notes: list[NoteResponse] | None = None general_notes: list[NoteResponse] | None = None @@ -271,14 +270,6 @@ def populate_well_casing_materials_with_strings(cls, well_casing_materials): materials = [] return materials - @field_validator("aquifer_systems", mode="before") - def populate_aquifer_types_with_strings(cls, aquifer_systems): - if aquifer_systems is not None: - systems = [aquifer_system.name for aquifer_system in aquifer_systems] - else: - systems = [] - return systems - class SpringResponse(BaseThingResponse): """ diff --git a/tests/features/environment.py b/tests/features/environment.py index 8c683fac8..d30f920ec 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -38,6 +38,7 @@ MonitoringFrequencyHistory, DataProvenance, AquiferSystem, + AquiferType, ThingAquiferAssociation, ) from db.engine import session_ctx @@ -438,17 +439,38 @@ def add_aquifer_system(context, session, name, well): session.commit() session.refresh(aquifer_system) + context.objects["aquifer_systems"].append(aquifer_system) + return aquifer_system + + +@add_context_object_container("thing_aquifer_associations") +def add_thing_aquifer_association(context, session, well, aquifer_system): association = ThingAquiferAssociation(thing=well, aquifer_system=aquifer_system) session.add(association) session.commit() + session.refresh(association) - context.objects["aquifer_systems"].append(aquifer_system) - return aquifer_system + context.objects["thing_aquifer_associations"].append(association) + return association + + +@add_context_object_container("aquifer_types") +def add_aquifer_type(context, session, aquifer_type_str, thing_aquifer_association): + aquifer_type = AquiferType( + aquifer_type=aquifer_type_str, + thing_aquifer_association=thing_aquifer_association, + ) + session.add(aquifer_type) + session.commit() + session.refresh(aquifer_type) + + context.objects["aquifer_types"].append(aquifer_type) + return aquifer_type def before_all(context): context.objects = {} - rebuild = True + rebuild = False # rebuild = True if rebuild: erase_and_rebuild_db() @@ -655,7 +677,12 @@ def before_all(context): add_well_purpose(context, session, well_1, purpose) for name in ["Aquifer A", "Aquifer B"]: - add_aquifer_system(context, session, name, well_1) + system = add_aquifer_system(context, session, name, well_1) + add_thing_aquifer_association(context, session, well_1, system) + + for t in ["Artesian", "Fractured"]: + taa = context.objects["thing_aquifer_associations"][0] + add_aquifer_type(context, session, t, taa) # parameter ID can be hardcoded because init_parameter always creates the same one parameter = session.get(Parameter, 1) diff --git a/tests/features/steps/well-additional-information.py b/tests/features/steps/well-additional-information.py index dbaaba0ed..aba64d37c 100644 --- a/tests/features/steps/well-additional-information.py +++ b/tests/features/steps/well-additional-information.py @@ -187,20 +187,23 @@ def step_impl(context): "the response should include the aquifer class code to classify the aquifer into aquifer system." ) def step_impl(context): - assert "aquifer_systems" in context.water_well_data - assert sorted(context.water_well_data["aquifer_systems"]) == sorted( - [system.name for system in context.objects["aquifer_systems"]] - ) + for aquifer in context.water_well_data["aquifers"]: + assert "aquifer_system" in aquifer + assert sorted( + [a.get("aquifer_system") for a in context.water_well_data["aquifers"]] + ) == sorted([system.name for system in context.objects["aquifer_systems"]]) -# TODO: needs to be added to model, schemas, test data -# TODO: should this be plural? that is, a descriptor model of the well @then( "the response should include the aquifer type as the type of aquifers penetrated by the well" ) def step_impl(context): - assert "aquifer_type" in context.water_well_data - assert ( - context.water_well_data["aquifer_type"] - == context.objects["wells"][0].aquifer_type - ) + for aquifer in context.water_well_data["aquifers"]: + assert "aquifer_types" in aquifer + + if aquifer["aquifer_system"] == "Aquifer A": + assert sorted(aquifer["aquifer_types"]) == sorted( + [a.aquifer_type for a in context.objects["aquifer_types"]] + ) + else: + assert aquifer["aquifer_types"] == [] From b4cd84e001ff33267d7bc71075c413c4c7f2dae7 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 24 Nov 2025 16:01:02 -0700 Subject: [PATCH 22/28] feat: implement geologic_formations from feature files --- db/thing.py | 4 +-- schemas/thing.py | 9 +++++++ tests/features/environment.py | 27 +++++++++++++++++++ .../steps/well-additional-information.py | 7 ++--- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/db/thing.py b/db/thing.py index 35c61b631..b08a14747 100644 --- a/db/thing.py +++ b/db/thing.py @@ -337,8 +337,8 @@ class Thing( ) # Proxy to directly access the GeologicFormations penetrated by this Thing. - formations: AssociationProxy[List["GeologicFormation"]] = association_proxy( - "formation_associations", "geologic_formation" + geologic_formations: AssociationProxy[List["GeologicFormation"]] = ( + association_proxy("formation_associations", "geologic_formation") ) # Full-text search vector diff --git a/schemas/thing.py b/schemas/thing.py index 0ff765d76..bd7665f92 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -247,6 +247,7 @@ class WellResponse(BaseThingResponse): measuring_point_height_unit: str = "ft" measuring_point_description: str | None aquifers: list[dict] = [] + geologic_formations: list[str] = [] water_notes: list[NoteResponse] | None = None measuring_notes: list[NoteResponse] | None = None general_notes: list[NoteResponse] | None = None @@ -270,6 +271,14 @@ def populate_well_casing_materials_with_strings(cls, well_casing_materials): materials = [] return materials + @field_validator("geologic_formations", mode="before") + def populate_geologic_formations_with_strings(cls, geologic_formations): + if geologic_formations is not None: + formations = [formation.formation_code for formation in geologic_formations] + else: + formations = [] + return formations + class SpringResponse(BaseThingResponse): """ diff --git a/tests/features/environment.py b/tests/features/environment.py index d30f920ec..e2959ac85 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -40,6 +40,8 @@ AquiferSystem, AquiferType, ThingAquiferAssociation, + GeologicFormation, + ThingGeologicFormationAssociation, ) from db.engine import session_ctx @@ -468,6 +470,29 @@ def add_aquifer_type(context, session, aquifer_type_str, thing_aquifer_associati return aquifer_type +@add_context_object_container("geologic_formations") +def add_geologic_formation(context, session, formation_code, well): + formation = GeologicFormation( + formation_code=formation_code, + description="This is a test geologic formation.", + lithology="Peat", + boundary="MULTIPOLYGON(((0 0, 1 1, 2 2, 3 3, 1 2, 0 0)))", + ) + session.add(formation) + session.commit() + session.refresh(formation) + + association = ThingGeologicFormationAssociation( + top_depth=1, bottom_depth=10, thing=well, geologic_formation=formation + ) + session.add(association) + session.commit() + session.refresh(association) + + context.objects["geologic_formations"].append(formation) + return formation + + def before_all(context): context.objects = {} rebuild = False @@ -684,6 +709,8 @@ def before_all(context): taa = context.objects["thing_aquifer_associations"][0] add_aquifer_type(context, session, t, taa) + add_geologic_formation(context, session, "000EXRV", well_1) + # parameter ID can be hardcoded because init_parameter always creates the same one parameter = session.get(Parameter, 1) block = add_block(context, session, parameter) diff --git a/tests/features/steps/well-additional-information.py b/tests/features/steps/well-additional-information.py index aba64d37c..638c6529f 100644 --- a/tests/features/steps/well-additional-information.py +++ b/tests/features/steps/well-additional-information.py @@ -174,13 +174,14 @@ def step_impl(context): # ------------------------------------------------------------------------------ -# TODO: needs to be added to model, schemas, test data @then( "the response should include the formation as the formation zone of well completion" ) def step_impl(context): - assert "formation" in context.water_well_data - assert context.water_well_data["formation"] == context.objects["wells"][0].formation + assert "geologic_formations" in context.water_well_data + assert context.water_well_data["geologic_formations"] == [ + context.objects["geologic_formations"][0].formation_code + ] @then( From 0994bec26f7198d638f1670f3f66f6fa982720b3 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 25 Nov 2025 09:16:31 -0700 Subject: [PATCH 23/28] refactor: clean up permission history testing data use a for loop for cleaner and more maintainable code --- tests/features/environment.py | 54 ++++++++++++----------------------- 1 file changed, 18 insertions(+), 36 deletions(-) diff --git a/tests/features/environment.py b/tests/features/environment.py index e2959ac85..6ba6caec3 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -517,42 +517,24 @@ def before_all(context): add_well_casing_material(context, session, well_1) contact = add_contact(context, session) - add_permission_history( - context, - session, - contact_id=context.objects["contacts"][0].id, - permission_type="Datalogger Installation", - permission_allowed=True, - start_date=datetime(2025, 1, 1).date(), - end_date=None, - notes="Permission granted for datalogger installation.", - target_id=well_1.id, - target_table="thing", - ) - add_permission_history( - context, - session, - contact_id=context.objects["contacts"][0].id, - permission_type="Water Level Sample", - permission_allowed=True, - start_date=datetime(2025, 1, 1).date(), - end_date=None, - notes="Permission granted for water level sampling.", - target_id=well_1.id, - target_table="thing", - ) - add_permission_history( - context, - session, - contact_id=context.objects["contacts"][0].id, - permission_type="Water Chemistry Sample", - permission_allowed=False, - start_date=datetime(2025, 1, 1).date(), - end_date=None, - notes="Permission granted for chemistry sampling.", - target_id=well_1.id, - target_table="thing", - ) + + for permission in [ + "Datalogger Installation", + "Water Level Sample", + "Water Chemistry Sample", + ]: + add_permission_history( + context, + session, + contact_id=context.objects["contacts"][0].id, + permission_type=permission, + permission_allowed=True, + start_date=datetime(2025, 1, 1).date(), + end_date=None, + notes=f"Permission granted for {permission.lower()}.", + target_id=well_1.id, + target_table="thing", + ) measuring_point_history_1 = add_measuring_point_history( context, session, well=well_1 From 3041102e8b2c97d81b99971505cb2ae2736170ea Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 25 Nov 2025 09:44:59 -0700 Subject: [PATCH 24/28] fix: use origin_type to retrieve well depth source --- tests/features/steps/well-core-information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index b0adc8346..1f56161f6 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -163,7 +163,7 @@ def step_impl(context): and r.target_table == "thing" and r.target_id == context.objects["wells"][0].id ] - well_depth_source = well_depth_source_records[0].origin_source + well_depth_source = well_depth_source_records[0].origin_type assert context.water_well_data["well_depth_source"] == well_depth_source From 5e8fdade7963a04308c7618cf039f9db51ed52c8 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 25 Nov 2025 09:46:21 -0700 Subject: [PATCH 25/28] refactor: use __tabename__ for polymorphic mixins this ensures fidelity in table naming across all database models --- db/data_provenance.py | 4 ++-- db/notes.py | 2 +- db/permission_history.py | 4 ++-- db/status_history.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/db/data_provenance.py b/db/data_provenance.py index 14cfdc5aa..20505d94c 100644 --- a/db/data_provenance.py +++ b/db/data_provenance.py @@ -19,7 +19,7 @@ from sqlalchemy import Integer, Index, and_ from sqlalchemy.orm import relationship, Mapped, mapped_column, declared_attr, foreign -from db.base import Base, AutoBaseMixin, ReleaseMixin, pascal_to_snake +from db.base import Base, AutoBaseMixin, ReleaseMixin from db import lexicon_term @@ -120,7 +120,7 @@ def data_provenance(cls): "DataProvenance", primaryjoin=and_( cls.id == foreign(DataProvenance.target_id), - DataProvenance.target_table == pascal_to_snake(cls.__name__), + DataProvenance.target_table == cls.__tablename__, ), lazy="selectin", viewonly=True, diff --git a/db/notes.py b/db/notes.py index ab8384064..dfc99bbba 100644 --- a/db/notes.py +++ b/db/notes.py @@ -97,7 +97,7 @@ def notes(cls): "Notes", primaryjoin=and_( cls.id == foreign(Notes.target_id), - Notes.target_table == cls.__name__, + Notes.target_table == cls.__tablename__, ), cascade="all, delete-orphan", lazy="selectin", diff --git a/db/permission_history.py b/db/permission_history.py index 7c9c37159..591046bba 100644 --- a/db/permission_history.py +++ b/db/permission_history.py @@ -12,7 +12,7 @@ from sqlalchemy import Integer, ForeignKey, String, and_ from sqlalchemy.orm import relationship, Mapped, mapped_column, declared_attr, foreign -from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term, pascal_to_snake +from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term if TYPE_CHECKING: @@ -88,7 +88,7 @@ def permission_history(cls): primaryjoin=( and_( cls.id == foreign(PermissionHistory.target_id), - PermissionHistory.target_table == pascal_to_snake(cls.__name__), + PermissionHistory.target_table == cls.__tablename__, ) ), lazy="selectin", diff --git a/db/status_history.py b/db/status_history.py index 8b3ee2321..15b5aec2f 100644 --- a/db/status_history.py +++ b/db/status_history.py @@ -19,7 +19,7 @@ ) from sqlalchemy.orm import Mapped, mapped_column, declared_attr, relationship, foreign -from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term, pascal_to_snake +from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term class StatusHistory(Base, AutoBaseMixin, ReleaseMixin): @@ -47,7 +47,7 @@ def status_history(cls): "StatusHistory", primaryjoin=and_( cls.id == foreign(StatusHistory.target_id), - StatusHistory.target_table == pascal_to_snake(cls.__name__), + StatusHistory.target_table == cls.__tablename__, ), cascade="all, delete-orphan", lazy="selectin", From 95ff8c0a63cc1395347a7b3284b98735e0a5e8bd Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 25 Nov 2025 10:21:52 -0700 Subject: [PATCH 26/28] fix: use __tablename__ for NotesMixin add_note Now that __tablename__ is used throughout it should be used for the target_table in add_note --- db/notes.py | 2 +- tests/features/environment.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/db/notes.py b/db/notes.py index dfc99bbba..0e2e8ab8b 100644 --- a/db/notes.py +++ b/db/notes.py @@ -120,7 +120,7 @@ def add_note( content=content, note_type=note_type, target_id=self.id, - target_table=self.__class__.__name__, + target_table=self.__class__.__tablename__, release_status=release_status, ) diff --git a/tests/features/environment.py b/tests/features/environment.py index 6ba6caec3..5fca63bf0 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -495,7 +495,7 @@ def add_geologic_formation(context, session, formation_code, well): def before_all(context): context.objects = {} - rebuild = False + rebuild = True # rebuild = True if rebuild: erase_and_rebuild_db() From bdff12edaa20f743f8e5160add8473b162fe929d Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 26 Nov 2025 08:33:58 -0700 Subject: [PATCH 27/28] refactor: use sets in feature tests for comparison --- .../steps/well-additional-information.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/features/steps/well-additional-information.py b/tests/features/steps/well-additional-information.py index 638c6529f..0b584d45c 100644 --- a/tests/features/steps/well-additional-information.py +++ b/tests/features/steps/well-additional-information.py @@ -132,9 +132,9 @@ def step_impl(context): @then("the response should include the casing materials") def step_impl(context): assert "well_casing_materials" in context.water_well_data - assert sorted(context.water_well_data["well_casing_materials"]) == sorted( - [m.material for m in context.objects["wells"][0].well_casing_materials] - ) + assert set(context.water_well_data["well_casing_materials"]) == { + m.material for m in context.objects["wells"][0].well_casing_materials + } @then("the response should include the well pump type (previously well_type field)") @@ -190,9 +190,9 @@ def step_impl(context): def step_impl(context): for aquifer in context.water_well_data["aquifers"]: assert "aquifer_system" in aquifer - assert sorted( - [a.get("aquifer_system") for a in context.water_well_data["aquifers"]] - ) == sorted([system.name for system in context.objects["aquifer_systems"]]) + assert {a.get("aquifer_system") for a in context.water_well_data["aquifers"]} == { + system.name for system in context.objects["aquifer_systems"] + } @then( @@ -203,8 +203,8 @@ def step_impl(context): assert "aquifer_types" in aquifer if aquifer["aquifer_system"] == "Aquifer A": - assert sorted(aquifer["aquifer_types"]) == sorted( - [a.aquifer_type for a in context.objects["aquifer_types"]] - ) + assert set(aquifer["aquifer_types"]) == { + a.aquifer_type for a in context.objects["aquifer_types"] + } else: assert aquifer["aquifer_types"] == [] From 19d736aba6d566e3946c24a8cf1cfb3ee59b4b82 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 26 Nov 2025 09:55:21 -0700 Subject: [PATCH 28/28] refactor: return permission history records for a well instead of a specific permission type, return a list of permission records for all permission types associated with the well. --- db/thing.py | 42 +++------- schemas/permission_history.py | 18 +++++ schemas/thing.py | 67 +++++++++++++--- .../steps/well-additional-information.py | 77 ++++++++++++++++--- 4 files changed, 156 insertions(+), 48 deletions(-) create mode 100644 schemas/permission_history.py diff --git a/db/thing.py b/db/thing.py index b08a14747..cae9363e0 100644 --- a/db/thing.py +++ b/db/thing.py @@ -446,36 +446,6 @@ def well_construction_method_source(self) -> str | None: "well_construction_method", "origin_source" ) - @property - def allow_water_level_samples(self): - """ - Returns the current permissions for the Thing. - """ - permission_record = retrieve_latest_polymorphic_history_table_record( - self, "permission_history", "Water Level Sample" - ) - return permission_record.permission_allowed if permission_record else None - - @property - def allow_water_chemistry_samples(self): - """ - Returns the current permissions for the Thing. - """ - permission_record = retrieve_latest_polymorphic_history_table_record( - self, "permission_history", "Water Chemistry Sample" - ) - return permission_record.permission_allowed if permission_record else None - - @property - def allow_datalogger_installation(self): - """ - Returns the current permissions for the Thing. - """ - permission_record = retrieve_latest_polymorphic_history_table_record( - self, "permission_history", "Datalogger Installation" - ) - return permission_record.permission_allowed if permission_record else None - @property def aquifers(self) -> List[dict]: """ @@ -493,6 +463,18 @@ def aquifers(self) -> List[dict]: aquifer_list.append(aquifer_info) return aquifer_list + @property + def permissions(self) -> list: + """ + Returns the associated permissions or an empty list. If there are no + associated permissions, an empty list is returned instead of None to + allow the API to serialize correctly (see schemas/thing.py). + """ + if self.permission_history: + return self.permission_history + else: + return [] + class ThingIdLink(Base, AutoBaseMixin, ReleaseMixin): """ diff --git a/schemas/permission_history.py b/schemas/permission_history.py new file mode 100644 index 000000000..e0619d90e --- /dev/null +++ b/schemas/permission_history.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel +from schemas import PastOrTodayDate + +from core.enums import PermissionType + + +# ------ RESPONSE ---------- +class PermissionHistoryResponse(BaseModel): + """ + Even though permission_allowed and start_date are not-nullable in the + database, they are nullable here to accommodate cases where no permission + record exists for a given permission type. + """ + + permission_type: PermissionType + permission_allowed: bool | None + start_date: PastOrTodayDate | None + end_date: PastOrTodayDate | None diff --git a/schemas/thing.py b/schemas/thing.py index bd7665f92..2b8967553 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -33,11 +33,7 @@ from schemas.group import GroupResponse from schemas.location import LocationGeoJSONResponse from schemas.notes import NoteResponse, CreateNote -from schemas.aquifer_system import AquiferSystemGeoJSONResponse - -# from schemas.geologic_formation import ( -# GeologicFormationResponse, -# ) +from schemas.permission_history import PermissionHistoryResponse # -------- VALIDATE ---------- @@ -238,9 +234,6 @@ class WellResponse(BaseThingResponse): well_pump_type: WellPumpType | None well_pump_depth: float | None well_pump_depth_unit: str = "ft" - allow_water_level_samples: bool | None - allow_water_chemistry_samples: bool | None - allow_datalogger_installation: bool | None is_suitable_for_datalogger: bool | None well_status: str | None measuring_point_height: float @@ -251,6 +244,7 @@ class WellResponse(BaseThingResponse): water_notes: list[NoteResponse] | None = None measuring_notes: list[NoteResponse] | None = None general_notes: list[NoteResponse] | None = None + permissions: list[PermissionHistoryResponse] @field_validator("well_purposes", mode="before") def populate_well_purposes_with_strings(cls, well_purposes): @@ -279,6 +273,43 @@ def populate_geologic_formations_with_strings(cls, geologic_formations): formations = [] return formations + @field_validator("permissions", mode="before") + def populate_permission_history_with_latest_records(cls, permissions): + """ + Populate the permission history with the latest records for each + type of permission. If multiple records exist for the same permission type + only the most recent one is included. If there are no records + the permission_allowed will be None + """ + permissions_to_return = [] + for permission_type in [ + "Water Level Sample", + "Water Chemistry Sample", + "Datalogger Installation", + ]: + # Filter records for the current permission type + filtered_records = [ + record + for record in permissions + if record.permission_type == permission_type and record.end_date is None + ] + if filtered_records: + # Get the most recent record based on start_date + latest_record = max( + filtered_records, key=lambda record: record.start_date + ) + permissions_to_return.append(latest_record) + else: + permissions_to_return.append( + PermissionHistoryResponse( + permission_type=permission_type, + permission_allowed=None, + start_date=None, + end_date=None, + ) + ) + return permissions_to_return + class SpringResponse(BaseThingResponse): """ @@ -301,7 +332,7 @@ class WellScreenResponse(BaseResponseModel): thing_id: int thing: WellResponse aquifer_system_id: int | None = None - aquifer_system: AquiferSystemGeoJSONResponse | None = None + aquifer_system: str | None = None aquifer_type: str | None = None geologic_formation_id: int | None = None # geologic_formation: GeologicFormationResponse | None = None @@ -312,6 +343,24 @@ class WellScreenResponse(BaseResponseModel): screen_type: str | None = None screen_description: str | None = None + @field_validator("aquifer_system", mode="before") + def populate_aquifer_system_with_name(cls, aquifer_system): + if aquifer_system is not None: + return aquifer_system.name + return None + + @field_validator("aquifer_type", mode="before") + def populate_aquifer_type_with_name(cls, aquifer_type): + if aquifer_type is not None: + return aquifer_type.name + return None + + @field_validator("geologic_formation_id", mode="before") + def populate_geologic_formation_with_code(cls, geologic_formation): + if geologic_formation is not None: + return geologic_formation.formation_code + return None + class GeoJSONGeometry(BaseModel): """ diff --git a/tests/features/steps/well-additional-information.py b/tests/features/steps/well-additional-information.py index 0b584d45c..5658f2ccc 100644 --- a/tests/features/steps/well-additional-information.py +++ b/tests/features/steps/well-additional-information.py @@ -10,44 +10,103 @@ "the response should include whether repeat measurement permission is granted for the well" ) def step_impl(context): - assert "allow_water_level_samples" in context.water_well_data + permission_type = "Water Level Sample" + assert "permissions" in context.water_well_data + permission_record = retrieve_latest_polymorphic_history_table_record( - context.objects["wells"][0], "permission_history", "Water Level Sample" + context.objects["wells"][0], "permission_history", permission_type + ) + + water_well_data_permissions = [ + p + for p in context.water_well_data["permissions"] + if p["permission_type"] == permission_type + ][0] + assert ( + water_well_data_permissions["permission_type"] + == permission_record.permission_type ) assert ( - context.water_well_data["allow_water_level_samples"] + water_well_data_permissions["permission_allowed"] == permission_record.permission_allowed ) + assert water_well_data_permissions[ + "start_date" + ] == permission_record.start_date.strftime("%Y-%m-%d") + if permission_record.end_date: + assert water_well_data_permissions[ + "end_date" + ] == permission_record.end_date.strftime("%Y-%m-%d") + else: + assert water_well_data_permissions["end_date"] is None @then("the response should include whether sampling permission is granted for the well") def step_impl(context): - assert "allow_water_chemistry_samples" in context.water_well_data + permission_type = "Water Chemistry Sample" + assert "permissions" in context.water_well_data permission_record = retrieve_latest_polymorphic_history_table_record( - context.objects["wells"][0], "permission_history", "Water Chemistry Sample" + context.objects["wells"][0], "permission_history", permission_type ) + water_well_data_permissions = [ + p + for p in context.water_well_data["permissions"] + if p["permission_type"] == permission_type + ][0] assert ( - context.water_well_data["allow_water_chemistry_samples"] + water_well_data_permissions["permission_type"] + == permission_record.permission_type + ) + assert ( + water_well_data_permissions["permission_allowed"] == permission_record.permission_allowed ) + assert water_well_data_permissions[ + "start_date" + ] == permission_record.start_date.strftime("%Y-%m-%d") + if permission_record.end_date: + assert water_well_data_permissions[ + "end_date" + ] == permission_record.end_date.strftime("%Y-%m-%d") + else: + assert water_well_data_permissions["end_date"] is None @then( "the response should include whether datalogger installation permission is granted for the well" ) def step_impl(context): - assert "allow_datalogger_installation" in context.water_well_data + permission_type = "Datalogger Installation" + assert "permissions" in context.water_well_data permission_record = retrieve_latest_polymorphic_history_table_record( - context.objects["wells"][0], "permission_history", "Datalogger Installation" + context.objects["wells"][0], "permission_history", permission_type ) + water_well_data_permissions = [ + p + for p in context.water_well_data["permissions"] + if p["permission_type"] == permission_type + ][0] + assert ( + water_well_data_permissions["permission_type"] + == permission_record.permission_type + ) assert ( - context.water_well_data["allow_datalogger_installation"] + water_well_data_permissions["permission_allowed"] == permission_record.permission_allowed ) + assert water_well_data_permissions[ + "start_date" + ] == permission_record.start_date.strftime("%Y-%m-%d") + if permission_record.end_date: + assert water_well_data_permissions[ + "end_date" + ] == permission_record.end_date.strftime("%Y-%m-%d") + else: + assert water_well_data_permissions["end_date"] is None # ------------------------------------------------------------------------------