diff --git a/core/enums.py b/core/enums.py index 11fc2708e..eb5e399c7 100644 --- a/core/enums.py +++ b/core/enums.py @@ -70,11 +70,12 @@ Vertical_datum: type[Enum] = build_enum_from_lexicon_category("vertical_datum") ScreenType: type[Enum] = build_enum_from_lexicon_category("screen_type") SensorType: type[Enum] = build_enum_from_lexicon_category("sensor_type") +WellPumpType: type[Enum] = build_enum_from_lexicon_category("well_pump_type") +PermissionType: type[Enum] = build_enum_from_lexicon_category("permission_type") GroupType: type[Enum] = build_enum_from_lexicon_category("group_type") MonitoringFrequency: type[Enum] = build_enum_from_lexicon_category( "monitoring_frequency" ) -WellPumpType: type[Enum] = build_enum_from_lexicon_category("well_pump_type") AquiferType: type[Enum] = build_enum_from_lexicon_category("aquifer_type") GeographicScale: type[Enum] = build_enum_from_lexicon_category("geographic_scale") Lithology: type[Enum] = build_enum_from_lexicon_category("lithology") diff --git a/core/lexicon.json b/core/lexicon.json index aa2c299a4..44a89d2aa 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -54,8 +54,8 @@ {"name": "status_type", "description": null}, {"name": "status_value", "description": null}, {"name": "origin_source", "description": null}, - {"name": "status_value", "description": null}, {"name": "well_pump_type", "description": null}, + {"name": "permission_type", "description": null}, {"name": "formation_code", "description": null}, {"name": "lithology", "description": null} ], @@ -1104,6 +1104,9 @@ {"categories": ["note_type"], "term": "Historical", "definition": "Historical information or context about the well or location."}, {"categories": ["note_type"], "term": "Other", "definition": "Other types of notes that do not fit into the predefined categories."}, {"categories": ["note_type"], "term": "Water", "definition": "Water bearing zone information and other info from ose reports"}, - {"categories": ["note_type"], "term": "Measuring", "definition": "Notes about measuring/visiting the well, on Access form"} + {"categories": ["note_type"], "term": "Measuring", "definition": "Notes about measuring/visiting the well, on Access form"}, + {"categories": ["permission_type"], "term": "Water Level Sample", "definition": "Permissions for taking water level samples"}, + {"categories": ["permission_type"], "term": "Water Chemistry Sample", "definition": "Permissions for water taking chemistry samples"}, + {"categories": ["permission_type"], "term": "Datalogger Installation", "definition": "Permissions for installing dataloggers"} ] } \ No newline at end of file diff --git a/db/__init__.py b/db/__init__.py index 35cf90991..4a0fc8e70 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -33,7 +33,7 @@ from db.notes import * from db.observation import * from db.parameter import * -from db.permission import * +from db.permission_history import * from db.publication import * from db.regulatory_limit import * from db.sample import * diff --git a/db/aquifer_system.py b/db/aquifer_system.py index 53086f983..6a1681561 100644 --- a/db/aquifer_system.py +++ b/db/aquifer_system.py @@ -81,4 +81,4 @@ class AquiferSystem(Base, AutoBaseMixin, ReleaseMixin): ) # --- Table Arguments --- - __table_args__ = Index("ix_aquifersystem_name", "name") + __table_args__ = (Index("ix_aquifersystem_name", "name"),) diff --git a/db/base.py b/db/base.py index e9b0d7f2b..765a341bc 100644 --- a/db/base.py +++ b/db/base.py @@ -53,7 +53,6 @@ declared_attr, Mapped, mapped_column, - relationship, ) from sqlalchemy_continuum import make_versioned from sqlalchemy_searchable import make_searchable @@ -179,25 +178,6 @@ def properties(self): # ============= Polymorphic Helper Mixins ============================================= -class PermissionMixin: - """ - Mixin for models that can have permissions (e.g., Thing, Location). - It automatically creates a polymorphic One-to-Many relationship to the - Permission table. - """ - - @declared_attr - def permissions(self): - # One-to-Many polymorphic relationship - return relationship( - "Permission", - primaryjoin=f"and_({self.__name__}.id==foreign(Permission.permissible_id), " - f"Permission.permissible_type=='{self.__name__}')", - lazy="selectin", - viewonly=True, - ) - - class User(Base): """Represents a user in the system.""" diff --git a/db/contact.py b/db/contact.py index 7855814fb..558724df9 100644 --- a/db/contact.py +++ b/db/contact.py @@ -26,7 +26,7 @@ from db.field import FieldEventParticipant, FieldEvent from db.thing import Thing from db.publication import Author, AuthorContactAssociation - from db.permission import Permission + from db.permission_history import PermissionHistory class ThingContactAssociation(Base, AutoBaseMixin): @@ -74,8 +74,10 @@ class Contact(Base, AutoBaseMixin, ReleaseMixin): ) # One-To-Many: A Contact can grant many Permissions. - permissions: Mapped[List["Permission"]] = relationship( - "Permission", back_populates="contact", cascade="all, delete, delete-orphan" + permissions: Mapped[List["PermissionHistory"]] = relationship( + "PermissionHistory", + back_populates="contact", + cascade="all, delete, delete-orphan", ) # One-To-Many: A Contact can be associated with many Authors (in Publications). author_associations: Mapped[List["AuthorContactAssociation"]] = relationship( diff --git a/db/data_provenance.py b/db/data_provenance.py index 06c468c8d..20505d94c 100644 --- a/db/data_provenance.py +++ b/db/data_provenance.py @@ -19,7 +19,7 @@ from sqlalchemy import Integer, Index, and_ from sqlalchemy.orm import relationship, Mapped, mapped_column, declared_attr, foreign -from db.base import Base, AutoBaseMixin, ReleaseMixin, pascal_to_snake +from db.base import Base, AutoBaseMixin, ReleaseMixin from db import lexicon_term @@ -53,9 +53,13 @@ class DataProvenance(AutoBaseMixin, ReleaseMixin, Base): ) # Values from the following NMAquifer tables are included as `origin_source` terms in the lexicon: # 'LU_DataSource', 'LU_Depth_CompletionSource'. - origin_source: Mapped[str] = lexicon_term( + origin_type: Mapped[str] = lexicon_term( nullable=True, - comment="Indicates the origin source of the data (e.g'Driller's Log', 'Well Report'.", + comment="Indicates the type of origin the data (e.g'Driller's Log', 'Well Report'.", + ) + origin_source: Mapped[str] = mapped_column( + nullable=True, + comment="The specific source of the data (e.g., 'J. Brown Thesis, \"I like APIs\", Pomona College, 1994').", ) # Values from the following NMAquifer tables are included as `collection_method` terms in the lexicon: # 'LU_AltitudeMethod','LU_CoordinateMethod'. @@ -116,7 +120,7 @@ def data_provenance(cls): "DataProvenance", primaryjoin=and_( cls.id == foreign(DataProvenance.target_id), - DataProvenance.target_table == pascal_to_snake(cls.__name__), + DataProvenance.target_table == cls.__tablename__, ), lazy="selectin", viewonly=True, diff --git a/db/geologic_formation.py b/db/geologic_formation.py index 130ed8d45..2379f50f4 100644 --- a/db/geologic_formation.py +++ b/db/geologic_formation.py @@ -70,7 +70,7 @@ class GeologicFormation(Base, AutoBaseMixin, ReleaseMixin): ) ) # One-To-Many: A GeologicFormation can have many physical WellScreens installed in it. - screens: Mapped[List["WellScreen"]] = relationship( + well_screens: Mapped[List["WellScreen"]] = relationship( "WellScreen", back_populates="geologic_formation", passive_deletes=True ) @@ -79,7 +79,4 @@ class GeologicFormation(Base, AutoBaseMixin, ReleaseMixin): things: AssociationProxy["Thing"] = association_proxy("thing_associations", "thing") # --- Table Arguments --- - __table_args__ = ( - Index("ix_geologicformation_name", "name"), - Index("ix_geologicformation_code", "code"), - ) + __table_args__ = (Index("ix_geologicformation_formation_code", "formation_code"),) diff --git a/db/notes.py b/db/notes.py index ab8384064..0e2e8ab8b 100644 --- a/db/notes.py +++ b/db/notes.py @@ -97,7 +97,7 @@ def notes(cls): "Notes", primaryjoin=and_( cls.id == foreign(Notes.target_id), - Notes.target_table == cls.__name__, + Notes.target_table == cls.__tablename__, ), cascade="all, delete-orphan", lazy="selectin", @@ -120,7 +120,7 @@ def add_note( content=content, note_type=note_type, target_id=self.id, - target_table=self.__class__.__name__, + target_table=self.__class__.__tablename__, release_status=release_status, ) diff --git a/db/permission.py b/db/permission.py deleted file mode 100644 index 340e587f7..000000000 --- a/db/permission.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -models/permission.py - -This model defines the `Permission` table, a polymorphic table that tracks -all legal and administrative agreements related to site access and activity. -Its purpose is to track who granted permission, what activities they authorized, -which entity the permission applies to, and for what period of time. -""" - -from typing import TYPE_CHECKING - -from sqlalchemy import ( - Integer, - ForeignKey, - String, - Boolean, - Date, - Text, -) -from sqlalchemy.orm import relationship, Mapped, mapped_column - -from db.base import Base, AutoBaseMixin, ReleaseMixin - - -if TYPE_CHECKING: - from db.contact import Contact - from db.thing import Thing - from db.location import Location - - -class Permission(Base, AutoBaseMixin, ReleaseMixin): - """ - Represents a specific grant of permission from a Contact for a - specific entity (e.g., a Thing or Location). - """ - - # --- Foreign Keys --- - contact_id: Mapped[int] = mapped_column( - Integer, ForeignKey("contact.id"), nullable=False - ) - - # --- Columns --- - allow_sampling: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - allow_installation: Mapped[bool] = mapped_column( - Boolean, nullable=False, default=False - ) - start_date: Mapped[Date] = mapped_column(Date, nullable=True) - end_date: Mapped[Date] = mapped_column(Date, nullable=True) - notes: Mapped[str] = mapped_column(Text, nullable=True) - - # --- Polymorphic Columns --- - permissible_id: Mapped[int] = mapped_column(Integer, nullable=False) - permissible_type: Mapped[str] = mapped_column(String(50), nullable=False) - - # --- Relationships --- - # Many-To-One: A Permission is granted by one Contact. - contact: Mapped["Contact"] = relationship("Contact", back_populates="permissions") - - # --- Polymorphic Parent Relationships (Internal) --- - # These are view-only relationships used by the 'target' property below. - # They tell SQLAlchemy exactly how to find the specific parent record for a given child. - _thing_target: Mapped["Thing"] = relationship( - "Thing", - primaryjoin="and_(foreign(Permission.permissible_id) == Thing.id, " - "Permission.permissible_type == 'Thing')", - viewonly=True, - ) - _location_target: Mapped["Location"] = relationship( - "Location", - primaryjoin="and_(foreign(Permission.permissible_id) == Location.id, " - "Permission.permissible_type == 'Location')", - viewonly=True, - ) - - @property - def target(self): - """ - A generic property to get the parent object (Thing, Location, etc.). - This is useful for simplifying application code by providing a single, - consistent way to access the parent of a polymorphic record. - """ - return getattr(self, f"_{self.permissible_type.lower()}_target") diff --git a/db/permission_history.py b/db/permission_history.py new file mode 100644 index 000000000..591046bba --- /dev/null +++ b/db/permission_history.py @@ -0,0 +1,96 @@ +""" +models/permission.py + +This model defines the `Permission` table, a polymorphic table that tracks +all legal and administrative agreements related to site access and activity. +Its purpose is to track who granted permission, what activities they authorized, +which entity the permission applies to, and for what period of time. +""" + +from typing import TYPE_CHECKING +from datetime import date +from sqlalchemy import Integer, ForeignKey, String, and_ +from sqlalchemy.orm import relationship, Mapped, mapped_column, declared_attr, foreign + +from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term + + +if TYPE_CHECKING: + from db.contact import Contact + from db.thing import Thing + from db.location import Location + + +class PermissionHistory(Base, AutoBaseMixin, ReleaseMixin): + """ + Represents a specific grant of permission from a Contact for a + specific entity (e.g., a Thing or Location). + """ + + # --- Foreign Keys --- + contact_id: Mapped[int] = mapped_column( + Integer, ForeignKey("contact.id", ondelete="CASCADE"), nullable=False + ) + + # --- Columns --- + permission_type: Mapped[str] = lexicon_term(nullable=False) + permission_allowed: Mapped[bool] = mapped_column(nullable=False, default=False) + start_date: Mapped[date] = mapped_column(nullable=False) + end_date: Mapped[date] = mapped_column(nullable=True) + notes: Mapped[str] = mapped_column(nullable=True) + + # --- Polymorphic Columns --- + target_id: Mapped[int] = mapped_column(nullable=False) + target_table: Mapped[str] = mapped_column(String(50), nullable=False) + + # --- Relationships --- + # Many-To-One: A Permission is granted by one Contact. + contact: Mapped["Contact"] = relationship("Contact", back_populates="permissions") + + # --- Polymorphic Parent Relationships (Internal) --- + # These are view-only relationships used by the 'target' property below. + # They tell SQLAlchemy exactly how to find the specific parent record for a given child. + _thing_target: Mapped["Thing"] = relationship( + "Thing", + primaryjoin="and_(foreign(PermissionHistory.target_id) == Thing.id, " + "PermissionHistory.target_table == 'thing')", + viewonly=True, + ) + _location_target: Mapped["Location"] = relationship( + "Location", + primaryjoin="and_(foreign(PermissionHistory.target_id) == Location.id, " + "PermissionHistory.target_table == 'location')", + viewonly=True, + ) + + @property + def target(self): + """ + A generic property to get the parent object (Thing, Location, etc.). + This is useful for simplifying application code by providing a single, + consistent way to access the parent of a polymorphic record. + """ + return getattr(self, f"_{self.target_table}_target") + + +class PermissionHistoryMixin: + """ + Mixin for models that can have permissions (e.g., Thing, Location). + It automatically creates a polymorphic One-to-Many relationship to the + Permission table. + """ + + @declared_attr + def permission_history(cls): + # One-to-Many polymorphic relationship + return relationship( + "PermissionHistory", + primaryjoin=( + and_( + cls.id == foreign(PermissionHistory.target_id), + PermissionHistory.target_table == cls.__tablename__, + ) + ), + lazy="selectin", + viewonly=True, + ) diff --git a/db/status_history.py b/db/status_history.py index 8b3ee2321..15b5aec2f 100644 --- a/db/status_history.py +++ b/db/status_history.py @@ -19,7 +19,7 @@ ) from sqlalchemy.orm import Mapped, mapped_column, declared_attr, relationship, foreign -from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term, pascal_to_snake +from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term class StatusHistory(Base, AutoBaseMixin, ReleaseMixin): @@ -47,7 +47,7 @@ def status_history(cls): "StatusHistory", primaryjoin=and_( cls.id == foreign(StatusHistory.target_id), - StatusHistory.target_table == pascal_to_snake(cls.__name__), + StatusHistory.target_table == cls.__tablename__, ), cascade="all, delete-orphan", lazy="selectin", diff --git a/db/thing.py b/db/thing.py index d2592f7ac..cae9363e0 100644 --- a/db/thing.py +++ b/db/thing.py @@ -26,8 +26,9 @@ AutoBaseMixin, Base, ReleaseMixin, - PermissionMixin, ) +from db.permission_history import PermissionHistoryMixin +from services.util import retrieve_latest_polymorphic_history_table_record from db.status_history import StatusHistoryMixin from db.measuring_point_history import MeasuringPointHistory from db.data_provenance import DataProvenanceMixin @@ -53,7 +54,7 @@ class Thing( AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, - PermissionMixin, + PermissionHistoryMixin, DataProvenanceMixin, NotesMixin, ): @@ -70,10 +71,6 @@ class Thing( comment="To audit where the data came from in NM_Aquifer if it was transferred over", ) - # notes = mapped_column(Text, nullable=True) - # measuring_notes = mapped_column(Text, nullable=True) - # water_notes = mapped_column(Text, nullable=True) - # TODO: should `name` be unique? name: Mapped[str] = mapped_column( nullable=False, @@ -136,6 +133,11 @@ class Thing( info={"unit": "feet below ground surface"}, comment="Depth of the well pump from ground surface to the pump intake (in feet).", ) + # TODO: should this be required for every well in the database? AMMP review + is_suitable_for_datalogger: Mapped[bool] = mapped_column( + nullable=True, + comment="Indicates if the well is suitable for datalogger installation.", + ) # Spring-related columns spring_type: Mapped[str] = lexicon_term( @@ -330,13 +332,13 @@ class Thing( ) # Proxy to directly access AquiferSystems associated with this Thing - aquifers: AssociationProxy[List["AquiferSystem"]] = association_proxy( + aquifer_systems: AssociationProxy[List["AquiferSystem"]] = association_proxy( "aquifer_associations", "aquifer_system" ) # Proxy to directly access the GeologicFormations penetrated by this Thing. - formations: AssociationProxy[List["GeologicFormation"]] = association_proxy( - "formation_associations", "geologic_formation" + geologic_formations: AssociationProxy[List["GeologicFormation"]] = ( + association_proxy("formation_associations", "geologic_formation") ) # Full-text search vector @@ -430,7 +432,48 @@ def measuring_point_description(self) -> str | None: @property def well_depth_source(self) -> str | None: - return self._get_data_provenance_attribute("well_depth", "origin_source") + return self._get_data_provenance_attribute("well_depth", "origin_type") + + @property + def well_completion_date_source(self) -> str | None: + return self._get_data_provenance_attribute( + "well_completion_date", "origin_type" + ) + + @property + def well_construction_method_source(self) -> str | None: + return self._get_data_provenance_attribute( + "well_construction_method", "origin_source" + ) + + @property + def aquifers(self) -> List[dict]: + """ + Returns a list of aquifer systems and their associated types for this Thing. + Each aquifer system is represented as a dictionary with its name and a list of types. + """ + aquifer_list = [] + for association in self.aquifer_associations: + aquifer_info = { + "aquifer_system": association.aquifer_system.name, + "aquifer_types": [ + atype.aquifer_type for atype in association.aquifer_types + ], + } + aquifer_list.append(aquifer_info) + return aquifer_list + + @property + def permissions(self) -> list: + """ + Returns the associated permissions or an empty list. If there are no + associated permissions, an empty list is returned instead of None to + allow the API to serialize correctly (see schemas/thing.py). + """ + if self.permission_history: + return self.permission_history + else: + return [] class ThingIdLink(Base, AutoBaseMixin, ReleaseMixin): @@ -484,6 +527,10 @@ class WellScreen(Base, AutoBaseMixin, ReleaseMixin): "AquiferSystem", back_populates="well_screens", passive_deletes=True ) + geologic_formation: Mapped["GeologicFormation"] = relationship( + "GeologicFormation", back_populates="well_screens", passive_deletes=True + ) + class WellPurpose(Base, AutoBaseMixin, ReleaseMixin): """ diff --git a/run_bdd.sh b/run_bdd.sh index 29d0be47d..cd05769e4 100755 --- a/run_bdd.sh +++ b/run_bdd.sh @@ -66,7 +66,7 @@ export BASE_URL=${BASE_URL:-http://localhost:8000} # tests/features/thing-query-parameters.feature #uv run behave tests/features/well-inventory-csv.feature -uv run behave tests/features/well-additional-information.feature --capture -# uv run behave tests/features --tags="@backend and @production" --capture +# uv run behave tests/features/well-additional-information.feature --capture +uv run behave tests/features --tags="@backend and @production" --capture echo "✅ BDD test run complete." diff --git a/schemas/location.py b/schemas/location.py index e911e3359..218790496 100644 --- a/schemas/location.py +++ b/schemas/location.py @@ -97,7 +97,7 @@ class GeoJSONUTMCoordinates(BaseModel): ) -class GeoJSONProperties(BaseModel): +class GeoJSONProperties(BaseResponseModel): elevation: float elevation_unit: str = "ft" vertical_datum: str = "NAVD88" @@ -147,6 +147,9 @@ def populate_fields(cls, data: Any) -> Any: data_dict["geometry"]["coordinates"] = coordinates # populate properties + data_dict["properties"]["id"] = data_dict.get("id") + data_dict["properties"]["created_at"] = data_dict.get("created_at") + data_dict["properties"]["release_status"] = data_dict.get("release_status") data_dict["properties"]["notes"] = data_dict.get("notes") data_dict["properties"]["elevation"] = convert_m_to_ft(elevation_m) data_dict["properties"]["elevation_method"] = data_dict.get("elevation_method") diff --git a/schemas/permission_history.py b/schemas/permission_history.py new file mode 100644 index 000000000..e0619d90e --- /dev/null +++ b/schemas/permission_history.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel +from schemas import PastOrTodayDate + +from core.enums import PermissionType + + +# ------ RESPONSE ---------- +class PermissionHistoryResponse(BaseModel): + """ + Even though permission_allowed and start_date are not-nullable in the + database, they are nullable here to accommodate cases where no permission + record exists for a given permission type. + """ + + permission_type: PermissionType + permission_allowed: bool | None + start_date: PastOrTodayDate | None + end_date: PastOrTodayDate | None diff --git a/schemas/thing.py b/schemas/thing.py index 506620b3f..2b8967553 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -24,6 +24,8 @@ ScreenType, Organization, MonitoringFrequency, + Organization, + MonitoringFrequency, WellConstructionMethod, WellPumpType, ) @@ -31,11 +33,7 @@ from schemas.group import GroupResponse from schemas.location import LocationGeoJSONResponse from schemas.notes import NoteResponse, CreateNote -from schemas.aquifer_system import AquiferSystemResponse -from schemas.geologic_formation import ( - GeologicFormationResponse, - ThingGeologicFormationAssociationResponse, -) +from schemas.permission_history import PermissionHistoryResponse # -------- VALIDATE ---------- @@ -229,21 +227,24 @@ class WellResponse(BaseThingResponse): well_casing_materials: list[CasingMaterial] = [] well_construction_notes: str | None = None well_completion_date: PastOrTodayDate | None + well_completion_date_source: str | None well_driller_name: str | None well_construction_method: WellConstructionMethod | None + well_construction_method_source: str | None well_pump_type: WellPumpType | None well_pump_depth: float | None well_pump_depth_unit: str = "ft" - aquifers: list[AquiferSystemResponse] = [] - formations: list[ThingGeologicFormationAssociationResponse] = [] + is_suitable_for_datalogger: bool | None well_status: str | None measuring_point_height: float measuring_point_height_unit: str = "ft" measuring_point_description: str | None - + aquifers: list[dict] = [] + geologic_formations: list[str] = [] water_notes: list[NoteResponse] | None = None measuring_notes: list[NoteResponse] | None = None general_notes: list[NoteResponse] | None = None + permissions: list[PermissionHistoryResponse] @field_validator("well_purposes", mode="before") def populate_well_purposes_with_strings(cls, well_purposes): @@ -264,6 +265,51 @@ def populate_well_casing_materials_with_strings(cls, well_casing_materials): materials = [] return materials + @field_validator("geologic_formations", mode="before") + def populate_geologic_formations_with_strings(cls, geologic_formations): + if geologic_formations is not None: + formations = [formation.formation_code for formation in geologic_formations] + else: + formations = [] + return formations + + @field_validator("permissions", mode="before") + def populate_permission_history_with_latest_records(cls, permissions): + """ + Populate the permission history with the latest records for each + type of permission. If multiple records exist for the same permission type + only the most recent one is included. If there are no records + the permission_allowed will be None + """ + permissions_to_return = [] + for permission_type in [ + "Water Level Sample", + "Water Chemistry Sample", + "Datalogger Installation", + ]: + # Filter records for the current permission type + filtered_records = [ + record + for record in permissions + if record.permission_type == permission_type and record.end_date is None + ] + if filtered_records: + # Get the most recent record based on start_date + latest_record = max( + filtered_records, key=lambda record: record.start_date + ) + permissions_to_return.append(latest_record) + else: + permissions_to_return.append( + PermissionHistoryResponse( + permission_type=permission_type, + permission_allowed=None, + start_date=None, + end_date=None, + ) + ) + return permissions_to_return + class SpringResponse(BaseThingResponse): """ @@ -286,9 +332,10 @@ class WellScreenResponse(BaseResponseModel): thing_id: int thing: WellResponse aquifer_system_id: int | None = None - aquifer_system: AquiferSystemResponse | None = None + aquifer_system: str | None = None + aquifer_type: str | None = None geologic_formation_id: int | None = None - geologic_formation: GeologicFormationResponse | None = None + # geologic_formation: GeologicFormationResponse | None = None screen_depth_bottom: float screen_depth_bottom_unit: str = "ft" screen_depth_top: float @@ -296,6 +343,24 @@ class WellScreenResponse(BaseResponseModel): screen_type: str | None = None screen_description: str | None = None + @field_validator("aquifer_system", mode="before") + def populate_aquifer_system_with_name(cls, aquifer_system): + if aquifer_system is not None: + return aquifer_system.name + return None + + @field_validator("aquifer_type", mode="before") + def populate_aquifer_type_with_name(cls, aquifer_type): + if aquifer_type is not None: + return aquifer_type.name + return None + + @field_validator("geologic_formation_id", mode="before") + def populate_geologic_formation_with_code(cls, geologic_formation): + if geologic_formation is not None: + return geologic_formation.formation_code + return None + class GeoJSONGeometry(BaseModel): """ diff --git a/services/util.py b/services/util.py index 77cd5d5cd..6fbdd0269 100644 --- a/services/util.py +++ b/services/util.py @@ -4,12 +4,14 @@ import pyproj import httpx from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import DeclarativeBase from constants import SRID_WGS84 TRANSFORMERS = {} METERS_TO_FEET = 3.28084 +METERS_TO_FEET = 3.28084 def transform_srid(geometry, source_srid, target_srid): @@ -43,6 +45,20 @@ def convert_ft_to_m(feet: float | None) -> float | None: return round(feet / METERS_TO_FEET, 6) +def convert_m_to_ft(meters: float | None) -> float | None: + """Convert a length from meters to feet.""" + if meters is None: + return None + return round(meters * METERS_TO_FEET, 6) + + +def convert_ft_to_m(feet: float | None) -> float | None: + """Convert a length from feet to meters.""" + if feet is None: + return None + return round(feet / METERS_TO_FEET, 6) + + def get_tiger_data( lon: float, lat: float, layer: int, outfields: str = "*" ) -> dict | None: @@ -181,11 +197,10 @@ def retrieve_latest_polymorphic_history_table_record( DeclarativeBase | None The latest record from the specified polymorphic table with the defined type if it exists. """ - if polymorphic_relationship == "permissions": + if polymorphic_relationship == "permission_history": type_field = "permission_type" elif polymorphic_relationship == "status_history": type_field = "status_type" - polymorphic_records = getattr(target_record, polymorphic_relationship) type_polymorphic_records = [ r diff --git a/tests/features/environment.py b/tests/features/environment.py index 597490b4e..5fca63bf0 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -28,13 +28,20 @@ Parameter, Deployment, TransducerObservationBlock, + WellCasingMaterial, + PermissionHistory, + Contact, StatusHistory, ThingIdLink, WellPurpose, MeasuringPointHistory, MonitoringFrequencyHistory, DataProvenance, - WellCasingMaterial, + AquiferSystem, + AquiferType, + ThingAquiferAssociation, + GeologicFormation, + ThingGeologicFormationAssociation, ) from db.engine import session_ctx @@ -93,6 +100,7 @@ def add_well(context, session, location, name_num): well_construction_method="Driven", well_pump_type="Submersible", well_pump_depth=8, + is_suitable_for_datalogger=True, ) session.add(well) @@ -206,6 +214,54 @@ def add_spring(context, session, location, name_num): return spring +@add_context_object_container("contacts") +def add_contact(context, session): + contact = Contact( + name="Test Contact", + role="Software Developer", + organization="NMBGMR", + release_status="draft", + contact_type="Primary", + ) + session.add(contact) + session.commit() + session.refresh(contact) + + context.objects["contacts"].append(contact) + return contact + + +@add_context_object_container("permission_histories") +def add_permission_history( + context, + session, + contact_id, + permission_type, + permission_allowed, + start_date, + end_date, + notes, + target_id, + target_table, +): + permission_history = PermissionHistory( + contact_id=contact_id, + permission_type=permission_type, + permission_allowed=permission_allowed, + start_date=start_date, + end_date=end_date, + notes=notes, + target_id=target_id, + target_table=target_table, + ) + session.add(permission_history) + session.commit() + session.refresh(permission_history) + + context.objects["permission_histories"].append(permission_history) + return permission_history + + @add_context_object_container("sensors") def add_sensor(context, session): sensor = Sensor( @@ -334,7 +390,8 @@ def add_data_provenance( target_id, target_table, field_name, - origin_source, + origin_type=None, + origin_source=None, collection_method=None, accuracy_value=None, accuracy_unit=None, @@ -344,6 +401,7 @@ def add_data_provenance( collection_method=collection_method, target_id=target_id, target_table=target_table, + origin_type=origin_type, origin_source=origin_source, accuracy_value=accuracy_value, accuracy_unit=accuracy_unit, @@ -370,9 +428,74 @@ def add_transducer_observation(context, session, block, deployment_id, value): return obs +@add_context_object_container("aquifer_systems") +def add_aquifer_system(context, session, name, well): + aquifer_system = AquiferSystem( + name=name, + description="this is a test aquifer", + primary_aquifer_type="Artesian", + geographic_scale="Major", + boundary="MULTIPOLYGON(((0 0, 1 1, 2 2, 3 3, 1 2, 0 0)))", + ) + session.add(aquifer_system) + session.commit() + session.refresh(aquifer_system) + + context.objects["aquifer_systems"].append(aquifer_system) + return aquifer_system + + +@add_context_object_container("thing_aquifer_associations") +def add_thing_aquifer_association(context, session, well, aquifer_system): + association = ThingAquiferAssociation(thing=well, aquifer_system=aquifer_system) + session.add(association) + session.commit() + session.refresh(association) + + context.objects["thing_aquifer_associations"].append(association) + return association + + +@add_context_object_container("aquifer_types") +def add_aquifer_type(context, session, aquifer_type_str, thing_aquifer_association): + aquifer_type = AquiferType( + aquifer_type=aquifer_type_str, + thing_aquifer_association=thing_aquifer_association, + ) + session.add(aquifer_type) + session.commit() + session.refresh(aquifer_type) + + context.objects["aquifer_types"].append(aquifer_type) + return aquifer_type + + +@add_context_object_container("geologic_formations") +def add_geologic_formation(context, session, formation_code, well): + formation = GeologicFormation( + formation_code=formation_code, + description="This is a test geologic formation.", + lithology="Peat", + boundary="MULTIPOLYGON(((0 0, 1 1, 2 2, 3 3, 1 2, 0 0)))", + ) + session.add(formation) + session.commit() + session.refresh(formation) + + association = ThingGeologicFormationAssociation( + top_depth=1, bottom_depth=10, thing=well, geologic_formation=formation + ) + session.add(association) + session.commit() + session.refresh(association) + + context.objects["geologic_formations"].append(formation) + return formation + + def before_all(context): context.objects = {} - rebuild = False + rebuild = True # rebuild = True if rebuild: erase_and_rebuild_db() @@ -391,6 +514,28 @@ def before_all(context): sensor_1 = add_sensor(context, session) deployment = add_deployment(context, session, well_1.id, sensor_1.id) + add_well_casing_material(context, session, well_1) + + contact = add_contact(context, session) + + for permission in [ + "Datalogger Installation", + "Water Level Sample", + "Water Chemistry Sample", + ]: + add_permission_history( + context, + session, + contact_id=context.objects["contacts"][0].id, + permission_type=permission, + permission_allowed=True, + start_date=datetime(2025, 1, 1).date(), + end_date=None, + notes=f"Permission granted for {permission.lower()}.", + target_id=well_1.id, + target_table="thing", + ) + measuring_point_history_1 = add_measuring_point_history( context, session, well=well_1 ) @@ -514,12 +659,40 @@ def before_all(context): target_id=well_1.id, target_table="thing", field_name="well_depth", - origin_source="Other", + origin_type="Other", + ) + + well_completion_date_source = add_data_provenance( + context, + session, + target_id=well_1.id, + target_table="thing", + field_name="well_completion_date", + origin_type="Data Portal", + ) + + well_construction_method_source = add_data_provenance( + context, + session, + target_id=well_1.id, + target_table="thing", + field_name="well_construction_method", + origin_source="Jacob's 2013 Thesis", ) for purpose in ["Domestic", "Irrigation"]: add_well_purpose(context, session, well_1, purpose) + for name in ["Aquifer A", "Aquifer B"]: + system = add_aquifer_system(context, session, name, well_1) + add_thing_aquifer_association(context, session, well_1, system) + + for t in ["Artesian", "Fractured"]: + taa = context.objects["thing_aquifer_associations"][0] + add_aquifer_type(context, session, t, taa) + + add_geologic_formation(context, session, "000EXRV", well_1) + # parameter ID can be hardcoded because init_parameter always creates the same one parameter = session.get(Parameter, 1) block = add_block(context, session, parameter) @@ -538,8 +711,10 @@ def before_all(context): def after_all(context): with session_ctx() as session: for table in context.objects.values(): - for obj in table: - session.delete(obj) + for record in table: + obj = session.get(record.__class__, record.id) + if obj: + session.delete(obj) session.commit() diff --git a/tests/features/steps/well-additional-information.py b/tests/features/steps/well-additional-information.py index 1263b270f..5658f2ccc 100644 --- a/tests/features/steps/well-additional-information.py +++ b/tests/features/steps/well-additional-information.py @@ -10,47 +10,103 @@ "the response should include whether repeat measurement permission is granted for the well" ) def step_impl(context): + permission_type = "Water Level Sample" assert "permissions" in context.water_well_data permission_record = retrieve_latest_polymorphic_history_table_record( - context.well, "permissions", "allow_water_level_measurements", latest=True + context.objects["wells"][0], "permission_history", permission_type ) + water_well_data_permissions = [ + p + for p in context.water_well_data["permissions"] + if p["permission_type"] == permission_type + ][0] assert ( - context.water_well_data["permissions"]["allow_water_level_measurements"] + water_well_data_permissions["permission_type"] + == permission_record.permission_type + ) + assert ( + water_well_data_permissions["permission_allowed"] == permission_record.permission_allowed ) + assert water_well_data_permissions[ + "start_date" + ] == permission_record.start_date.strftime("%Y-%m-%d") + if permission_record.end_date: + assert water_well_data_permissions[ + "end_date" + ] == permission_record.end_date.strftime("%Y-%m-%d") + else: + assert water_well_data_permissions["end_date"] is None @then("the response should include whether sampling permission is granted for the well") def step_impl(context): + permission_type = "Water Chemistry Sample" assert "permissions" in context.water_well_data permission_record = retrieve_latest_polymorphic_history_table_record( - context.well, "permissions", "allow_water_chemistry_sample", latest=True + context.objects["wells"][0], "permission_history", permission_type ) + water_well_data_permissions = [ + p + for p in context.water_well_data["permissions"] + if p["permission_type"] == permission_type + ][0] + assert ( + water_well_data_permissions["permission_type"] + == permission_record.permission_type + ) assert ( - context.water_well_data["permissions"]["allow_sampling"] + water_well_data_permissions["permission_allowed"] == permission_record.permission_allowed ) + assert water_well_data_permissions[ + "start_date" + ] == permission_record.start_date.strftime("%Y-%m-%d") + if permission_record.end_date: + assert water_well_data_permissions[ + "end_date" + ] == permission_record.end_date.strftime("%Y-%m-%d") + else: + assert water_well_data_permissions["end_date"] is None -# TODO: should this be datalogger specific? @then( "the response should include whether datalogger installation permission is granted for the well" ) def step_impl(context): + permission_type = "Datalogger Installation" assert "permissions" in context.water_well_data permission_record = retrieve_latest_polymorphic_history_table_record( - context.well, "permissions", "allow_data_logger_installation", latest=True + context.objects["wells"][0], "permission_history", permission_type ) + water_well_data_permissions = [ + p + for p in context.water_well_data["permissions"] + if p["permission_type"] == permission_type + ][0] + assert ( + water_well_data_permissions["permission_type"] + == permission_record.permission_type + ) assert ( - context.water_well_data["permissions"]["allow_data_logger_installation"] + water_well_data_permissions["permission_allowed"] == permission_record.permission_allowed ) + assert water_well_data_permissions[ + "start_date" + ] == permission_record.start_date.strftime("%Y-%m-%d") + if permission_record.end_date: + assert water_well_data_permissions[ + "end_date" + ] == permission_record.end_date.strftime("%Y-%m-%d") + else: + assert water_well_data_permissions["end_date"] is None # ------------------------------------------------------------------------------ @@ -58,47 +114,48 @@ def step_impl(context): # ------------------------------------------------------------------------------ -# TODO: needs to be added to model, schemas, test data @then("the response should include the completion date of the well") def step_impl(context): - assert "completion_date" in context.water_well_data - assert context.water_well_data[ - "completion_date" - ] == context.well.completion_date.strftime("%Y-%m-%d") + assert "well_completion_date" in context.water_well_data + assert context.water_well_data["well_completion_date"] == context.objects["wells"][ + 0 + ].well_completion_date.strftime("%Y-%m-%d") -# TODO: needs to be added to model, schemas, test data @then("the response should include the source of the completion information") def step_impl(context): - assert "completion_info_source" in context.water_well_data + assert "well_completion_date_source" in context.water_well_data + assert ( - context.water_well_data["completion_info_source"] - == context.well.completion_info_source + context.water_well_data["well_completion_date_source"] + == context.objects["wells"][0].well_completion_date_source ) -# TODO: needs to be added to model, schemas, test data @then("the response should include the driller name") def step_impl(context): - assert "driller_name" in context.data - assert context.data["driller_name"] == context.well.driller_name + assert "well_driller_name" in context.water_well_data + assert ( + context.water_well_data["well_driller_name"] + == context.objects["wells"][0].well_driller_name + ) -# TODO: needs to be added to model, schemas, test data -# TODO: needs to be an enum and added to lexicon @then("the response should include the construction method") def step_impl(context): - assert "construction_method" in context.data - assert context.data["construction_method"] == context.well.construction_method + assert "well_construction_method" in context.water_well_data + assert ( + context.water_well_data["well_construction_method"] + == context.objects["wells"][0].well_construction_method + ) -# TODO: needs to be added to model, schemas, test data @then("the response should include the source of the construction information") def step_impl(context): - assert "construction_info_source" in context.water_well_data + assert "well_construction_method_source" in context.water_well_data assert ( - context.water_well_data["construction_info_source"] - == context.well.construction_info_source + context.water_well_data["well_construction_method_source"] + == context.objects["wells"][0].well_construction_method_source ) @@ -107,14 +164,16 @@ def step_impl(context): # ------------------------------------------------------------------------------ -# TODO: the transfer script needs to convert ft to in @then("the response should include the casing diameter in inches") def step_impl(context): - assert "casing_diameter" in context.water_well_data - assert "casing_diameter_unit" in context.water_well_data + assert "well_casing_diameter" in context.water_well_data + assert "well_casing_diameter_unit" in context.water_well_data - assert context.water_well_data["casing_diameter"] == context.well.casing_diameter - assert context.water_well_data["casing_diameter_unit"] == "in" + assert ( + context.water_well_data["well_casing_diameter"] + == context.objects["wells"][0].well_casing_diameter + ) + assert context.water_well_data["well_casing_diameter_unit"] == "in" @then("the response should include the casing depth in feet below ground surface") @@ -123,46 +182,50 @@ def step_impl(context): assert "well_casing_depth_unit" in context.water_well_data assert ( - context.water_well_data["well_casing_depth"] == context.well.well_casing_depth + context.water_well_data["well_casing_depth"] + == context.objects["wells"][0].well_casing_depth ) assert context.water_well_data["well_casing_depth_unit"] == "ft" -# TODO: needs to be added to model, schemas, test data @then("the response should include the casing materials") def step_impl(context): assert "well_casing_materials" in context.water_well_data - assert sorted(context.water_well_data["well_casing_materials"]) == sorted( - [m.material for m in context.well.well_casing_materials] - ) + assert set(context.water_well_data["well_casing_materials"]) == { + m.material for m in context.objects["wells"][0].well_casing_materials + } -# TODO: needs to be added to model, schemas, test data -# TODO: needs to be added to lexicon and an enum should be created @then("the response should include the well pump type (previously well_type field)") def step_impl(context): assert "well_pump_type" in context.water_well_data - assert context.water_well_data["well_pump_type"] == context.well.well_pump_type + assert ( + context.water_well_data["well_pump_type"] + == context.objects["wells"][0].well_pump_type + ) -# TODO: needs to be added to model, schemas, test data @then("the response should include the well pump depth in feet (new field)") def step_impl(context): assert "well_pump_depth" in context.water_well_data assert "well_pump_depth_unit" in context.water_well_data - assert context.water_well_data["well_pump_depth"] == context.well.well_pump_depth + assert ( + context.water_well_data["well_pump_depth"] + == context.objects["wells"][0].well_pump_depth + ) assert context.water_well_data["well_pump_depth_unit"] == "ft" -# TODO: needs to be added to model, schemas, test data @then( "the response should include whether the well is open and suitable for a datalogger" ) def step_impl(context): - data = context.response.json() - assert data["well_open"] is True - assert data["well_suitable_for_datalogger"] is True + assert "is_suitable_for_datalogger" in context.water_well_data + assert ( + context.water_well_data["is_suitable_for_datalogger"] + == context.objects["wells"][0].is_suitable_for_datalogger + ) # ------------------------------------------------------------------------------ @@ -170,31 +233,37 @@ def step_impl(context): # ------------------------------------------------------------------------------ -# TODO: needs to be added to model, schemas, test data @then( "the response should include the formation as the formation zone of well completion" ) def step_impl(context): - assert "formation" in context.water_well_data - assert context.water_well_data["formation"] == context.well.formation + assert "geologic_formations" in context.water_well_data + assert context.water_well_data["geologic_formations"] == [ + context.objects["geologic_formations"][0].formation_code + ] -# TODO: needs to be added to model, schemas, test data, lexicon @then( "the response should include the aquifer class code to classify the aquifer into aquifer system." ) def step_impl(context): - assert "aquifer_class_code" in context.water_well_data - assert ( - context.water_well_data["aquifer_class_code"] == context.well.aquifer_class_code - ) + for aquifer in context.water_well_data["aquifers"]: + assert "aquifer_system" in aquifer + assert {a.get("aquifer_system") for a in context.water_well_data["aquifers"]} == { + system.name for system in context.objects["aquifer_systems"] + } -# TODO: needs to be added to model, schemas, test data -# TODO: should this be plural? that is, a descriptor model of the well @then( "the response should include the aquifer type as the type of aquifers penetrated by the well" ) def step_impl(context): - assert "aquifer_type" in context.water_well_data - assert context.water_well_data["aquifer_type"] == context.well.aquifer_type + for aquifer in context.water_well_data["aquifers"]: + assert "aquifer_types" in aquifer + + if aquifer["aquifer_system"] == "Aquifer A": + assert set(aquifer["aquifer_types"]) == { + a.aquifer_type for a in context.objects["aquifer_types"] + } + else: + assert aquifer["aquifer_types"] == [] diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index b0adc8346..1f56161f6 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -163,7 +163,7 @@ def step_impl(context): and r.target_table == "thing" and r.target_id == context.objects["wells"][0].id ] - well_depth_source = well_depth_source_records[0].origin_source + well_depth_source = well_depth_source_records[0].origin_type assert context.water_well_data["well_depth_source"] == well_depth_source