From 670737eb97585a62ba443df27098dd47b0e75d66 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Mon, 29 Sep 2025 14:28:17 -0600 Subject: [PATCH 01/39] feat: import mixins into `Thing` model --- db/thing.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/db/thing.py b/db/thing.py index 7da938744..79065e9e0 100644 --- a/db/thing.py +++ b/db/thing.py @@ -20,7 +20,13 @@ from db import lexicon_term from db.asset import Asset -from db.base import AutoBaseMixin, Base, ReleaseMixin +from db.base import ( + AutoBaseMixin, + Base, + ReleaseMixin, + StatusHistoryMixin, + PermissionMixin, +) from typing import List, TYPE_CHECKING @@ -32,7 +38,7 @@ from db.contact import Contact -class Thing(Base, AutoBaseMixin, ReleaseMixin): +class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMixin): name = mapped_column(String(255), nullable=False) description = mapped_column(String(500)) From 362ca0d5d5820f550c20900248a20140ab2edf98 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Mon, 29 Sep 2025 16:28:14 -0600 Subject: [PATCH 02/39] reformat: updated fields to comform to SQLAlchemy 2.0 notation. Reorganized code into labeled sections --- db/thing.py | 104 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 78 insertions(+), 26 deletions(-) diff --git a/db/thing.py b/db/thing.py index 79065e9e0..c4c7ef3b9 100644 --- a/db/thing.py +++ b/db/thing.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from sqlalchemy import Integer, ForeignKey, String, Column, Float +from sqlalchemy import Integer, ForeignKey, String, Column, Float, Text from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy from sqlalchemy.orm import relationship, mapped_column, Mapped from sqlalchemy_utils import TSVectorType @@ -39,21 +39,40 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMixin): + """ + Represents a physical object of interest being monitored (e.g., a well). + Stores static, core attributes of the physical installation. + """ + + # --- Foreign Keys --- + location_id: Mapped[int] = mapped_column( + Integer, ForeignKey("location.id"), nullable=False + ) - name = mapped_column(String(255), nullable=False) - description = mapped_column(String(500)) - thing_type = lexicon_term(nullable=True) - spring_type = lexicon_term(nullable=True) + # --- Columns --- + # TODO: should `name` be unique? + name: Mapped[str] = mapped_column( + nullable=False, + comment="The name of the thing (e.g., well name or identifier).", + ) + # TODO: what is the purpose of the `description` field? + description: Mapped[str] = mapped_column(String(500)) + thing_type: Mapped[str] = lexicon_term( + nullable=True, + comment="A controlled vocabulary field defining the type of infrastructure (e.g., 'Well', 'Spring', 'Stream Gauge').", + ) + spring_type: Mapped[str] = lexicon_term( + nullable=True, + comment="A controlled vocabulary field defining the type of spring (e.g., 'Mineral', 'Artesian', 'Seep', etc.).", + ) + # --- Relationships --- asset_associations = relationship( "AssetThingAssociation", back_populates="thing", overlaps="things", cascade="all, delete-orphan", ) - assets: AssociationProxy[list["Asset"]] = association_proxy( - "asset_associations", "asset" - ) location_associations = relationship( "LocationThingAssociation", @@ -62,9 +81,6 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix cascade="all, delete-orphan", order_by="LocationThingAssociation.effective_start.desc()", ) - locations: AssociationProxy[list["Location"]] = association_proxy( # noqa: F821 - "location_associations", "location" - ) contact_associations = relationship( "ThingContactAssociation", @@ -72,27 +88,61 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix overlaps="contacts", cascade="all, delete-orphan", ) + + # --- Association Proxies --- + assets: AssociationProxy[list["Asset"]] = association_proxy( + "asset_associations", "asset" + ) + + locations: AssociationProxy[list["Location"]] = association_proxy( # noqa: F821 + "location_associations", "location" + ) + + # Proxy to directly access the Contact objects associated with this Thing contacts: AssociationProxy[list["Contact"]] = association_proxy( # noqa: F821 "contact_associations", "contact" ) + # Proxy to directly access the Sensor (Equipment) deployed at this Thing. + sensor: AssociationProxy[List["Sensor"]] = association_proxy( + "deployments", "sensor" + ) + # Well fields - well_depth = Column( + well_depth: Mapped[float] = mapped_column( + Float, + nullable=True, + info={"unit": "feet below ground surface"}, + comment="Total depth of the well, from ground surface to the bottom of the well (in feet).", + ) + hole_depth: Mapped[float] = mapped_column( + Float, + nullable=True, + info={"unit": "feet below ground surface"}, + comment="Depth of the drilled hole, from ground surface to the bottom of the borehole (in feet).", + ) + well_purpose: Mapped[str] = lexicon_term( + nullable=True, + comment="A controlled vocabulary field defining the primary function of the well (e.g., 'Monitoring', 'Irrigation', 'Domestic', 'Livestock', 'Remediation').", + ) + well_casing_diameter: Mapped[float] = mapped_column( + Float, + nullable=True, + info={"unit": "inches"}, + comment="Diameter of the well casing in inches.", + ) + well_casing_depth: Mapped[float] = mapped_column( Float, nullable=True, info={"unit": "feet below ground surface"}, + comment="Depth of the well casing from ground surface to the bottom of the casing (in feet).", ) - hole_depth = Column( - Float, nullable=True, info={"unit": "feet below ground surface"} + well_casing_material: Mapped[str] = lexicon_term( + nullable=True, + comment="Material of the well casing (e.g., 'PVC', 'Steel', 'Concrete', 'Wood').", ) - well_type = lexicon_term() - # e.g., "Production", "Observation", etc. - # - well_casing_diameter = Column(Float, info={"unit": "inches"}) - well_casing_depth = Column(Float, info={"unit": "feet below ground surface"}) - well_casing_description = Column(String(50)) - well_construction_notes = Column(String(250)) + well_construction_notes: Mapped[str] = mapped_column(Text, nullable=True) # Spring fields @@ -125,12 +175,14 @@ class ThingIdLink(Base, AutoBaseMixin, ReleaseMixin): Represents a link associated with a Thing. """ - thing_id = mapped_column(Integer, ForeignKey("thing.id", ondelete="CASCADE")) - relation = lexicon_term(nullable=False) - alternate_id = mapped_column(String(100), nullable=False) - alternate_organization = lexicon_term(nullable=False) + thing_id: Mapped[int] = mapped_column( + Integer, ForeignKey("thing.id", ondelete="CASCADE") + ) + relation: Mapped[str] = lexicon_term(nullable=False) + alternate_id: Mapped[str] = mapped_column(String(100), nullable=False) + alternate_organization: Mapped[str] = lexicon_term(nullable=False) - thing = relationship("Thing", backref="links") + thing: Mapped["Thing"] = relationship("Thing", backref="links") class WellScreen(Base, AutoBaseMixin, ReleaseMixin): From c53bc5d1fceffb9226e4a42b8768fb9a86296eb8 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Mon, 29 Sep 2025 16:30:48 -0600 Subject: [PATCH 03/39] reformat: rename code sections for clarity --- db/thing.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/db/thing.py b/db/thing.py index c4c7ef3b9..346d086b4 100644 --- a/db/thing.py +++ b/db/thing.py @@ -61,10 +61,6 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix nullable=True, comment="A controlled vocabulary field defining the type of infrastructure (e.g., 'Well', 'Spring', 'Stream Gauge').", ) - spring_type: Mapped[str] = lexicon_term( - nullable=True, - comment="A controlled vocabulary field defining the type of spring (e.g., 'Mineral', 'Artesian', 'Seep', etc.).", - ) # --- Relationships --- asset_associations = relationship( @@ -108,7 +104,7 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix "deployments", "sensor" ) - # Well fields + # Well-related fields well_depth: Mapped[float] = mapped_column( Float, nullable=True, @@ -144,7 +140,11 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix well_construction_notes: Mapped[str] = mapped_column(Text, nullable=True) - # Spring fields + # Spring-related fields + spring_type: Mapped[str] = lexicon_term( + nullable=True, + comment="A controlled vocabulary field defining the type of spring (e.g., 'Mineral', 'Artesian', 'Seep', etc.).", + ) search_vector = Column( TSVectorType( From c13129a09d2a8c61a79d8b968b437a0f9b15973b Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Mon, 29 Sep 2025 16:33:35 -0600 Subject: [PATCH 04/39] reformat: move well-related and spring-related columns under the `columns` code section --- db/thing.py | 83 ++++++++++++++++++++++++++--------------------------- 1 file changed, 41 insertions(+), 42 deletions(-) diff --git a/db/thing.py b/db/thing.py index 346d086b4..452782610 100644 --- a/db/thing.py +++ b/db/thing.py @@ -61,6 +61,47 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix nullable=True, comment="A controlled vocabulary field defining the type of infrastructure (e.g., 'Well', 'Spring', 'Stream Gauge').", ) + # Well-related columns + well_depth: Mapped[float] = mapped_column( + Float, + nullable=True, + info={"unit": "feet below ground surface"}, + comment="Total depth of the well, from ground surface to the bottom of the well (in feet).", + ) + hole_depth: Mapped[float] = mapped_column( + Float, + nullable=True, + info={"unit": "feet below ground surface"}, + comment="Depth of the drilled hole, from ground surface to the bottom of the borehole (in feet).", + ) + well_purpose: Mapped[str] = lexicon_term( + nullable=True, + comment="A controlled vocabulary field defining the primary function of the well (e.g., 'Monitoring', 'Irrigation', 'Domestic', 'Livestock', 'Remediation').", + ) + well_casing_diameter: Mapped[float] = mapped_column( + Float, + nullable=True, + info={"unit": "inches"}, + comment="Diameter of the well casing in inches.", + ) + well_casing_depth: Mapped[float] = mapped_column( + Float, + nullable=True, + info={"unit": "feet below ground surface"}, + comment="Depth of the well casing from ground surface to the bottom of the casing (in feet).", + ) + well_casing_material: Mapped[str] = lexicon_term( + nullable=True, + comment="Material of the well casing (e.g., 'PVC', 'Steel', 'Concrete', 'Wood').", + ) + + well_construction_notes: Mapped[str] = mapped_column(Text, nullable=True) + + # Spring-related columns + spring_type: Mapped[str] = lexicon_term( + nullable=True, + comment="A controlled vocabulary field defining the type of spring (e.g., 'Mineral', 'Artesian', 'Seep', etc.).", + ) # --- Relationships --- asset_associations = relationship( @@ -104,48 +145,6 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix "deployments", "sensor" ) - # Well-related fields - well_depth: Mapped[float] = mapped_column( - Float, - nullable=True, - info={"unit": "feet below ground surface"}, - comment="Total depth of the well, from ground surface to the bottom of the well (in feet).", - ) - hole_depth: Mapped[float] = mapped_column( - Float, - nullable=True, - info={"unit": "feet below ground surface"}, - comment="Depth of the drilled hole, from ground surface to the bottom of the borehole (in feet).", - ) - well_purpose: Mapped[str] = lexicon_term( - nullable=True, - comment="A controlled vocabulary field defining the primary function of the well (e.g., 'Monitoring', 'Irrigation', 'Domestic', 'Livestock', 'Remediation').", - ) - well_casing_diameter: Mapped[float] = mapped_column( - Float, - nullable=True, - info={"unit": "inches"}, - comment="Diameter of the well casing in inches.", - ) - well_casing_depth: Mapped[float] = mapped_column( - Float, - nullable=True, - info={"unit": "feet below ground surface"}, - comment="Depth of the well casing from ground surface to the bottom of the casing (in feet).", - ) - well_casing_material: Mapped[str] = lexicon_term( - nullable=True, - comment="Material of the well casing (e.g., 'PVC', 'Steel', 'Concrete', 'Wood').", - ) - - well_construction_notes: Mapped[str] = mapped_column(Text, nullable=True) - - # Spring-related fields - spring_type: Mapped[str] = lexicon_term( - nullable=True, - comment="A controlled vocabulary field defining the type of spring (e.g., 'Mineral', 'Artesian', 'Seep', etc.).", - ) - search_vector = Column( TSVectorType( "name", "well_construction_notes", "well_type", "well_casing_description" From 431884b2327940ffe5b4466febc70cc85ac2a8aa Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Mon, 29 Sep 2025 16:35:22 -0600 Subject: [PATCH 05/39] feat: add versioning to Thing table --- db/thing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/db/thing.py b/db/thing.py index 452782610..b4e0e89d4 100644 --- a/db/thing.py +++ b/db/thing.py @@ -44,6 +44,7 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix Stores static, core attributes of the physical installation. """ + __versioned__ = {} # --- Foreign Keys --- location_id: Mapped[int] = mapped_column( Integer, ForeignKey("location.id"), nullable=False From 8c68fa01a9df3e21280652edac11dfd1f406733c Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 10:51:02 -0600 Subject: [PATCH 06/39] refactor: add descriptions to relationships in `Thing` model feat: add missing relationships to `Thing` model --- db/thing.py | 48 +++++++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/db/thing.py b/db/thing.py index b4e0e89d4..74d6a169b 100644 --- a/db/thing.py +++ b/db/thing.py @@ -36,6 +36,7 @@ from db.deployment import Deployment from db.sensor import Sensor from db.contact import Contact + from db.group import Group, GroupThingAssociation class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMixin): @@ -45,10 +46,6 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix """ __versioned__ = {} - # --- Foreign Keys --- - location_id: Mapped[int] = mapped_column( - Integer, ForeignKey("location.id"), nullable=False - ) # --- Columns --- # TODO: should `name` be unique? @@ -105,6 +102,8 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix ) # --- Relationships --- + # One-To-Many: A Thing can have many associated Assets. + # If the Thing is deleted, its asset associations will be deleted. asset_associations = relationship( "AssetThingAssociation", back_populates="thing", @@ -112,6 +111,8 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix cascade="all, delete-orphan", ) + # One-To-Many: A Thing can be at many locations over time. + # If the Thing is deleted, its location history will be deleted. location_associations = relationship( "LocationThingAssociation", back_populates="thing", @@ -127,11 +128,27 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix cascade="all, delete-orphan", ) + # One-To-Many: A Thing can have many FieldEvents over time. + field_events: Mapped[List["FieldEvent"]] = relationship( + "FieldEvent", back_populates="thing", cascade="all, delete-orphan" + ) + + # One-To-Many: A Thing can have many Deployments of sensors (equipment) over time. + deployments: Mapped[List["Deployment"]] = relationship( + "Deployment", back_populates="thing", cascade="all, delete-orphan" + ) + + # One To-Many: A Thing can be in many Groups over time. + group_thing_associations: Mapped[List["GroupThingAssociation"]] = relationship( + "GroupThingAssociation", back_populates="thing", cascade="all, delete-orphan" + ) + # --- Association Proxies --- assets: AssociationProxy[list["Asset"]] = association_proxy( "asset_associations", "asset" ) + # Proxy to directly access the Location associated with this Thing locations: AssociationProxy[list["Location"]] = association_proxy( # noqa: F821 "location_associations", "location" ) @@ -146,29 +163,18 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix "deployments", "sensor" ) + # Proxy to directly access the Group(s) this Thing is a member of. + groups: AssociationProxy[List["Group"]] = association_proxy( + "group_thing_associations", "group" + ) + + # Full-text search vector search_vector = Column( TSVectorType( "name", "well_construction_notes", "well_type", "well_casing_description" ) ) - # --- Relationships --- - # One-To-Many: A Thing can have many FieldEvents over time. - field_events: Mapped[List["FieldEvent"]] = relationship( - "FieldEvent", back_populates="thing", cascade="all, delete-orphan", uselist=True - ) - - # One-To-Many: A Thing can have many Deployments of sensors (equipment) over time. - deployments: Mapped[List["Deployment"]] = relationship( - "Deployment", back_populates="thing", cascade="all, delete-orphan" - ) - - # --- Association Proxies --- - # Proxy to directly access the Sensor deployed at this Thing. - sensors: AssociationProxy[List["Sensor"]] = association_proxy( - "deployments", "sensor" - ) - class ThingIdLink(Base, AutoBaseMixin, ReleaseMixin): """ From 602ae6a26e05d969fb6fe9ef13f5faf9da35b081 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 11:58:35 -0600 Subject: [PATCH 07/39] refactor: update relationships in `Thing` and `Group` tables. feat: add relationships to `GroupThingAssociation` table. feat: add proxy to `Group` table --- db/group.py | 28 +++++++++++++++++++++++++--- db/thing.py | 2 +- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/db/group.py b/db/group.py index 66f7a717b..f28dfc208 100644 --- a/db/group.py +++ b/db/group.py @@ -13,16 +13,21 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from typing import Optional +from typing import Optional, List, TYPE_CHECKING from geoalchemy2 import Geometry, WKBElement from sqlalchemy import String, Integer, ForeignKey from sqlalchemy.orm import relationship, Mapped from sqlalchemy.testing.schema import mapped_column +from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy from constants import SRID_WGS84 from db.base import Base, AutoBaseMixin, ReleaseMixin +if TYPE_CHECKING: + from db.group import GroupThingAssociation + from db.thing import Thing + class Group(Base, AutoBaseMixin, ReleaseMixin): # --- Column Definitions --- @@ -37,8 +42,18 @@ class Group(Base, AutoBaseMixin, ReleaseMixin): Integer, ForeignKey("group.id", ondelete="CASCADE"), nullable=True ) - # --- Relationship Definitions --- - things = relationship("Thing", secondary="group_thing_association") + # --- Relationships --- + # One-To-Many (Association Object): A Group has many members (Things). + # The GroupThingAssociation table manages this many-to-many relationship. + thing_associations: Mapped[List["GroupThingAssociation"]] = relationship( + "GroupThingAssociation", back_populates="group", cascade="all, delete-orphan" + ) + + # --- Association Proxies --- + # Many-To-Many Proxy: Provides direct access to the Thing objects in this group. + things: AssociationProxy[List["Thing"]] = association_proxy( + "thing_associations", "thing" + ) class GroupThingAssociation(Base, AutoBaseMixin): @@ -49,5 +64,12 @@ class GroupThingAssociation(Base, AutoBaseMixin): Integer, ForeignKey("thing.id", ondelete="CASCADE"), nullable=False ) + # --- Relationship Definitions --- + # Many-To-One: This association links to one Thing. + thing: Mapped["Thing"] = relationship("Thing", back_populates="thing_groups") + + # Many-To-One: This association links to one Group. + group: Mapped["Group"] = relationship("Group", back_populates="thing_associations") + # ============= EOF ============================================= diff --git a/db/thing.py b/db/thing.py index 74d6a169b..7bbf445fb 100644 --- a/db/thing.py +++ b/db/thing.py @@ -139,7 +139,7 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix ) # One To-Many: A Thing can be in many Groups over time. - group_thing_associations: Mapped[List["GroupThingAssociation"]] = relationship( + thing_groups: Mapped[List["GroupThingAssociation"]] = relationship( "GroupThingAssociation", back_populates="thing", cascade="all, delete-orphan" ) From 46333ae4c14fa5e23663aba2bf9954a24ce83ce0 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 12:17:13 -0600 Subject: [PATCH 08/39] feat: add `thing` relationship to `WellScreen` model --- db/thing.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/db/thing.py b/db/thing.py index 7bbf445fb..46946dbb0 100644 --- a/db/thing.py +++ b/db/thing.py @@ -192,6 +192,11 @@ class ThingIdLink(Base, AutoBaseMixin, ReleaseMixin): class WellScreen(Base, AutoBaseMixin, ReleaseMixin): + """ + Represents a single, discrete screened interval in a well. + A Thing can have multiple WellScreens. + """ + thing_id: Mapped[int] = mapped_column( ForeignKey("thing.id", ondelete="CASCADE"), nullable=False ) @@ -208,8 +213,9 @@ class WellScreen(Base, AutoBaseMixin, ReleaseMixin): ) nma_pk_wellscreens: Mapped[str] = mapped_column(String(100), nullable=True) - # Define a relationship to well if needed - thing: Mapped["Thing"] = relationship("Thing") + # --- Relationships --- + # Many-To-One: A WellScreen belongs to one Thing. + thing: Mapped["Thing"] = relationship("Thing", back_populates="screens") # TODO: this could be the model used to handle AMP monitoring From af284d6a5947a1bb7db3c204bce0ccdfa2cd0816 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 12:20:09 -0600 Subject: [PATCH 09/39] feat: add `screens` relationship to `Thing` model --- db/thing.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/db/thing.py b/db/thing.py index 46946dbb0..89f345fd6 100644 --- a/db/thing.py +++ b/db/thing.py @@ -143,6 +143,11 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix "GroupThingAssociation", back_populates="thing", cascade="all, delete-orphan" ) + # One-To-Many: A Thing (well) can have multiple screened intervals. + screens: Mapped[List["WellScreen"]] = relationship( + "WellScreen", back_populates="thing", cascade="all, delete-orphan" + ) + # --- Association Proxies --- assets: AssociationProxy[list["Asset"]] = association_proxy( "asset_associations", "asset" From f6a5460489943d0e2fd94d4e15ebf3d2eae8c659 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 12:26:31 -0600 Subject: [PATCH 10/39] refactor: update `screens` relationship to `Thing` model --- db/thing.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/db/thing.py b/db/thing.py index 89f345fd6..dc0525d77 100644 --- a/db/thing.py +++ b/db/thing.py @@ -145,7 +145,10 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix # One-To-Many: A Thing (well) can have multiple screened intervals. screens: Mapped[List["WellScreen"]] = relationship( - "WellScreen", back_populates="thing", cascade="all, delete-orphan" + "WellScreen", + back_populates="thing", + cascade="all, delete-orphan", + passive_deletes=True, ) # --- Association Proxies --- From 36a8f43df1fc15cfa3bd1acf0a44130f648f18f9 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 12:31:24 -0600 Subject: [PATCH 11/39] refactor: add 'passive_deletes=True' parameter to necessary relationships in the Thing model --- db/thing.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/db/thing.py b/db/thing.py index dc0525d77..f798080aa 100644 --- a/db/thing.py +++ b/db/thing.py @@ -118,6 +118,7 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix back_populates="thing", overlaps="location", cascade="all, delete-orphan", + passive_deletes=True, order_by="LocationThingAssociation.effective_start.desc()", ) @@ -130,17 +131,26 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix # One-To-Many: A Thing can have many FieldEvents over time. field_events: Mapped[List["FieldEvent"]] = relationship( - "FieldEvent", back_populates="thing", cascade="all, delete-orphan" + "FieldEvent", + back_populates="thing", + cascade="all, delete-orphan", + passive_deletes=True, ) # One-To-Many: A Thing can have many Deployments of sensors (equipment) over time. deployments: Mapped[List["Deployment"]] = relationship( - "Deployment", back_populates="thing", cascade="all, delete-orphan" + "Deployment", + back_populates="thing", + cascade="all, delete-orphan", + passive_deletes=True, ) # One To-Many: A Thing can be in many Groups over time. thing_groups: Mapped[List["GroupThingAssociation"]] = relationship( - "GroupThingAssociation", back_populates="thing", cascade="all, delete-orphan" + "GroupThingAssociation", + back_populates="thing", + cascade="all, delete-orphan", + passive_deletes=True, ) # One-To-Many: A Thing (well) can have multiple screened intervals. From b494e4b95d498f2d5c0ef7396184df96a7931df2 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 12:35:13 -0600 Subject: [PATCH 12/39] refactor: add 'ondelete=CASCADE' to thing_id foreign key --- db/deployment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/deployment.py b/db/deployment.py index 78eaa4ef0..749a76695 100644 --- a/db/deployment.py +++ b/db/deployment.py @@ -24,7 +24,7 @@ class Deployment(Base, AutoBaseMixin, ReleaseMixin): # --- Foreign Keys --- thing_id: Mapped[int] = mapped_column( - Integer, ForeignKey("thing.id"), nullable=False + Integer, ForeignKey("thing.id"), nullable=False, ondelete="CASCADE" ) sensor_id: Mapped[int] = mapped_column( Integer, ForeignKey("sensor.id"), nullable=False From c9ad45f3990525aa3445643a8bd877953a1a6568 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 12:37:31 -0600 Subject: [PATCH 13/39] refactor: add 'passive_deletes=True' to `asset_associations` relationship in Thing model --- db/thing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/db/thing.py b/db/thing.py index f798080aa..6c37e6d78 100644 --- a/db/thing.py +++ b/db/thing.py @@ -109,6 +109,7 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix back_populates="thing", overlaps="things", cascade="all, delete-orphan", + passive_deletes=True, ) # One-To-Many: A Thing can be at many locations over time. From 1f01dcc7ba3afb338a19b4e89a4ac21983b7e498 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 12:40:44 -0600 Subject: [PATCH 14/39] refactor: move `ondelete` parameter into foreign key constraint in Deployment model --- db/deployment.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/db/deployment.py b/db/deployment.py index 749a76695..69d45e8a7 100644 --- a/db/deployment.py +++ b/db/deployment.py @@ -4,6 +4,7 @@ at which Thing and for what period of time. """ +from tkinter.constants import CASCADE from typing import TYPE_CHECKING from sqlalchemy import Integer, ForeignKey, Date, Numeric, Text @@ -24,7 +25,9 @@ class Deployment(Base, AutoBaseMixin, ReleaseMixin): # --- Foreign Keys --- thing_id: Mapped[int] = mapped_column( - Integer, ForeignKey("thing.id"), nullable=False, ondelete="CASCADE" + Integer, + ForeignKey("thing.id", ondelete=CASCADE), + nullable=False, ) sensor_id: Mapped[int] = mapped_column( Integer, ForeignKey("sensor.id"), nullable=False From 2c96c0cab4df844d382c49c1cc8a2d3f38c69b2f Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 13:20:29 -0600 Subject: [PATCH 15/39] refactor: add TYPE_CHECKING to `Contact` model to mitigate circular references --- db/contact.py | 15 ++++++++++++--- db/thing.py | 4 +--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/db/contact.py b/db/contact.py index 7a4f79340..6ba6a518e 100644 --- a/db/contact.py +++ b/db/contact.py @@ -17,10 +17,15 @@ from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy from sqlalchemy.orm import relationship, Mapped, mapped_column from sqlalchemy_utils import TSVectorType -from typing import List +from typing import List, TYPE_CHECKING from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term +if TYPE_CHECKING: + from db.thing import Thing + from db.field import FieldEvent + from db.field import FieldEventContactAssociation + class ThingContactAssociation(Base, AutoBaseMixin): thing_id: Mapped[int] = mapped_column( @@ -30,8 +35,12 @@ class ThingContactAssociation(Base, AutoBaseMixin): ForeignKey("contact.id", ondelete="CASCADE"), nullable=False ) - contact: Mapped[List["Contact"]] = relationship("Contact") - thing: Mapped[List["Thing"]] = relationship("Thing") # noqa: F821 + contact: Mapped["Contact"] = relationship( + "Contact", back_populates="thing_associations" + ) + thing: Mapped["Thing"] = relationship( + "Thing", back_populates="contact_associations" + ) # noqa: F821 class Contact(Base, AutoBaseMixin, ReleaseMixin): diff --git a/db/thing.py b/db/thing.py index 6c37e6d78..3216b6cd8 100644 --- a/db/thing.py +++ b/db/thing.py @@ -183,9 +183,7 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix ) # Proxy to directly access the Group(s) this Thing is a member of. - groups: AssociationProxy[List["Group"]] = association_proxy( - "group_thing_associations", "group" - ) + groups: AssociationProxy[List["Group"]] = association_proxy("thing_groups", "group") # Full-text search vector search_vector = Column( From 6106c6893966d1d3fca5c7a7aa68740d0f89d22c Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 14:20:24 -0600 Subject: [PATCH 16/39] feat: add `first_visit_date` field to the `Thing` model --- db/thing.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/db/thing.py b/db/thing.py index 3216b6cd8..b58d58dae 100644 --- a/db/thing.py +++ b/db/thing.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from sqlalchemy import Integer, ForeignKey, String, Column, Float, Text +from sqlalchemy import Integer, ForeignKey, String, Column, Float, Text, Date from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy from sqlalchemy.orm import relationship, mapped_column, Mapped from sqlalchemy_utils import TSVectorType @@ -59,6 +59,11 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix nullable=True, comment="A controlled vocabulary field defining the type of infrastructure (e.g., 'Well', 'Spring', 'Stream Gauge').", ) + first_visit_date: Mapped[Date] = mapped_column( + Date, + nullable=True, + comment="The date of NMBGMR's first recorded interaction with this specific `Thing`.", + ) # Well-related columns well_depth: Mapped[float] = mapped_column( Float, From 98a0d64698d39e891ef20828ed76854523da23c0 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 14:48:03 -0600 Subject: [PATCH 17/39] refactor: rename `well_type` field to `well_purpose` as decided with AMP folks on 2025-08-29. --- .../66ac1af4ba69_initial_migration.py | 4 +-- api/search.py | 2 +- core/lexicon.json | 10 +++---- db/thing.py | 2 +- schemas/thing.py | 10 ++++--- tests/mvp.py | 4 +-- tests/test_thing.py | 26 +++++++++---------- transfers/well_transfer.py | 2 +- 8 files changed, 32 insertions(+), 28 deletions(-) diff --git a/alembic/versions/66ac1af4ba69_initial_migration.py b/alembic/versions/66ac1af4ba69_initial_migration.py index e07bb5807..f8813f9db 100644 --- a/alembic/versions/66ac1af4ba69_initial_migration.py +++ b/alembic/versions/66ac1af4ba69_initial_migration.py @@ -204,7 +204,7 @@ def upgrade() -> None: # sa.Column('spring_type', sa.String(length=100), nullable=True), # sa.Column('well_depth', sa.Float(), nullable=True), # sa.Column('hole_depth', sa.Float(), nullable=True), - # sa.Column('well_type', sa.String(length=100), nullable=True), + # sa.Column('well_purpose', sa.String(length=100), nullable=True), # sa.Column('well_casing_diameter', sa.Float(), nullable=True), # sa.Column('well_casing_depth', sa.Float(), nullable=True), # sa.Column('well_casing_description', sa.String(length=50), nullable=True), @@ -216,7 +216,7 @@ def upgrade() -> None: # sa.ForeignKeyConstraint(['release_status'], ['lexicon_term.term'], ), # sa.ForeignKeyConstraint(['spring_type'], ['lexicon_term.term'], ), # sa.ForeignKeyConstraint(['thing_type'], ['lexicon_term.term'], ), - # sa.ForeignKeyConstraint(['well_type'], ['lexicon_term.term'], ), + # sa.ForeignKeyConstraint(['well_purpose'], ['lexicon_term.term'], ), # sa.PrimaryKeyConstraint('id') # ) # op.create_index('ix_thing_search_vector', 'thing', ['search_vector'], unique=False, postgresql_using='gin') diff --git a/api/search.py b/api/search.py index db1d9b661..2a4158841 100644 --- a/api/search.py +++ b/api/search.py @@ -103,7 +103,7 @@ def make_well_response(thing: Thing) -> dict: "Wells", thing, { - "well_type": thing.well_type, + "well_purpose": thing.well_type, "well_depth": thing.well_depth, "hole_depth": thing.hole_depth, }, diff --git a/core/lexicon.json b/core/lexicon.json index 9cc4365f7..68abe4f88 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -41,7 +41,7 @@ {"name": "thing_type", "description": null}, {"name": "unit", "description": null}, {"name": "vertical_datum", "description": null}, - {"name": "well_type", "description": null}], + {"name": "well_purpose", "description": null}], "terms": [ {"categories": ["qc_type"], "term": "Normal", "definition": "The primary environmental sample collected from the well, spring, or soil boring."}, {"categories": ["qc_type"], "term": "Duplicate", "definition": "A second, independent sample collected at the same location, at the same time, and in the same manner as the normal sample. This sample is sent to the primary laboratory."}, @@ -431,10 +431,10 @@ {"categories": ["spring_type"], "term": "Perennial", "definition": "perennial spring"}, {"categories": ["spring_type"], "term": "Thermal", "definition": "thermal spring"}, {"categories": ["spring_type"], "term": "Mineral", "definition": "mineral spring"}, - {"categories": ["well_type"], "term": "Exploration", "definition": "Exploration well"}, - {"categories": ["well_type"], "term": "Monitoring", "definition": "Monitoring"}, - {"categories": ["well_type"], "term": "Production", "definition": "Production"}, - {"categories": ["well_type"], "term": "Injection", "definition": "Injection"}, + {"categories": ["well_purpose"], "term": "Exploration", "definition": "Exploration well"}, + {"categories": ["well_purpose"], "term": "Monitoring", "definition": "Monitoring"}, + {"categories": ["well_purpose"], "term": "Production", "definition": "Production"}, + {"categories": ["well_purpose"], "term": "Injection", "definition": "Injection"}, {"categories": ["casing_material"], "term": "PVC", "definition": "Polyvinyl Chloride"}, {"categories": ["casing_material"], "term": "Steel", "definition": "Steel"}, {"categories": ["casing_material"], "term": "Concrete", "definition": "Concrete"}, diff --git a/db/thing.py b/db/thing.py index b58d58dae..e13ab4274 100644 --- a/db/thing.py +++ b/db/thing.py @@ -193,7 +193,7 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix # Full-text search vector search_vector = Column( TSVectorType( - "name", "well_construction_notes", "well_type", "well_casing_description" + "name", "well_construction_notes", "well_purpose", "well_casing_description" ) ) diff --git a/schemas/thing.py b/schemas/thing.py index 2ac3d72ba..8583274b9 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -20,6 +20,8 @@ from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel from schemas.location import LocationResponse +from datetime import date + # -------- CREATE ---------- class CreateThingIdLink(BaseModel): @@ -46,6 +48,7 @@ class CreateBaseThing(BaseCreateModel): location_id: int | None = None # Optional location ID for the thing group_id: int | None = None # Optional group ID for the thing name: str # Name of the thing + first_visit_date: date | None = None # Date of NMBGMR's first visit class CreateWell(CreateBaseThing): @@ -53,7 +56,7 @@ class CreateWell(CreateBaseThing): Schema for creating a well. """ - well_type: str | None = None + well_purpose: str | None = None well_depth: float | None = None # in feet hole_depth: float | None = None # in feet well_construction_notes: str | None = None @@ -93,6 +96,7 @@ class BaseThingResponse(BaseResponseModel): name: str thing_type: str active_location: LocationResponse | None = None + first_visit_date: date | None = None class WellResponse(BaseThingResponse): @@ -104,7 +108,7 @@ class WellResponse(BaseThingResponse): # ose_pod_id: str | None = None # usgs_id: str | None = None - well_type: str | None = None # e.g., "Production", "Observation", etc. + well_purpose: str | None = None # e.g., "Production", "Observation", etc. well_depth: float | None = None # in feet hole_depth: float | None = None # in feet well_construction_notes: str | None = None @@ -196,7 +200,7 @@ class UpdateThing(BaseUpdateModel): class UpdateWell(UpdateThing): - well_type: str | None = None + well_purpose: str | None = None well_depth: float | None = None # in feet hole_depth: float | None = None # in feet well_construction_notes: str | None = None diff --git a/tests/mvp.py b/tests/mvp.py index 7d2d06460..5af46680b 100644 --- a/tests/mvp.py +++ b/tests/mvp.py @@ -35,7 +35,7 @@ def test_add_location_all(): def test_add_well_minimum(): well = { "location_id": 1, - "well_type": "Monitoring", + "well_purpose": "Monitoring", } @@ -44,7 +44,7 @@ def test_add_well_all(): "location_id": 1, "api_id": "1001-0001", "ose_pod_id": "RA-0001", - "well_type": "Monitoring", + "well_purpose": "Monitoring", "well_depth": 100.0, "hole_depth": 100.0, "casing_diameter": 10.0, diff --git a/tests/test_thing.py b/tests/test_thing.py index b9f7485bb..1df697eea 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -60,7 +60,7 @@ def test_add_water_well(location, group): "group_id": group.id, "release_status": "draft", "name": "Test Well", - "well_type": "Monitoring", + "well_purpose": "Monitoring", "well_depth": 100.0, "hole_depth": 110, "well_construction_notes": "this is a test of notes", @@ -74,7 +74,7 @@ def test_add_water_well(location, group): assert data["release_status"] == payload["release_status"] assert data["name"] == payload["name"] assert data["thing_type"] == "water well" - assert data["well_type"] == payload["well_type"] + assert data["well_purpose"] == payload["well_purpose"] assert data["hole_depth"] == payload["hole_depth"] assert data["well_depth"] == payload["well_depth"] assert data["well_construction_notes"] == payload["well_construction_notes"] @@ -95,7 +95,7 @@ def test_add_water_well_409_bad_group_id(location): "group_id": bad_group_id, # Invalid group ID "release_status": "draft", "name": "Test Well", - "well_type": "Monitoring", + "well_purpose": "Monitoring", "well_depth": 100.0, "hole_depth": 110, "well_construction_notes": "this is a test of notes", @@ -117,7 +117,7 @@ def test_add_water_well_409_bad_location_id(group): "group_id": group.id, # Invalid group ID "release_status": "draft", "name": "Test Well", - "well_type": "Monitoring", + "well_purpose": "Monitoring", "well_depth": 100.0, "hole_depth": 110, "well_construction_notes": "this is a test of notes", @@ -331,7 +331,7 @@ def test_get_water_wells(water_well_thing, location): assert data["items"][0]["name"] == water_well_thing.name assert data["items"][0]["thing_type"] == water_well_thing.thing_type assert data["items"][0]["release_status"] == water_well_thing.release_status - assert data["items"][0]["well_type"] == water_well_thing.well_type + assert data["items"][0]["well_purpose"] == water_well_thing.well_purpose assert data["items"][0]["well_depth"] == water_well_thing.well_depth assert data["items"][0]["hole_depth"] == water_well_thing.hole_depth assert ( @@ -356,7 +356,7 @@ def test_get_water_well_by_id(water_well_thing, location): assert data["name"] == water_well_thing.name assert data["thing_type"] == water_well_thing.thing_type assert data["release_status"] == water_well_thing.release_status - assert data["well_type"] == water_well_thing.well_type + assert data["well_purpose"] == water_well_thing.well_purpose assert data["well_depth"] == water_well_thing.well_depth assert data["hole_depth"] == water_well_thing.hole_depth assert data["well_construction_notes"] == water_well_thing.well_construction_notes @@ -612,7 +612,7 @@ def test_get_things(water_well_thing, spring_thing, location): assert data["items"][0]["name"] == water_well_thing.name assert data["items"][0]["thing_type"] == water_well_thing.thing_type assert data["items"][0]["release_status"] == water_well_thing.release_status - assert data["items"][0]["well_type"] == water_well_thing.well_type + assert data["items"][0]["well_purpose"] == water_well_thing.well_purpose assert data["items"][0]["well_depth"] == water_well_thing.well_depth assert data["items"][0]["hole_depth"] == water_well_thing.hole_depth assert ( @@ -630,7 +630,7 @@ def test_get_things(water_well_thing, spring_thing, location): assert data["items"][1]["thing_type"] == spring_thing.thing_type assert data["items"][1]["release_status"] == spring_thing.release_status assert data["items"][1]["spring_type"] == spring_thing.spring_type - assert data["items"][1]["well_type"] is None + assert data["items"][1]["well_purpose"] is None assert data["items"][1]["well_depth"] is None assert data["items"][1]["hole_depth"] is None assert data["items"][1]["well_construction_notes"] is None @@ -650,7 +650,7 @@ def test_get_thing_by_id(water_well_thing, location): assert data["name"] == water_well_thing.name assert data["thing_type"] == water_well_thing.thing_type assert data["release_status"] == water_well_thing.release_status - assert data["well_type"] == water_well_thing.well_type + assert data["well_purpose"] == water_well_thing.well_purpose assert data["well_depth"] == water_well_thing.well_depth assert data["hole_depth"] == water_well_thing.hole_depth assert data["well_construction_notes"] == water_well_thing.well_construction_notes @@ -727,7 +727,7 @@ def test_patch_water_well(water_well_thing, location): payload = { "name": "patched water well", "release_status": "provisional", - "well_type": "Injection", + "well_purpose": "Injection", "well_depth": 20, "hole_depth": 40, "well_construction_notes": "patched well construction notes", @@ -737,7 +737,7 @@ def test_patch_water_well(water_well_thing, location): data = response.json() assert data["name"] == payload["name"] assert data["release_status"] == payload["release_status"] - assert data["well_type"] == payload["well_type"] + assert data["well_purpose"] == payload["well_purpose"] assert data["well_depth"] == payload["well_depth"] assert data["hole_depth"] == payload["hole_depth"] assert data["well_construction_notes"] == payload["well_construction_notes"] @@ -756,7 +756,7 @@ def test_patch_water_well_404_not_found(): payload = { "name": "patched water well", "release_status": "provisional", - "well_type": "Injection", + "well_purpose": "Injection", "well_depth": 20, "hole_depth": 40, "well_construction_notes": "patched well construction notes", @@ -771,7 +771,7 @@ def test_patch_water_well_404_wrong_type(spring_thing): payload = { "name": "patched water well", "release_status": "provisional", - "well_type": "Injection", + "well_purpose": "Injection", "well_depth": 20, "hole_depth": 40, "well_construction_notes": "patched well construction notes", diff --git a/transfers/well_transfer.py b/transfers/well_transfer.py index f62ffb6b8..33520814c 100644 --- a/transfers/well_transfer.py +++ b/transfers/well_transfer.py @@ -119,7 +119,7 @@ def transfer_wells(session, limit=0): # ) # ADDED.append(wt) # - # well.well_type = wt + # well.well_purpose = wt assoc = LocationThingAssociation() From 94bd1030dee5e267cb1ae6396afc3901c3e61f13 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 15:11:22 -0600 Subject: [PATCH 18/39] feat: add `first_visit_date` field to applicable tests in `test.thing.py` --- tests/test_thing.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_thing.py b/tests/test_thing.py index 1df697eea..b5470458d 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -60,6 +60,7 @@ def test_add_water_well(location, group): "group_id": group.id, "release_status": "draft", "name": "Test Well", + "first_visit_date": "2023-01-01", "well_purpose": "Monitoring", "well_depth": 100.0, "hole_depth": 110, @@ -73,6 +74,7 @@ def test_add_water_well(location, group): assert "created_at" in data assert data["release_status"] == payload["release_status"] assert data["name"] == payload["name"] + assert data["first_visit_date"] == payload["first_visit_date"] assert data["thing_type"] == "water well" assert data["well_purpose"] == payload["well_purpose"] assert data["hole_depth"] == payload["hole_depth"] @@ -95,6 +97,7 @@ def test_add_water_well_409_bad_group_id(location): "group_id": bad_group_id, # Invalid group ID "release_status": "draft", "name": "Test Well", + "first_visit_date": "2023-01-01", "well_purpose": "Monitoring", "well_depth": 100.0, "hole_depth": 110, @@ -117,6 +120,7 @@ def test_add_water_well_409_bad_location_id(group): "group_id": group.id, # Invalid group ID "release_status": "draft", "name": "Test Well", + "first_visit_date": "2023-01-01", "well_purpose": "Monitoring", "well_depth": 100.0, "hole_depth": 110, @@ -137,6 +141,7 @@ def test_add_spring(location, group): "location_id": location.id, "group_id": group.id, "name": "test spring", + "first_visit_date": "2023-01-01", "release_status": "draft", "spring_type": "Ephemeral", } @@ -146,6 +151,7 @@ def test_add_spring(location, group): assert "id" in data assert "created_at" in data assert data["name"] == payload["name"] + assert data["first_visit_date"] == payload["first_visit_date"] assert data["release_status"] == payload["release_status"] assert data["spring_type"] == payload["spring_type"] @@ -164,6 +170,7 @@ def test_add_spring_409_bad_group_id(location): "location_id": location.id, "group_id": bad_group_id, # Invalid group ID "name": "test spring", + "first_visit_date": "2023-01-01", "release_status": "draft", "spring_type": "Ephemeral", } @@ -182,6 +189,7 @@ def test_add_spring_409_bad_location_id(group): "location_id": bad_location_id, "group_id": group.id, # Invalid group ID "name": "test spring", + "first_visit_date": "2023-01-01", "release_status": "draft", "spring_type": "Ephemeral", } @@ -329,6 +337,10 @@ def test_get_water_wells(water_well_thing, location): "created_at" ] == water_well_thing.created_at.isoformat().replace("+00:00", "Z") assert data["items"][0]["name"] == water_well_thing.name + assert ( + data["items"][0]["first_visit_date"] + == water_well_thing.first_visit_date.isoformat() + ) assert data["items"][0]["thing_type"] == water_well_thing.thing_type assert data["items"][0]["release_status"] == water_well_thing.release_status assert data["items"][0]["well_purpose"] == water_well_thing.well_purpose @@ -354,6 +366,7 @@ def test_get_water_well_by_id(water_well_thing, location): "+00:00", "Z" ) assert data["name"] == water_well_thing.name + assert data["first_visit_date"] == water_well_thing.first_visit_date.isoformat() assert data["thing_type"] == water_well_thing.thing_type assert data["release_status"] == water_well_thing.release_status assert data["well_purpose"] == water_well_thing.well_purpose @@ -399,6 +412,10 @@ def test_get_springs(spring_thing, location): "created_at" ] == spring_thing.created_at.isoformat().replace("+00:00", "Z") assert data["items"][0]["name"] == spring_thing.name + assert ( + data["items"][0]["first_visit_date"] + == spring_thing.first_visit_date.isoformat() + ) assert data["items"][0]["thing_type"] == spring_thing.thing_type assert data["items"][0]["release_status"] == spring_thing.release_status assert data["items"][0]["spring_type"] == spring_thing.spring_type @@ -418,6 +435,7 @@ def test_get_spring_by_id(spring_thing, location): "+00:00", "Z" ) assert data["name"] == spring_thing.name + assert data["first_visit_date"] == spring_thing.first_visit_date.isoformat() assert data["thing_type"] == spring_thing.thing_type assert data["release_status"] == spring_thing.release_status assert data["spring_type"] == spring_thing.spring_type @@ -610,6 +628,10 @@ def test_get_things(water_well_thing, spring_thing, location): "created_at" ] == water_well_thing.created_at.isoformat().replace("+00:00", "Z") assert data["items"][0]["name"] == water_well_thing.name + assert ( + data["items"][0]["first_visit_date"] + == water_well_thing.first_visit_date.isoformat() + ) assert data["items"][0]["thing_type"] == water_well_thing.thing_type assert data["items"][0]["release_status"] == water_well_thing.release_status assert data["items"][0]["well_purpose"] == water_well_thing.well_purpose @@ -627,6 +649,10 @@ def test_get_things(water_well_thing, spring_thing, location): "created_at" ] == spring_thing.created_at.isoformat().replace("+00:00", "Z") assert data["items"][1]["name"] == spring_thing.name + assert ( + data["items"][1]["first_visit_date"] + == spring_thing.first_visit_date.isoformat() + ) assert data["items"][1]["thing_type"] == spring_thing.thing_type assert data["items"][1]["release_status"] == spring_thing.release_status assert data["items"][1]["spring_type"] == spring_thing.spring_type @@ -648,6 +674,7 @@ def test_get_thing_by_id(water_well_thing, location): ) assert data["release_status"] == water_well_thing.release_status assert data["name"] == water_well_thing.name + assert data["first_visit_date"] == water_well_thing.first_visit_date.isoformat() assert data["thing_type"] == water_well_thing.thing_type assert data["release_status"] == water_well_thing.release_status assert data["well_purpose"] == water_well_thing.well_purpose @@ -726,6 +753,7 @@ def test_get_thing_by_id_404_not_found(water_well_thing): def test_patch_water_well(water_well_thing, location): payload = { "name": "patched water well", + "first_visit_date": "2023-02-02", "release_status": "provisional", "well_purpose": "Injection", "well_depth": 20, @@ -736,6 +764,7 @@ def test_patch_water_well(water_well_thing, location): assert response.status_code == 200 data = response.json() assert data["name"] == payload["name"] + assert data["first_visit_date"] == payload["first_visit_date"] assert data["release_status"] == payload["release_status"] assert data["well_purpose"] == payload["well_purpose"] assert data["well_depth"] == payload["well_depth"] @@ -755,6 +784,7 @@ def test_patch_water_well_404_not_found(): bad_id = 99999 payload = { "name": "patched water well", + "first_visit_date": "2023-02-02", "release_status": "provisional", "well_purpose": "Injection", "well_depth": 20, @@ -770,6 +800,7 @@ def test_patch_water_well_404_not_found(): def test_patch_water_well_404_wrong_type(spring_thing): payload = { "name": "patched water well", + "first_visit_date": "2023-02-02", "release_status": "provisional", "well_purpose": "Injection", "well_depth": 20, @@ -791,6 +822,7 @@ def test_patch_water_well_404_wrong_type(spring_thing): def test_patch_spring(spring_thing, location): payload = { "name": "patched spring", + "first_visit_date": "2023-03-03", "release_status": "private", "spring_type": "Mineral", } @@ -798,6 +830,7 @@ def test_patch_spring(spring_thing, location): assert response.status_code == 200 data = response.json() assert data["name"] == payload["name"] + assert data["first_visit_date"] == payload["first_visit_date"] assert data["release_status"] == payload["release_status"] assert data["spring_type"] == payload["spring_type"] @@ -814,6 +847,7 @@ def test_patch_spring_404_not_found(spring_thing): bad_id = 99999 payload = { "name": "patched spring", + "first_visit_date": "2023-03-03", "release_status": "private", "spring_type": "Mineral", } @@ -826,6 +860,7 @@ def test_patch_spring_404_not_found(spring_thing): def test_patch_spring_404_wrong_type(water_well_thing): payload = { "name": "patched spring", + "first_visit_date": "2023-03-03", "release_status": "private", "spring_type": "Mineral", } From c18a341ca816d35fd09512701b246cd7f3d63a4d Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 15:16:11 -0600 Subject: [PATCH 19/39] feat: add `first_visit_date` field to `tests/conftest.py` --- tests/conftest.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index fe0fe8f87..ce861a255 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,6 +51,7 @@ def water_well_thing(location): with session_ctx() as session: water_well = Thing( name="Test Well", + first_visit_date="2023-03-03", thing_type="water well", release_status="draft", well_type="Production", @@ -142,6 +143,7 @@ def spring_thing(location): with session_ctx() as session: spring = Thing( name="Test Spring", + first_visit_date="2023-03-03", thing_type="spring", release_status="draft", spring_type="Artesian", @@ -163,6 +165,7 @@ def second_spring_thing(location): with session_ctx() as session: spring = Thing( name="Second Test Spring", + first_visit_date="2023-03-03", thing_type="spring", release_status="draft", spring_type="Artesian", From 64b2cba5349e65a08e83e1e3247bf9964c299288 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 20:18:15 -0600 Subject: [PATCH 20/39] refactor: correct polymorphic parent relationship on `Permission` table from `foreign(permissible_id) == Thing.thing_id` to `foreign(permissible_id) == Thing.id` --- db/permission.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/permission.py b/db/permission.py index 93e688e86..db4023c20 100644 --- a/db/permission.py +++ b/db/permission.py @@ -63,14 +63,14 @@ class Permission(Base, AutoBaseMixin, ReleaseMixin): _thing_target: Mapped["Thing"] = relationship( "Thing", primaryjoin=and_( - foreign(permissible_id) == Thing.thing_id, permissible_type == "Thing" + foreign(permissible_id) == Thing.id, permissible_type == "Thing" ), viewonly=True, ) _location_target: Mapped["Location"] = relationship( "Location", primaryjoin=and_( - foreign(permissible_id) == Location.location_id, + foreign(permissible_id) == Location.id, permissible_type == "Location", ), viewonly=True, From 099549208ffe02f06a91a450256e0fb413224c47 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 21:59:02 -0600 Subject: [PATCH 21/39] fix(models): Correct primaryjoin condition in `PermissionMixin` and `StatusHistoryMixin` The previous logic dynamically generated a primary key name based on the class name (e.g., "thing_id"), which failed for models using the AutoBaseMixin that provides a standard "id" column. This change modifies the f-string to use the correct ".id" attribute, resolving the AttributeError during mapper configuration. --- db/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/base.py b/db/base.py index 31ec9ecd4..97a6b7cfc 100644 --- a/db/base.py +++ b/db/base.py @@ -184,7 +184,7 @@ def status_history(self): # One-to-Many polymorphic relationship return relationship( "StatusHistory", - primaryjoin=f"and_({self.__name__}.{self.__name__.lower()}_id==StatusHistory.statusable_id, " + primaryjoin=f"and_({self.__name__}.id==StatusHistory.statusable_id, " f"StatusHistory.statusable_type=='{self.__name__}')", cascade="all, delete-orphan", lazy="selectin", @@ -203,7 +203,7 @@ def permissions(self): # One-to-Many polymorphic relationship return relationship( "Permission", - primaryjoin=f"and_({self.__name__}.{self.__name__.lower()}_id==Permission.permissible_id, " + primaryjoin=f"and_({self.__name__}.id==Permission.permissible_id, " f"Permission.permissible_type=='{self.__name__}')", lazy="selectin", viewonly=True, From 680ac5d21beb01e6a6e07adb361023a1ba689f79 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 22:02:00 -0600 Subject: [PATCH 22/39] feat: add `permission` and `status` history models to `db/__init__.py` --- db/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/db/__init__.py b/db/__init__.py index bf0db93be..3cda51bd5 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -31,9 +31,11 @@ from db.lexicon import * from db.location import * from db.observation import * +from db.permission import * from db.publication import * from db.sample import * from db.sensor import * +from db.status_history import * from db.thing import * from sqlalchemy import ( From 275ae12b31f0ec8792e603995a6636d5862a2130 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 22:21:43 -0600 Subject: [PATCH 23/39] fix(models): Use string-based join in Permission model The polymorphic relationships `_thing_target` and `_location_target` caused a `NameError` at runtime because they referenced the `Thing` and `Location` models directly. These models were only imported within a `TYPE_CHECKING` block to avoid circular dependencies, making them unavailable when the code was executed. This commit refactors the `primaryjoin` condition to be a string. This allows SQLAlchemy to defer the evaluation of the join until all models are fully loaded, resolving the `NameError` and making the relationship definitions more robust. --- db/permission.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/db/permission.py b/db/permission.py index db4023c20..d9c3bc671 100644 --- a/db/permission.py +++ b/db/permission.py @@ -16,9 +16,8 @@ Boolean, Date, Text, - and_, ) -from sqlalchemy.orm import relationship, Mapped, mapped_column, foreign +from sqlalchemy.orm import relationship, Mapped, mapped_column from db.base import Base, AutoBaseMixin, ReleaseMixin @@ -62,17 +61,14 @@ class Permission(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(permissible_id) == Thing.id, permissible_type == "Thing" - ), + primaryjoin="and_(foreign(Permission.permissible_id) == Thing.id, " + "Permission.permissible_type == 'Thing')", viewonly=True, ) _location_target: Mapped["Location"] = relationship( "Location", - primaryjoin=and_( - foreign(permissible_id) == Location.id, - permissible_type == "Location", - ), + primaryjoin="and_(foreign(Permission.permissible_id) == Location.id, " + "Permission.permissible_type == 'Location')", viewonly=True, ) From c3650f31795f3c98128faccb49f31568132dcb32 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Tue, 30 Sep 2025 22:25:01 -0600 Subject: [PATCH 24/39] fix(models): Correct ForeignKey reference for `contact_id` in `Permission` model --- db/permission.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/permission.py b/db/permission.py index d9c3bc671..340e587f7 100644 --- a/db/permission.py +++ b/db/permission.py @@ -36,7 +36,7 @@ class Permission(Base, AutoBaseMixin, ReleaseMixin): # --- Foreign Keys --- contact_id: Mapped[int] = mapped_column( - Integer, ForeignKey("contact.contact_id"), nullable=False + Integer, ForeignKey("contact.id"), nullable=False ) # --- Columns --- From 01127a3a54022781fd835f35e5bd45e4d012c1fa Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Wed, 1 Oct 2025 09:05:41 -0600 Subject: [PATCH 25/39] feat: Add `Permission` relationship to `Contact` table --- db/contact.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/db/contact.py b/db/contact.py index 6ba6a518e..c0850ac05 100644 --- a/db/contact.py +++ b/db/contact.py @@ -25,6 +25,7 @@ from db.thing import Thing from db.field import FieldEvent from db.field import FieldEventContactAssociation + from db.permission import Permission class ThingContactAssociation(Base, AutoBaseMixin): @@ -59,6 +60,10 @@ class Contact(Base, AutoBaseMixin, ReleaseMixin): addresses: Mapped[List["Address"]] = relationship( "Address", back_populates="contact", passive_deletes=True ) + # One-To-Many: A Contact can grant many Permissions. + permissions: Mapped[List["Permission"]] = relationship( + "Permission", back_populates="contact", cascade="all, delete, delete-orphan" + ) search_vector: Mapped[TSVectorType] = mapped_column( TSVectorType("name", "role", "organization", "nma_pk_owners") From e9bedf31682619e46d8ce56ea8b519ef3fb53160 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Wed, 1 Oct 2025 09:18:39 -0600 Subject: [PATCH 26/39] refactor: Add 'cascade="all, delete, delete-orphan"' to phone, emails, and addresses relationships in `Contact` model --- db/contact.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/db/contact.py b/db/contact.py index c0850ac05..d41fa1314 100644 --- a/db/contact.py +++ b/db/contact.py @@ -51,14 +51,18 @@ class Contact(Base, AutoBaseMixin, ReleaseMixin): contact_type: Mapped[str] = lexicon_term(nullable=False) nma_pk_owners: Mapped[str] = mapped_column(String(100), nullable=True) + # --- Relationships --- + # One-To-Many: A Contact can have many phone numbers. phones: Mapped[List["Phone"]] = relationship( - "Phone", back_populates="contact", passive_deletes=True + "Phone", back_populates="contact", cascade="all, delete, delete-orphan" ) + # One-To-Many: A Contact can have many email addresses. emails: Mapped[List["Email"]] = relationship( - "Email", back_populates="contact", passive_deletes=True + "Email", back_populates="contact", cascade="all, delete, delete-orphan" ) + # One-To-Many: A Contact can have many addresses. addresses: Mapped[List["Address"]] = relationship( - "Address", back_populates="contact", passive_deletes=True + "Address", back_populates="contact", cascade="all, delete, delete-orphan" ) # One-To-Many: A Contact can grant many Permissions. permissions: Mapped[List["Permission"]] = relationship( From dc93b6a5e0eb1b4ef65f26b47815ee9acb078c18 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Wed, 1 Oct 2025 09:38:43 -0600 Subject: [PATCH 27/39] refactor: reorganize `Contact` model Columns, relationships, and proxies were randomly added to the `Contact` model. It has been reorganized so like things are grouped together under a designed header. --- db/contact.py | 54 +++++++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/db/contact.py b/db/contact.py index d41fa1314..df05beede 100644 --- a/db/contact.py +++ b/db/contact.py @@ -26,6 +26,7 @@ from db.field import FieldEvent from db.field import FieldEventContactAssociation from db.permission import Permission + from db.publication import AuthorContactAssociation, Author class ThingContactAssociation(Base, AutoBaseMixin): @@ -68,34 +69,20 @@ class Contact(Base, AutoBaseMixin, ReleaseMixin): permissions: Mapped[List["Permission"]] = relationship( "Permission", back_populates="contact", cascade="all, delete, delete-orphan" ) - - search_vector: Mapped[TSVectorType] = mapped_column( - TSVectorType("name", "role", "organization", "nma_pk_owners") - ) - - author_associations: Mapped[List["AuthorContactAssociation"]] = ( # noqa: F821 - relationship( - "AuthorContactAssociation", - back_populates="contact", - cascade="all, delete-orphan", - ) - ) - authors: AssociationProxy[list["Author"]] = association_proxy( # noqa: F821 - "author_associations", "author" + # One-To-Many: A Contact can be associated with many Authors (in Publications). + author_associations: Mapped[List["AuthorContactAssociation"]] = relationship( + "AuthorContactAssociation", + back_populates="contact", + cascade="all, delete-orphan", ) + # One-To-Many: A Contact can be associated with many Things. thing_associations: Mapped[List["ThingContactAssociation"]] = relationship( "ThingContactAssociation", back_populates="contact", cascade="all, delete-orphan", - passive_deletes=True, - ) - things: AssociationProxy[list["Thing"]] = association_proxy( # noqa: F821 - "thing_associations", "thing" ) - - # Proxy to directly access the FieldEvent objects in which this Contact participated. - # fmt: off - field_event_contact_associations: Mapped[list["FieldEventContactAssociation"]] = ( # noqa: F821 + # One-To-Many: A Contact can participate in many Field Events. + field_event_contact_associations: Mapped[list["FieldEventContactAssociation"]] = ( relationship( "FieldEventContactAssociation", back_populates="contact", @@ -103,9 +90,26 @@ class Contact(Base, AutoBaseMixin, ReleaseMixin): passive_deletes=True, ) ) - # fmt: on - field_events: AssociationProxy[list["FieldEvent"]] = ( # noqa: F821 - association_proxy("field_event_contact_associations", "field_event") + + # --- Association Proxies --- + # Proxy to directly access the Author objects associated with this Contact + authors: AssociationProxy[list["Author"]] = association_proxy( + "author_associations", "author" + ) + + # Proxy to directly access the Thing objects associated with this Contact + things: AssociationProxy[list["Thing"]] = association_proxy( + "thing_associations", "thing" + ) + + # Proxy to directly access the FieldEvent objects in which this Contact participated. + field_events: AssociationProxy[list["FieldEvent"]] = association_proxy( + "field_event_contact_associations", "field_event" + ) + + # Full-Text Search Vector + search_vector: Mapped[TSVectorType] = mapped_column( + TSVectorType("name", "role", "organization", "nma_pk_owners") ) From dd0c9273ae009d3c747c0dcde1e8abc48c226def Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Wed, 1 Oct 2025 09:57:22 -0600 Subject: [PATCH 28/39] fix(models): Explicitly mark foreign keys in polymorphic joins SQLAlchemy was raising an `ArgumentError` because it could not infer the direction of the one-to-many polymorphic relationships defined in the `StatusHistoryMixin` and `PermissionMixin`. Without a formal `ForeignKey` constraint on the child columns (`statusable_id` and `permissible_id`), SQLAlchemy could not determine which side of the join was the foreign key. This commit resolves the ambiguity by wrapping these child columns with the `foreign()` annotation in their respective `primaryjoin` conditions. This explicitly marks them as the foreign-key side of the join, allowing the mapper to configure correctly. --- db/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/base.py b/db/base.py index 97a6b7cfc..ba2a45be8 100644 --- a/db/base.py +++ b/db/base.py @@ -184,7 +184,7 @@ def status_history(self): # One-to-Many polymorphic relationship return relationship( "StatusHistory", - primaryjoin=f"and_({self.__name__}.id==StatusHistory.statusable_id, " + primaryjoin=f"and_({self.__name__}.id==foreign(StatusHistory.statusable_id), " f"StatusHistory.statusable_type=='{self.__name__}')", cascade="all, delete-orphan", lazy="selectin", @@ -203,7 +203,7 @@ def permissions(self): # One-to-Many polymorphic relationship return relationship( "Permission", - primaryjoin=f"and_({self.__name__}.id==Permission.permissible_id, " + primaryjoin=f"and_({self.__name__}.id==foreign(Permission.permissible_id), " f"Permission.permissible_type=='{self.__name__}')", lazy="selectin", viewonly=True, From 57c5500fbddc01205fdd82a6e3fc798835a3fb2a Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Wed, 1 Oct 2025 11:54:07 -0600 Subject: [PATCH 29/39] feat(models): Update column name in the `search_vector`. The `Thing` model's search_vector referenced the `well_casing_description` column, but it was not defined in the schema, causing an `AttributeError` on startup. The `well_casing_description` column was renamed `well_casing_material` so the search_vector was updated accordingly. --- db/thing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/thing.py b/db/thing.py index e13ab4274..a2ce90738 100644 --- a/db/thing.py +++ b/db/thing.py @@ -193,7 +193,7 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix # Full-text search vector search_vector = Column( TSVectorType( - "name", "well_construction_notes", "well_purpose", "well_casing_description" + "name", "well_construction_notes", "well_purpose", "well_casing_material" ) ) From f5ea3b4293003689421cbc115baa85d4fe314662 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Wed, 1 Oct 2025 14:11:58 -0600 Subject: [PATCH 30/39] fix(model): resolve errors to allow tests to run without issue. `first_visit_date` was missing from the `schemas/thing.py` UPDATE model. It has been added. Renamed instances of 'well_type' to 'well_purpose' Commented out the `Thing.description` field --- db/thing.py | 4 ++-- schemas/thing.py | 1 + tests/conftest.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/db/thing.py b/db/thing.py index a2ce90738..c7e24ec20 100644 --- a/db/thing.py +++ b/db/thing.py @@ -53,8 +53,8 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix nullable=False, comment="The name of the thing (e.g., well name or identifier).", ) - # TODO: what is the purpose of the `description` field? - description: Mapped[str] = mapped_column(String(500)) + # TODO: what is the purpose of the `description` field? Is it ever used? + # description: Mapped[str] = mapped_column(String(500), nullable=True) thing_type: Mapped[str] = lexicon_term( nullable=True, comment="A controlled vocabulary field defining the type of infrastructure (e.g., 'Well', 'Spring', 'Stream Gauge').", diff --git a/schemas/thing.py b/schemas/thing.py index 8583274b9..7301157f0 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -196,6 +196,7 @@ class UpdateThing(BaseUpdateModel): """ name: str | None = None # Optional name for the thing + first_visit_date: date | None = None # Date of NMBGMR's first visit class UpdateWell(UpdateThing): diff --git a/tests/conftest.py b/tests/conftest.py index ce861a255..6f3c3242b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,7 +54,7 @@ def water_well_thing(location): first_visit_date="2023-03-03", thing_type="water well", release_status="draft", - well_type="Production", + well_purpose="Production", well_depth=10, hole_depth=10, well_construction_notes="Test well construction notes", From cfe8829291b8e13ff1419c1c70a1b2f64acd7cae Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Wed, 1 Oct 2025 14:18:54 -0600 Subject: [PATCH 31/39] refactor: update instance of `well_type` to `well_purpose`. The `well_type` property was renamed to `well_purposed` in the `make_well_response` function. --- api/search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/search.py b/api/search.py index 2a4158841..236f8848f 100644 --- a/api/search.py +++ b/api/search.py @@ -103,7 +103,7 @@ def make_well_response(thing: Thing) -> dict: "Wells", thing, { - "well_purpose": thing.well_type, + "well_purpose": thing.well_purpose, "well_depth": thing.well_depth, "hole_depth": thing.hole_depth, }, From d370f3b31cd9dea951a534345f69f6e48e301536 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Wed, 1 Oct 2025 14:59:25 -0600 Subject: [PATCH 32/39] refactor: remove `tkinter` import Instances of ondelete, CASCADE should be a str e.g. ondelete="CASCADE". The CASCADE from tkinter and tkinter itself was removed. --- db/deployment.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/db/deployment.py b/db/deployment.py index 69d45e8a7..72586c980 100644 --- a/db/deployment.py +++ b/db/deployment.py @@ -4,7 +4,6 @@ at which Thing and for what period of time. """ -from tkinter.constants import CASCADE from typing import TYPE_CHECKING from sqlalchemy import Integer, ForeignKey, Date, Numeric, Text @@ -26,7 +25,7 @@ class Deployment(Base, AutoBaseMixin, ReleaseMixin): # --- Foreign Keys --- thing_id: Mapped[int] = mapped_column( Integer, - ForeignKey("thing.id", ondelete=CASCADE), + ForeignKey("thing.id", ondelete="CASCADE"), nullable=False, ) sensor_id: Mapped[int] = mapped_column( From a2e781a48a708dbfb3360ef97b3f3465edf3c335 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Thu, 2 Oct 2025 14:55:55 -0600 Subject: [PATCH 33/39] refactor (schema): update data type `first_visit_date` field should be of type `PastDate` to ensure a future date is not entered. --- schemas/thing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/schemas/thing.py b/schemas/thing.py index 7301157f0..8e7cb5da5 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -15,7 +15,7 @@ # =============================================================================== from typing import List -from pydantic import BaseModel, model_validator +from pydantic import BaseModel, model_validator, PastDate from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel from schemas.location import LocationResponse @@ -48,7 +48,7 @@ class CreateBaseThing(BaseCreateModel): location_id: int | None = None # Optional location ID for the thing group_id: int | None = None # Optional group ID for the thing name: str # Name of the thing - first_visit_date: date | None = None # Date of NMBGMR's first visit + first_visit_date: PastDate | None = None # Date of NMBGMR's first visit class CreateWell(CreateBaseThing): From c560d1380ad2c77ed2e0bfd78608b7e3fa152aa8 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Thu, 2 Oct 2025 15:06:26 -0600 Subject: [PATCH 34/39] refactor (schema): update `first_visit_date` field to be required (non-nullable) When creating a new `Thing` record, users should be required to enter a value for `first_visit_date`. This applies to new records being created - the database itself will still allow NULLS in order to accommodate legacy data. --- schemas/thing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schemas/thing.py b/schemas/thing.py index 8e7cb5da5..131641c56 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -48,7 +48,7 @@ class CreateBaseThing(BaseCreateModel): location_id: int | None = None # Optional location ID for the thing group_id: int | None = None # Optional group ID for the thing name: str # Name of the thing - first_visit_date: PastDate | None = None # Date of NMBGMR's first visit + first_visit_date: PastDate # Date of NMBGMR's first visit class CreateWell(CreateBaseThing): From b8fff0ec86b11da42d877e55b8ed4e25c72ec53f Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Thu, 2 Oct 2025 15:53:51 -0600 Subject: [PATCH 35/39] refactor (model): update relationship name The `thing_groups` relationship was updated to `group_associations` for clarity. It more descriptive of the relationship and is also consistent with the naming convention used in the group table (`group_associations)`. The back_populates property reference in the group.thing relationship was updated from "thing_groups" to "group_associations" to reflect the update to the relationship name in the Thing table. --- db/group.py | 2 +- db/thing.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/db/group.py b/db/group.py index f28dfc208..a0943d2bb 100644 --- a/db/group.py +++ b/db/group.py @@ -66,7 +66,7 @@ class GroupThingAssociation(Base, AutoBaseMixin): # --- Relationship Definitions --- # Many-To-One: This association links to one Thing. - thing: Mapped["Thing"] = relationship("Thing", back_populates="thing_groups") + thing: Mapped["Thing"] = relationship("Thing", back_populates="group_associations") # Many-To-One: This association links to one Group. group: Mapped["Group"] = relationship("Group", back_populates="thing_associations") diff --git a/db/thing.py b/db/thing.py index c7e24ec20..261e87f00 100644 --- a/db/thing.py +++ b/db/thing.py @@ -152,7 +152,7 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix ) # One To-Many: A Thing can be in many Groups over time. - thing_groups: Mapped[List["GroupThingAssociation"]] = relationship( + group_associations: Mapped[List["GroupThingAssociation"]] = relationship( "GroupThingAssociation", back_populates="thing", cascade="all, delete-orphan", @@ -188,7 +188,9 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix ) # Proxy to directly access the Group(s) this Thing is a member of. - groups: AssociationProxy[List["Group"]] = association_proxy("thing_groups", "group") + groups: AssociationProxy[List["Group"]] = association_proxy( + "group_associations", "group" + ) # Full-text search vector search_vector = Column( From 86996951ee3228d37bce8f5befb8ebaff0f98d7b Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Thu, 2 Oct 2025 15:55:49 -0600 Subject: [PATCH 36/39] refactor (model): remove # noqa: F821 calls # noqa: F821 calls were removed since TYPE_CHECKING is invoked. --- db/thing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/thing.py b/db/thing.py index 261e87f00..2ec61dff6 100644 --- a/db/thing.py +++ b/db/thing.py @@ -173,12 +173,12 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix ) # Proxy to directly access the Location associated with this Thing - locations: AssociationProxy[list["Location"]] = association_proxy( # noqa: F821 + locations: AssociationProxy[list["Location"]] = association_proxy( "location_associations", "location" ) # Proxy to directly access the Contact objects associated with this Thing - contacts: AssociationProxy[list["Contact"]] = association_proxy( # noqa: F821 + contacts: AssociationProxy[list["Contact"]] = association_proxy( "contact_associations", "contact" ) From a727f7486216cfcb2d25c3973f420ce7a142ecd0 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Thu, 2 Oct 2025 17:40:20 -0600 Subject: [PATCH 37/39] refactor: update instances of `FieldEventContactAssociation` to `FieldEventParticipants`. Update instances of `field_event_contact_associations` to `field_event_participants` --- db/contact.py | 16 +++++++--------- db/field.py | 20 +++++++++----------- db/sample.py | 6 +++--- services/sample_helper.py | 4 ++-- tests/conftest.py | 2 +- 5 files changed, 22 insertions(+), 26 deletions(-) diff --git a/db/contact.py b/db/contact.py index df05beede..29df0785f 100644 --- a/db/contact.py +++ b/db/contact.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: from db.thing import Thing from db.field import FieldEvent - from db.field import FieldEventContactAssociation + from db.field import FieldEventParticipants from db.permission import Permission from db.publication import AuthorContactAssociation, Author @@ -82,13 +82,11 @@ class Contact(Base, AutoBaseMixin, ReleaseMixin): cascade="all, delete-orphan", ) # One-To-Many: A Contact can participate in many Field Events. - field_event_contact_associations: Mapped[list["FieldEventContactAssociation"]] = ( - relationship( - "FieldEventContactAssociation", - back_populates="contact", - cascade="all, delete-orphan", - passive_deletes=True, - ) + field_event_participants: Mapped[list["FieldEventParticipants"]] = relationship( + "FieldEventParticipants", + back_populates="contact", + cascade="all, delete-orphan", + passive_deletes=True, ) # --- Association Proxies --- @@ -104,7 +102,7 @@ class Contact(Base, AutoBaseMixin, ReleaseMixin): # Proxy to directly access the FieldEvent objects in which this Contact participated. field_events: AssociationProxy[list["FieldEvent"]] = association_proxy( - "field_event_contact_associations", "field_event" + "field_event_participants", "field_event" ) # Full-Text Search Vector diff --git a/db/field.py b/db/field.py index ca7b99372..2aaef2726 100644 --- a/db/field.py +++ b/db/field.py @@ -7,7 +7,7 @@ from db.contact import Contact -class FieldEventContactAssociation(Base, AutoBaseMixin, ReleaseMixin): +class FieldEventParticipants(Base, AutoBaseMixin, ReleaseMixin): """ This association table is to create a many-to-many relationship between FieldEvent and Contact. These are participants in the field event. @@ -32,10 +32,10 @@ class FieldEventContactAssociation(Base, AutoBaseMixin, ReleaseMixin): # --- Relationships --- field_event: Mapped["FieldEvent"] = relationship( - "FieldEvent", back_populates="field_event_contact_associations" + "FieldEvent", back_populates="field_event_participants" ) contact: Mapped["Contact"] = relationship( # noqa: F821 - "Contact", back_populates="field_event_contact_associations" + "Contact", back_populates="field_event_participants" ) # map associated contacts to samples to restrict the people who could have @@ -86,19 +86,17 @@ class FieldEvent(Base, AutoBaseMixin, ReleaseMixin): field_activities: Mapped[list["FieldActivity"]] = relationship( "FieldActivity", back_populates="field_event" ) - field_event_contact_associations: Mapped[list["FieldEventContactAssociation"]] = ( - relationship( - "FieldEventContactAssociation", - back_populates="field_event", - cascade="all, delete-orphan", - passive_deletes=True, - ) + field_event_participants: Mapped[list["FieldEventParticipants"]] = relationship( + "FieldEventParticipants", + back_populates="field_event", + cascade="all, delete-orphan", + passive_deletes=True, ) # --- Association Proxies --- # Proxy to directly access the Contact objects participating in this event. contacts: AssociationProxy[list["Contact"]] = association_proxy( # noqa: F821 - "field_event_contact_associations", "contact" + "field_event_participants", "contact" ) diff --git a/db/sample.py b/db/sample.py index c06a39523..c1cb5068c 100644 --- a/db/sample.py +++ b/db/sample.py @@ -24,7 +24,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from db.field import FieldEvent, FieldActivity, FieldEventContactAssociation + from db.field import FieldEvent, FieldActivity, FieldEventParticipants from db.thing import Thing from db.contact import Contact from db.observation import Observation @@ -51,7 +51,7 @@ class Sample(Base, AutoBaseMixin, ReleaseMixin): ) field_event_contact_id: Mapped[str] = mapped_column( - ForeignKey("field_event_contact_association.id"), nullable=True + ForeignKey("field_event_participant.id"), nullable=True ) # --- Columns --- @@ -86,7 +86,7 @@ class Sample(Base, AutoBaseMixin, ReleaseMixin): # --- Relationship Definitions --- field_activity: Mapped["FieldActivity"] = relationship(back_populates="samples") - field_event_contact: Mapped["FieldEventContactAssociation"] = relationship( + field_event_contact: Mapped["FieldEventParticipants"] = relationship( back_populates="samples" ) diff --git a/services/sample_helper.py b/services/sample_helper.py index 421cb3336..f98bf6e07 100644 --- a/services/sample_helper.py +++ b/services/sample_helper.py @@ -1,7 +1,7 @@ from sqlalchemy.orm import Session, joinedload from fastapi_pagination.ext.sqlalchemy import paginate -from db import FieldEvent, FieldActivity, FieldEventContactAssociation, Sample +from db import FieldEvent, FieldActivity, FieldEventParticipants, Sample from services.query_helper import order_sort_filter @@ -18,7 +18,7 @@ def get_db_samples( .joinedload(FieldActivity.field_event) .joinedload(FieldEvent.thing), joinedload(Sample.field_event_contact).joinedload( - FieldEventContactAssociation.contact + FieldEventParticipants.contact ), # Eagerly load related Contact ) diff --git a/tests/conftest.py b/tests/conftest.py index 6f3c3242b..db65601b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -498,7 +498,7 @@ def field_event(water_well_thing): @pytest.fixture(scope="session") def field_event_contact(field_event, contact): with session_ctx() as session: - field_event_contact = FieldEventContactAssociation( + field_event_contact = FieldEventParticipants( field_event_id=field_event.id, contact_id=contact.id, field_contact_role="Lead", From d08c565e44c150fbc7304959a4daaf6f5d05c7c8 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Thu, 2 Oct 2025 17:50:23 -0600 Subject: [PATCH 38/39] fix: resolve foreign key reference The foreign key on `field_event_contact_id` field was incorrectly named "field_event_participant.id". It was updated to "field_event_participants.id" --- db/sample.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/sample.py b/db/sample.py index c1cb5068c..337a2a3de 100644 --- a/db/sample.py +++ b/db/sample.py @@ -51,7 +51,7 @@ class Sample(Base, AutoBaseMixin, ReleaseMixin): ) field_event_contact_id: Mapped[str] = mapped_column( - ForeignKey("field_event_participant.id"), nullable=True + ForeignKey("field_event_participants.id"), nullable=True ) # --- Columns --- From 440d639d369834530369b22041e142b8c13f7ab0 Mon Sep 17 00:00:00 2001 From: ksmuczynski Date: Fri, 3 Oct 2025 08:36:36 -0600 Subject: [PATCH 39/39] refactor: update data type Change data type for `first_visit_date` from `date` to `PastDate` --- schemas/thing.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/schemas/thing.py b/schemas/thing.py index 131641c56..fc28b3558 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -20,8 +20,6 @@ from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel from schemas.location import LocationResponse -from datetime import date - # -------- CREATE ---------- class CreateThingIdLink(BaseModel): @@ -96,7 +94,7 @@ class BaseThingResponse(BaseResponseModel): name: str thing_type: str active_location: LocationResponse | None = None - first_visit_date: date | None = None + first_visit_date: PastDate | None = None class WellResponse(BaseThingResponse): @@ -196,7 +194,7 @@ class UpdateThing(BaseUpdateModel): """ name: str | None = None # Optional name for the thing - first_visit_date: date | None = None # Date of NMBGMR's first visit + first_visit_date: PastDate | None = None # Date of NMBGMR's first visit class UpdateWell(UpdateThing):