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..236f8848f 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_purpose, "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/__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 ( diff --git a/db/base.py b/db/base.py index 31ec9ecd4..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__}.{self.__name__.lower()}_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__}.{self.__name__.lower()}_id==Permission.permissible_id, " + primaryjoin=f"and_({self.__name__}.id==foreign(Permission.permissible_id), " f"Permission.permissible_type=='{self.__name__}')", lazy="selectin", viewonly=True, diff --git a/db/contact.py b/db/contact.py index 7a4f79340..29df0785f 100644 --- a/db/contact.py +++ b/db/contact.py @@ -17,10 +17,17 @@ 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 FieldEventParticipants + from db.permission import Permission + from db.publication import AuthorContactAssociation, Author + class ThingContactAssociation(Base, AutoBaseMixin): thing_id: Mapped[int] = mapped_column( @@ -30,8 +37,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): @@ -41,53 +52,62 @@ 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 - ) - - search_vector: Mapped[TSVectorType] = mapped_column( - TSVectorType("name", "role", "organization", "nma_pk_owners") + "Address", back_populates="contact", cascade="all, delete, delete-orphan" ) - - author_associations: Mapped[List["AuthorContactAssociation"]] = ( # noqa: F821 - relationship( - "AuthorContactAssociation", - back_populates="contact", - cascade="all, delete-orphan", - ) + # One-To-Many: A Contact can grant many Permissions. + permissions: Mapped[List["Permission"]] = relationship( + "Permission", back_populates="contact", cascade="all, delete, 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", + ) + # One-To-Many: A Contact can participate in many Field Events. + field_event_participants: Mapped[list["FieldEventParticipants"]] = relationship( + "FieldEventParticipants", + back_populates="contact", + cascade="all, delete-orphan", passive_deletes=True, ) - things: AssociationProxy[list["Thing"]] = association_proxy( # noqa: F821 + + # --- 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. - # fmt: off - field_event_contact_associations: Mapped[list["FieldEventContactAssociation"]] = ( # noqa: F821 - relationship( - "FieldEventContactAssociation", - back_populates="contact", - cascade="all, delete-orphan", - passive_deletes=True, - ) + field_events: AssociationProxy[list["FieldEvent"]] = association_proxy( + "field_event_participants", "field_event" ) - # fmt: on - field_events: AssociationProxy[list["FieldEvent"]] = ( # noqa: F821 - 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") ) diff --git a/db/deployment.py b/db/deployment.py index 78eaa4ef0..72586c980 100644 --- a/db/deployment.py +++ b/db/deployment.py @@ -24,7 +24,9 @@ class Deployment(Base, AutoBaseMixin, ReleaseMixin): # --- Foreign Keys --- thing_id: Mapped[int] = mapped_column( - Integer, ForeignKey("thing.id"), nullable=False + Integer, + ForeignKey("thing.id", ondelete="CASCADE"), + nullable=False, ) sensor_id: Mapped[int] = mapped_column( Integer, ForeignKey("sensor.id"), nullable=False 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/group.py b/db/group.py index 66f7a717b..a0943d2bb 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="group_associations") + + # Many-To-One: This association links to one Group. + group: Mapped["Group"] = relationship("Group", back_populates="thing_associations") + # ============= EOF ============================================= diff --git a/db/permission.py b/db/permission.py index 93e688e86..340e587f7 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 @@ -37,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 --- @@ -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.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.location_id, - permissible_type == "Location", - ), + primaryjoin="and_(foreign(Permission.permissible_id) == Location.id, " + "Permission.permissible_type == 'Location')", viewonly=True, ) diff --git a/db/sample.py b/db/sample.py index c06a39523..337a2a3de 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_participants.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/db/thing.py b/db/thing.py index 7da938744..2ec61dff6 100644 --- a/db/thing.py +++ b/db/thing.py @@ -13,14 +13,20 @@ # 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, Date from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy from sqlalchemy.orm import relationship, mapped_column, Mapped from sqlalchemy_utils import TSVectorType 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 @@ -30,35 +36,97 @@ 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): +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. + """ + + __versioned__ = {} + + # --- 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? 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').", + ) + 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, + 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').", + ) - name = mapped_column(String(255), nullable=False) - description = mapped_column(String(500)) - thing_type = lexicon_term(nullable=True) - spring_type = lexicon_term(nullable=True) + 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 --- + # 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", overlaps="things", cascade="all, delete-orphan", - ) - assets: AssociationProxy[list["Asset"]] = association_proxy( - "asset_associations", "asset" + passive_deletes=True, ) + # 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", overlaps="location", cascade="all, delete-orphan", + passive_deletes=True, order_by="LocationThingAssociation.effective_start.desc()", ) - locations: AssociationProxy[list["Location"]] = association_proxy( # noqa: F821 - "location_associations", "location" - ) contact_associations = relationship( "ThingContactAssociation", @@ -66,68 +134,93 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin): overlaps="contacts", cascade="all, delete-orphan", ) - contacts: AssociationProxy[list["Contact"]] = association_proxy( # noqa: F821 - "contact_associations", "contact" - ) - # Well fields - well_depth = Column( - Float, - nullable=True, - info={"unit": "feet below ground surface"}, + # 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", + passive_deletes=True, ) - hole_depth = Column( - Float, nullable=True, info={"unit": "feet below ground surface"} + + # 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", + passive_deletes=True, ) - 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)) + # One To-Many: A Thing can be in many Groups over time. + group_associations: Mapped[List["GroupThingAssociation"]] = relationship( + "GroupThingAssociation", + back_populates="thing", + cascade="all, delete-orphan", + passive_deletes=True, + ) - # Spring fields + # One-To-Many: A Thing (well) can have multiple screened intervals. + screens: Mapped[List["WellScreen"]] = relationship( + "WellScreen", + back_populates="thing", + cascade="all, delete-orphan", + passive_deletes=True, + ) - search_vector = Column( - TSVectorType( - "name", "well_construction_notes", "well_type", "well_casing_description" - ) + # --- Association Proxies --- + assets: AssociationProxy[list["Asset"]] = association_proxy( + "asset_associations", "asset" ) - # --- 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 + # Proxy to directly access the Location associated with this Thing + locations: AssociationProxy[list["Location"]] = association_proxy( + "location_associations", "location" ) - # 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" + # Proxy to directly access the Contact objects associated with this Thing + contacts: AssociationProxy[list["Contact"]] = association_proxy( + "contact_associations", "contact" ) - # --- Association Proxies --- - # Proxy to directly access the Sensor deployed at this Thing. - sensors: AssociationProxy[List["Sensor"]] = association_proxy( + # Proxy to directly access the Sensor (Equipment) deployed at this Thing. + sensor: AssociationProxy[List["Sensor"]] = association_proxy( "deployments", "sensor" ) + # Proxy to directly access the Group(s) this Thing is a member of. + groups: AssociationProxy[List["Group"]] = association_proxy( + "group_associations", "group" + ) + + # Full-text search vector + search_vector = Column( + TSVectorType( + "name", "well_construction_notes", "well_purpose", "well_casing_material" + ) + ) + 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): + """ + 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 ) @@ -144,8 +237,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 diff --git a/schemas/thing.py b/schemas/thing.py index 2ac3d72ba..fc28b3558 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 @@ -46,6 +46,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 # Date of NMBGMR's first visit class CreateWell(CreateBaseThing): @@ -53,7 +54,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 +94,7 @@ class BaseThingResponse(BaseResponseModel): name: str thing_type: str active_location: LocationResponse | None = None + first_visit_date: PastDate | None = None class WellResponse(BaseThingResponse): @@ -104,7 +106,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 @@ -192,11 +194,12 @@ class UpdateThing(BaseUpdateModel): """ name: str | None = None # Optional name for the thing + first_visit_date: PastDate | None = None # Date of NMBGMR's first visit 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/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 fe0fe8f87..db65601b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,9 +51,10 @@ 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", + well_purpose="Production", well_depth=10, hole_depth=10, well_construction_notes="Test well construction notes", @@ -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", @@ -495,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", 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..b5470458d 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -60,7 +60,8 @@ def test_add_water_well(location, group): "group_id": group.id, "release_status": "draft", "name": "Test Well", - "well_type": "Monitoring", + "first_visit_date": "2023-01-01", + "well_purpose": "Monitoring", "well_depth": 100.0, "hole_depth": 110, "well_construction_notes": "this is a test of notes", @@ -73,8 +74,9 @@ 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_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 +97,8 @@ 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", + "first_visit_date": "2023-01-01", + "well_purpose": "Monitoring", "well_depth": 100.0, "hole_depth": 110, "well_construction_notes": "this is a test of notes", @@ -117,7 +120,8 @@ 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", + "first_visit_date": "2023-01-01", + "well_purpose": "Monitoring", "well_depth": 100.0, "hole_depth": 110, "well_construction_notes": "this is a test of notes", @@ -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,9 +337,13 @@ 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_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 ( @@ -354,9 +366,10 @@ 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_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 @@ -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,9 +628,13 @@ 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_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 ( @@ -627,10 +649,14 @@ 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 - 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 @@ -648,9 +674,10 @@ 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_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 @@ -726,8 +753,9 @@ 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_type": "Injection", + "well_purpose": "Injection", "well_depth": 20, "hole_depth": 40, "well_construction_notes": "patched well construction notes", @@ -736,8 +764,9 @@ 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_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"] @@ -755,8 +784,9 @@ 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_type": "Injection", + "well_purpose": "Injection", "well_depth": 20, "hole_depth": 40, "well_construction_notes": "patched well construction notes", @@ -770,8 +800,9 @@ 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_type": "Injection", + "well_purpose": "Injection", "well_depth": 20, "hole_depth": 40, "well_construction_notes": "patched well construction notes", @@ -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", } 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()