diff --git a/core/enums.py b/core/enums.py index 52e37d805..32538ce2a 100644 --- a/core/enums.py +++ b/core/enums.py @@ -67,4 +67,8 @@ Vertical_datum: type[Enum] = build_enum_from_lexicon_category("vertical_datum") ScreenType: type[Enum] = build_enum_from_lexicon_category("screen_type") SensorType: type[Enum] = build_enum_from_lexicon_category("sensor_type") +GroupType: type[Enum] = build_enum_from_lexicon_category("group_type") +MonitoringFrequency: type[Enum] = build_enum_from_lexicon_category( + "monitoring_frequency" +) # ============= EOF ============================================= diff --git a/core/lexicon.json b/core/lexicon.json index 0413f61b4..2ba161456 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -330,6 +330,8 @@ {"categories": ["status_value"], "term": "Active, pumping well", "definition": "This well is in use."}, {"categories": ["status_value"], "term": "Destroyed, exists but not usable", "definition": "The well structure is physically present but is damaged, collapsed, or otherwise compromised to the point that it is non-functional."}, {"categories": ["status_value"], "term": "Inactive, exists but not used", "definition": "The well is not currently in use but is believed to be in a usable condition; it has not been permanently decommissioned/abandoned."}, + {"categories": ["status_value"], "term": "Currently monitored", "definition": "The well is currently being monitored by AMMP."}, + {"categories": ["status_value"], "term": "Not currently monitored", "definition": "The well is not currently being monitored by AMMP."}, {"categories": ["sample_method"], "term": "Airline measurement", "definition": "Airline measurement"}, {"categories": ["sample_method"], "term": "Analog or graphic recorder", "definition": "Analog or graphic recorder"}, {"categories": ["sample_method"], "term": "Calibrated airline measurement", "definition": "Calibrated airline measurement"}, @@ -566,6 +568,7 @@ {"categories": ["organization"], "term": "Winter Brothers", "definition": "Winter Brothers"}, {"categories": ["organization"], "term": "Yates Petroleum Corporation", "definition": "Yates Petroleum Corporation"}, {"categories": ["organization"], "term": "Zamora Accounting Services", "definition": "Zamora Accounting Services"}, + {"categories": ["organization"], "term": "PLSS", "definition": "Public Land Survey System"}, {"categories": ["collection_method"], "term": "manual", "definition": "manual sampling"}, {"categories": ["collection_method"], "term": "continuous", "definition": "continuous sampling"}, {"categories": ["role"], "term": "Owner", "definition": "Owner"}, diff --git a/db/__init__.py b/db/__init__.py index efb23a418..9771aaa1e 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -40,6 +40,7 @@ from db.status_history import * from db.thing import * from db.transducer import * +from db.measuring_point_history import * from sqlalchemy import ( func, diff --git a/db/base.py b/db/base.py index ba2a45be8..5f9dd6516 100644 --- a/db/base.py +++ b/db/base.py @@ -172,23 +172,6 @@ def properties(self): # ============= Polymorphic Helper Mixins ============================================= -class StatusHistoryMixin: - """ - Mixin for models that can have a status history (e.g., Thing, Location). - It automatically creates a polymorphic One-to-Many relationship to the - StatusHistory table. - """ - - @declared_attr - def status_history(self): - # One-to-Many polymorphic relationship - return relationship( - "StatusHistory", - primaryjoin=f"and_({self.__name__}.id==foreign(StatusHistory.statusable_id), " - f"StatusHistory.statusable_type=='{self.__name__}')", - cascade="all, delete-orphan", - lazy="selectin", - ) class PermissionMixin: diff --git a/db/group.py b/db/group.py index a02eb3d34..2669e70f7 100644 --- a/db/group.py +++ b/db/group.py @@ -22,8 +22,7 @@ from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy from constants import SRID_WGS84 -from db.base import Base, AutoBaseMixin, ReleaseMixin -from tests.conftest import lexicon_term +from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term if TYPE_CHECKING: from db.group import GroupThingAssociation @@ -37,10 +36,7 @@ class Group(Base, AutoBaseMixin, ReleaseMixin): project_area: Mapped[Optional[WKBElement]] = mapped_column( Geometry(geometry_type="MULTIPOLYGON", srid=SRID_WGS84, spatial_index=True) ) - group_type: Mapped[Optional[str]] = lexicon_term(String(50), nullable=True) - monitoring_frequency: Mapped[Optional[str]] = lexicon_term( - String(50), nullable=True - ) + group_type: Mapped[Optional[str]] = lexicon_term(nullable=True) # Foreign Keys parent_group_id: Mapped[Optional[int]] = mapped_column( diff --git a/db/status_history.py b/db/status_history.py index acfd20f5d..8b3ee2321 100644 --- a/db/status_history.py +++ b/db/status_history.py @@ -9,30 +9,46 @@ mixin to establish a One-to-Many relationship TO this table. """ -import datetime +from datetime import date from sqlalchemy import ( Integer, String, - DateTime, Text, + and_, ) -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, declared_attr, relationship, foreign -from db.base import Base, AutoBaseMixin, ReleaseMixin +from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term, pascal_to_snake class StatusHistory(Base, AutoBaseMixin, ReleaseMixin): - status_type: Mapped[str] = mapped_column(String(50), nullable=False) - status_value: Mapped[str] = mapped_column(String(50), nullable=False) - start_date: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), nullable=True - ) - end_date: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), nullable=True - ) + status_type: Mapped[str] = lexicon_term(nullable=False) + status_value: Mapped[str] = lexicon_term(nullable=False) + start_date: Mapped[date] = mapped_column(nullable=False) + end_date: Mapped[date] = mapped_column(nullable=True) reason: Mapped[str] = mapped_column(Text, nullable=True) # Polymorphic relationship columns - statusable_id: Mapped[int] = mapped_column(Integer, nullable=False) - statusable_type: Mapped[str] = mapped_column(String(50), nullable=False) + target_id: Mapped[int] = mapped_column(Integer, nullable=False) + target_table: Mapped[str] = mapped_column(String(50), nullable=False) + + +class StatusHistoryMixin: + """ + Mixin for models that can have a status history (e.g., Thing, Location). + It automatically creates a polymorphic One-to-Many relationship to the + StatusHistory table. + """ + + @declared_attr + def status_history(cls): + return relationship( + "StatusHistory", + primaryjoin=and_( + cls.id == foreign(StatusHistory.target_id), + StatusHistory.target_table == pascal_to_snake(cls.__name__), + ), + cascade="all, delete-orphan", + lazy="selectin", + ) diff --git a/db/thing.py b/db/thing.py index 73dc9d4cf..7b33d8e6d 100644 --- a/db/thing.py +++ b/db/thing.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== from typing import List, TYPE_CHECKING - +from datetime import date from sqlalchemy import Integer, ForeignKey, String, Column, Float, Text, Date from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy from sqlalchemy.orm import relationship, mapped_column, Mapped @@ -26,10 +26,11 @@ AutoBaseMixin, Base, ReleaseMixin, - StatusHistoryMixin, PermissionMixin, ) +from db.status_history import StatusHistoryMixin from db.measuring_point_history import MeasuringPointHistory +from services.util import retrieve_latest_polymorphic_table_record if TYPE_CHECKING: from db.location import Location @@ -229,6 +230,7 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix back_populates="thing", cascade="all, delete-orphan", passive_deletes=True, + lazy="joined", ) # One-To-Many: A Thing (well) can have multiple measuring points over time. @@ -237,6 +239,15 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix back_populates="thing", cascade="all, delete-orphan", passive_deletes=True, + lazy="joined", + ) + + monitoring_frequencies: Mapped[List["MonitoringFrequencyHistory"]] = relationship( + "MonitoringFrequencyHistory", + back_populates="thing", + cascade="all, delete-orphan", + passive_deletes=True, + lazy="joined", ) # --- Association Proxies --- @@ -283,6 +294,64 @@ def current_location(self): else None ) + @property + def well_status(self) -> str | None: + """ + Returns the well status from the most recent status history entry + where status_type is "Well Status". + + Since status_history is eagerly loaded, this should not introduce N+1 query issues. + """ + latest_status = retrieve_latest_polymorphic_table_record( + self, "status_history", "Well Status" + ) + return latest_status.status_value if latest_status else None + + @property + def monitoring_status(self) -> str | None: + """ + Returns the monitoring status from the most recent status history entry + where status_type is "Monitoring Status". + + Since status_history is eagerly loaded, this should not introduce N+1 query issues. + """ + latest_status = retrieve_latest_polymorphic_table_record( + self, "status_history", "Monitoring Status" + ) + return latest_status.status_value if latest_status else None + + @property + def measuring_point_height(self) -> int | None: + """ + Returns the most recent measuring point height from the measuring point history + table. This assumes that every well has a measuring point + + Since measuring_point_history is eagerly loaded, this should not introduce N+1 query issues. + """ + if self.thing_type == "water well": + sorted_measuring_point_history = sorted( + self.measuring_points, key=lambda x: x.start_date, reverse=True + ) + return sorted_measuring_point_history[0].measuring_point_height + else: + return None + + @property + def measuring_point_description(self) -> str | None: + """ + Returns the most recent measuring point description from the measuring point history + table. This assumes that every well has a measuring point. + + Since measuring_point_history is eagerly loaded, this should not introduce N+1 query issues. + """ + if self.thing_type == "water well": + sorted_measuring_point_history = sorted( + self.measuring_points, key=lambda x: x.start_date, reverse=True + ) + return sorted_measuring_point_history[0].measuring_point_description + else: + return None + class ThingIdLink(Base, AutoBaseMixin, ReleaseMixin): """ @@ -359,6 +428,23 @@ class WellCasingMaterial(Base, AutoBaseMixin, ReleaseMixin): ) +class MonitoringFrequencyHistory(Base, AutoBaseMixin, ReleaseMixin): + """ + Represents the monitoring frequency history for a Thing. + """ + + thing_id: Mapped[int] = mapped_column( + Integer, ForeignKey("thing.id", ondelete="CASCADE"), nullable=False + ) + monitoring_frequency: Mapped[str] = lexicon_term(nullable=False) + start_date: Mapped[date] = mapped_column(Date, nullable=False) + end_date: Mapped[date] = mapped_column(Date, nullable=True) + + thing: Mapped["Thing"] = relationship( + "Thing", back_populates="monitoring_frequencies" + ) + + # TODO: this could be the model used to handle AMP monitoring # class FieldSamplingAdministation(Base, AutoBaseMixin): # # the thing being monitored diff --git a/schemas/group.py b/schemas/group.py index 49c3a25a4..e3cc7488c 100644 --- a/schemas/group.py +++ b/schemas/group.py @@ -18,6 +18,7 @@ from pydantic import BaseModel, field_validator, model_validator from typing_extensions import Self +from core.enums import GroupType from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel from services.validation.geospatial import validate_wkt_geometry @@ -53,8 +54,9 @@ class GroupResponse(BaseResponseModel): """ name: str - project_area: str | None description: str | None + project_area: str | None + group_type: GroupType | None parent_group_id: int | None @model_validator(mode="before") diff --git a/schemas/location.py b/schemas/location.py index 7b2d5420f..195562084 100644 --- a/schemas/location.py +++ b/schemas/location.py @@ -15,11 +15,14 @@ # =============================================================================== from geoalchemy2 import WKBElement from geoalchemy2.shape import to_shape -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, model_validator, field_validator, Field, ConfigDict +from typing import Any +from constants import SRID_WGS84, SRID_UTM_ZONE_13N from core.enums import ElevationMethod, CoordinateMethod from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel from services.validation.geospatial import validate_wkt_geometry +from services.util import convert_m_to_ft, transform_srid # -------- VALIDATE -------- @@ -60,6 +63,94 @@ class CreateGroupThing(BaseModel): # -------- RESPONSE ---------- + + +class GeoJSONGeometry(BaseModel): + type: str = "Point" + coordinates: list = Field( + max_length=3, + min_length=3, + description="Coordinates in [longitude, latitude, elevation] format", + ) + + model_config = ConfigDict( + from_attributes=True, + populate_by_name=True, + ) + + +class GeoJSONUTMCoordinates(BaseModel): + easting: float + northing: float + utm_zone: int = 13 + horizontal_datum: str = "NAD83" + + model_config = ConfigDict( + from_attributes=True, + populate_by_name=True, + ) + + +class GeoJSONProperties(BaseModel): + elevation: float + elevation_unit: str = "ft" + vertical_datum: str = "NAVD88" + elevation_method: ElevationMethod | None + utm_coordinates: GeoJSONUTMCoordinates = Field( + default_factory=GeoJSONUTMCoordinates + ) + + model_config = ConfigDict( + from_attributes=True, + populate_by_name=True, + ) + + +class LocationGeoJSONResponse(BaseModel): + type: str = "Feature" + geometry: GeoJSONGeometry + properties: GeoJSONProperties + + model_config = ConfigDict( + from_attributes=True, + populate_by_name=True, + ) + + @model_validator(mode="before") + @classmethod + def populate_fields(cls, data: Any) -> Any: + # convert row to dictionary + if not isinstance(data, dict): + data_dict = {c.name: getattr(data, c.name) for c in data.__table__.columns} + + # add empty fields as necessary + data_dict["geometry"] = {} + data_dict["properties"] = {} + data_dict["properties"]["utm_coordinates"] = {} + + # populate coordinates + point_wgs84_wkb = data_dict.get("point") + point_wgs84_wkt = to_shape(point_wgs84_wkb) + elevation_m = data_dict.get("elevation") + coordinates = [point_wgs84_wkt.x, point_wgs84_wkt.y, elevation_m] + data_dict["geometry"]["coordinates"] = coordinates + + # populate properties + data_dict["properties"]["elevation"] = convert_m_to_ft(elevation_m) + data_dict["properties"]["elevation_method"] = data_dict.get("elevation_method") + + # populate UTM coordinates + point_utm_zone_13n_wkt = transform_srid( + point_wgs84_wkt, SRID_WGS84, SRID_UTM_ZONE_13N + ) + data_dict["properties"]["utm_coordinates"]["easting"] = point_utm_zone_13n_wkt.x + data_dict["properties"]["utm_coordinates"][ + "northing" + ] = point_utm_zone_13n_wkt.y + + return data_dict + + class LocationResponse(BaseResponseModel): """ Response schema for sample location details. diff --git a/schemas/thing.py b/schemas/thing.py index cd741c758..39f5c15f6 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -17,9 +17,17 @@ from pydantic import BaseModel, model_validator, PastDate, Field, field_validator -from core.enums import WellPurpose, CasingMaterial, SpringType, ScreenType +from core.enums import ( + WellPurpose, + CasingMaterial, + SpringType, + ScreenType, + Organization, + MonitoringFrequency, +) from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel -from schemas.location import LocationResponse +from schemas.location import LocationGeoJSONResponse +from schemas.group import GroupResponse # -------- VALIDATE ---------- @@ -130,11 +138,42 @@ def check_depths(self): # ------ RESPONSE ---------- +class ThingIdLinkResponse(BaseResponseModel): + thing_id: int + relation: str + alternate_id: str + alternate_organization: Organization + + +class MonitoringFrequencyResponse(BaseModel): + monitoring_frequency: MonitoringFrequency + start_date: PastDate + end_date: PastDate | None + + class BaseThingResponse(BaseResponseModel): name: str thing_type: str - current_location: LocationResponse | None + current_location: LocationGeoJSONResponse first_visit_date: PastDate | None + groups: list[GroupResponse] = [] + monitoring_status: str | None + links: list[ThingIdLinkResponse] = Field(default=[], alias="alternate_ids") + monitoring_frequencies: list[MonitoringFrequencyResponse] = [] + + @field_validator("monitoring_frequencies", mode="before") + def remove_records_with_end_date(cls, monitoring_frequencies): + if monitoring_frequencies is not None: + active_frequencies = [ + { + "monitoring_frequency": freq.monitoring_frequency, + "start_date": freq.start_date.isoformat(), + "end_date": None, + } + for freq in monitoring_frequencies + if freq.end_date is None + ] + return active_frequencies class WellResponse(BaseThingResponse): @@ -153,6 +192,10 @@ class WellResponse(BaseThingResponse): well_casing_depth_unit: str = "ft" well_casing_materials: list[CasingMaterial] = [] well_construction_notes: str | None = None + well_status: str | None + measuring_point_height: float + measuring_point_height_unit: str = "ft" + measuring_point_description: str | None @field_validator("well_purposes", mode="before") def populate_well_purposes_with_strings(cls, well_purposes): @@ -186,22 +229,6 @@ class ThingResponse(WellResponse, SpringResponse): pass -class ThingIdLinkResponse(BaseResponseModel): - thing_id: int - thing: ThingResponse - relation: str - alternate_id: str - alternate_organization: str - - -class LocationWellResponse(LocationResponse): - """ - Response schema for sample location with well details. - """ - - well: List[WellResponse] = [] # List of wells associated with the sample location - - class WellScreenResponse(BaseResponseModel): """ Response schema for well screen details. diff --git a/services/util.py b/services/util.py index cb3d8826c..c5edee30e 100644 --- a/services/util.py +++ b/services/util.py @@ -3,10 +3,13 @@ from shapely.ops import transform import pyproj import httpx +from sqlalchemy.orm import DeclarativeBase from constants import SRID_WGS84 + TRANSFORMERS = {} +METERS_TO_FEET = 3.28084 def transform_srid(geometry, source_srid, target_srid): @@ -26,6 +29,20 @@ def transform_srid(geometry, source_srid, target_srid): return transform(transformer.transform, geometry) +def convert_m_to_ft(meters: float | None) -> float | None: + """Convert a length from meters to feet.""" + if meters is None: + return None + return round(meters * METERS_TO_FEET, 6) + + +def convert_ft_to_m(feet: float | None) -> float | None: + """Convert a length from feet to meters.""" + if feet is None: + return None + return round(feet / METERS_TO_FEET, 6) + + def get_tiger_data( lon: float, lat: float, layer: int, outfields: str = "*" ) -> dict | None: @@ -115,6 +132,52 @@ def get_epqs_elevation_from_point(lon: float, lat: float) -> float | None: return data["value"] +def retrieve_latest_polymorphic_table_record( + target_record: DeclarativeBase, + polymorphic_relationship: str, + polymorphic_type: str, +) -> DeclarativeBase | None: + """ + Retrieve the latest record from a polymorphic table. This function assumes that the + parent class has the correct mixin to support retrieval via an attribute. This + requires end_date to be None + + Parameters: + ---------- + target_record : DeclarativeBase + The parent record from which to retrieve the polymorphic child record. + polymorphic_relationship : str + The name of the relationship attribute on the parent record that corresponds to the polymorphic table. + polymorphic_type : str + The specific type of the polymorphic record to retrieve (e.g., 'Use Status' or 'Monitoring Status' for StatusHistory). + latest : bool, optional + If True, retrieves the latest record based on start_date. Defaults to True. + + Returns + ------- + DeclarativeBase | None + The latest record from the specified polymorphic table with the defined type if it exists. + """ + if polymorphic_relationship == "permissions": + type_field = "permission_type" + elif polymorphic_relationship == "status_history": + type_field = "status_type" + + polymorphic_records = getattr(target_record, polymorphic_relationship) + type_polymorphic_records = [ + r + for r in polymorphic_records + if getattr(r, type_field) == polymorphic_type and r.end_date is None + ] + sorted_type_polymorphic_records = sorted( + type_polymorphic_records, key=lambda r: r.start_date, reverse=True + ) + if sorted_type_polymorphic_records: + return sorted_type_polymorphic_records[0] + else: + return None + + if __name__ == "__main__": x = -106.904107 y = 34.068198 diff --git a/tests/__init__.py b/tests/__init__.py index ed7fe4ea8..5f06309dc 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -113,42 +113,4 @@ def cleanup_patch_test(model: Base, payload: dict, original_data: Base) -> None: session.commit() -def retrieve_latest_polymorphic_table_record( - target_record: Base, - polymorphic_relationship: str, - polymorphic_type: str, -) -> Base: - """ - Retrieve the latest record from a polymorphic table. This function assumes that the - parent class has the correct mixin to support retrieval via an attribute. This - requires end_date to be None - - Parameters: - ---------- - target_record : Base - The parent record from which to retrieve the polymorphic child record. - polymorphic_relationship : str - The name of the relationship attribute on the parent record that corresponds to the polymorphic table. - polymorphic_type : str - The specific type of the polymorphic record to retrieve (e.g., 'Use Status' or 'Monitoring Status' for StatusHistory). - latest : bool, optional - If True, retrieves the latest record based on start_date. Defaults to True. - """ - if polymorphic_relationship == "permissions": - type_field = "permission_type" - elif polymorphic_relationship == "status_history": - type_field = "status_type" - - polymorphic_records = getattr(target_record, polymorphic_relationship) - type_polymorphic_records = [ - r - for r in polymorphic_records - if getattr(r, type_field) == polymorphic_type and r.end_date is None - ] - sorted_type_polymorphic_records = sorted( - type_polymorphic_records, key=lambda r: r.start_date, reverse=True - ) - return sorted_type_polymorphic_records[0] - - # ============= EOF ============================================= diff --git a/tests/features/environment.py b/tests/features/environment.py index 61ee82709..c130e3f48 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -31,6 +31,9 @@ TransducerObservationBlock, StatusHistory, ThingIdLink, + WellPurpose, + MeasuringPointHistory, + MonitoringFrequencyHistory, ) from db.engine import session_ctx @@ -95,6 +98,53 @@ def add_well(context, session, location, name_num): return well +@add_context_object_container("well_purposes") +def add_well_purpose(context, session, well, purpose_term): + purpose = WellPurpose(thing=well, purpose=purpose_term) + session.add(purpose) + session.commit() + session.refresh(purpose) + + context.objects["well_purposes"].append(purpose) + return purpose + + +@add_context_object_container("measuring_point_histories") +def add_measuring_point_history(context, session, well): + mph = MeasuringPointHistory( + thing=well, + measuring_point_height=2, + measuring_point_description="test description", + start_date="2024-01-01", + end_date=None, + reason="Initial measuring point record", + ) + session.add(mph) + session.commit() + session.refresh(mph) + + context.objects["measuring_point_histories"].append(mph) + return mph + + +@add_context_object_container("monitoring_frequency_histories") +def add_monitoring_frequency_history( + context, session, well, monitoring_frequency, start_date, end_date +): + mfh = MonitoringFrequencyHistory( + thing=well, + monitoring_frequency=monitoring_frequency, + start_date=start_date, + end_date=end_date, + ) + session.add(mfh) + session.commit() + session.refresh(mfh) + + context.objects["monitoring_frequency_histories"].append(mfh) + return mfh + + @add_context_object_container("springs") def add_spring(context, session, location, name_num): spring = Thing( @@ -122,7 +172,7 @@ def add_spring(context, session, location, name_num): @add_context_object_container("sensors") -def add_sensor(context, session, sid): +def add_sensor(context, session): sensor = Sensor( name="Test Sensor", sensor_type="Pressure Transducer", @@ -143,12 +193,15 @@ def add_sensor(context, session, sid): @add_context_object_container("groups") -def add_group(context, session, wells, gid): +def add_group(context, session, things): group = Group( - name="Collabnet", description="Healy Collaborative Network", project_area=None + name="Collabnet", + description="Healy Collaborative Network", + project_area=None, + group_type="Monitoring Plan", ) - for w in wells: - assoc = GroupThingAssociation(group=group, thing=w) + for thing in things: + assoc = GroupThingAssociation(group=group, thing=thing) session.add(assoc) session.add(group) @@ -191,7 +244,7 @@ def add_block(context, session, parameter): return block -@add_context_object_container("status_histories") +@add_context_object_container("status_history") def add_status_history( context, session, @@ -200,8 +253,8 @@ def add_status_history( start_date, end_date, reason, - statusable_id, - statusable_type, + target_id, + target_table, ): status_history = StatusHistory( status_type=status_type, @@ -209,15 +262,15 @@ def add_status_history( start_date=start_date, end_date=end_date, reason=reason, - statusable_id=statusable_id, - statusable_type=statusable_type, + target_id=target_id, + target_table=target_table, ) session.add(status_history) session.commit() session.refresh(status_history) - context.objects["status_histories"].append(status_history) + context.objects["status_history"].append(status_history) return status_history @@ -258,55 +311,77 @@ def before_all(context): well_2 = add_well(context, session, loc_2, name_num=2) well_3 = add_well(context, session, loc_3, name_num=3) spring_4 = add_spring(context, session, loc_4, name_num=4) - sensor_1 = add_sensor(context, session, well_1.id) + sensor_1 = add_sensor(context, session) deployment = add_deployment(context, session, well_1.id, sensor_1.id) well_status_1 = add_status_history( context, session, - status_type="well_status", + status_type="Well Status", status_value="Active, pumping well", start_date=datetime(2020, 1, 1), end_date=datetime(2021, 1, 1), reason="Initial status", - statusable_id=well_1.id, - statusable_type="Thing", + target_id=context.objects["wells"][0].id, + target_table="thing", ) well_status_2 = add_status_history( context, session, - status_type="well_status", + status_type="Well Status", status_value="Destroyed, exists but not usable", start_date=datetime(2021, 1, 1), end_date=None, reason="Roving bovine", - statusable_id=well_1.id, - statusable_type="Thing", + target_id=context.objects["wells"][0].id, + target_table="thing", ) monitoring_status_1 = add_status_history( context, session, - status_type="monitoring_status", - status_value="currently monitored", + status_type="Monitoring Status", + status_value="Currently monitored", start_date=datetime(2020, 1, 1), end_date=datetime(2021, 1, 1), reason="Initial monitoring status", - statusable_id=well_1.id, - statusable_type="Thing", + target_id=context.objects["wells"][0].id, + target_table="thing", ) monitoring_status_2 = add_status_history( context, session, - status_type="monitoring_status", - status_value="not monitored", + status_type="Monitoring Status", + status_value="Not currently monitored", start_date=datetime(2021, 1, 1), end_date=None, reason="Roving bovine destroyed well", - statusable_id=well_1.id, - statusable_type="Thing", + target_id=context.objects["wells"][0].id, + target_table="thing", + ) + + measuring_point_history_1 = add_measuring_point_history( + context, session, well=well_1 + ) + + monitoring_frequency_history_1 = add_monitoring_frequency_history( + context, + session, + well=well_1, + monitoring_frequency="Monthly", + start_date="2020-01-01", + end_date="2021-01-01", + ) + + monitoring_frequency_history_2 = add_monitoring_frequency_history( + context, + session, + well=well_1, + monitoring_frequency="Annual", + start_date="2020-01-01", + end_date=None, ) id_link_1 = add_id_link( @@ -336,6 +411,11 @@ def before_all(context): alternate_organization="NMBGMR", ) + group = add_group(context, session, [well_1, well_2]) + + for purpose in ["Domestic", "Irrigation"]: + add_well_purpose(context, session, well_1, purpose) + # parameter ID can be hardcoded because init_parameter always creates the same one parameter = session.get(Parameter, 1) add_obs = add_block(context, session, parameter) @@ -350,6 +430,9 @@ def before_all(context): session.add(obs) session.commit() + # the well needs to be refreshed to get all the new relationships + session.refresh(well_1) + def after_all(context): with session_ctx() as session: diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index 54c67ac47..c4f235135 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -1,6 +1,12 @@ from constants import SRID_WGS84, SRID_UTM_ZONE_13N -from services.util import transform_srid +from services.util import ( + transform_srid, + convert_m_to_ft, + retrieve_latest_polymorphic_table_record, +) + from behave import when, then +from geoalchemy2.shape import to_shape # TODO: move to commonly used step definitions @@ -37,7 +43,6 @@ def step_impl(context): assert context.water_well_data["name"] == context.objects["wells"][0].name -# TODO: model schema, and test data need to be udpated @then("the response should include the project(s) or group(s) associated with the well") def step_impl(context): assert "groups" in context.water_well_data @@ -54,6 +59,10 @@ def step_impl(context): context.water_well_data["groups"][0]["project_area"] == context.objects["groups"][0].project_area ) + assert ( + context.water_well_data["groups"][0]["group_type"] + == context.objects["groups"][0].group_type + ) # ------------------------------------------------------------------------------ @@ -63,65 +72,57 @@ def step_impl(context): @then("the response should include the purpose of the well (current use)") def step_impl(context): + assert "well_purposes" in context.water_well_data + assert "Domestic" in context.water_well_data["well_purposes"] assert "Irrigation" in context.water_well_data["well_purposes"] assert ( context.water_well_data["well_purposes"][0] - == context.objects.wells[0].well_purposes[0].purpose + == context.objects["wells"][0].well_purposes[0].purpose ) assert ( context.water_well_data["well_purposes"][1] - == context.objects.wells[0].well_purposes[1].purpose + == context.objects["wells"][0].well_purposes[1].purpose ) -# TODO: this needs to be added to the ThingResponse and thing_helper via StatusHistory @then( "the response should include the well hole status of the well as the status of the hole in the ground (from previous Status field)" ) def step_impl(context): assert "well_status" in context.water_well_data - status_history = context.objects["wells"][0].status_history - well_status = [sh for sh in status_history if sh.status_type == "well_status"] - well_status_sorted = sorted(well_status, key=lambda sh: sh.start_date, reverse=True) - - assert context.water_well_data["well_status"] == well_status_sorted[0].status_value + well_status_record = retrieve_latest_polymorphic_table_record( + context.objects["wells"][0], "status_history", "Well Status" + ) + assert context.water_well_data["well_status"] == well_status_record.status_value -# TODO: this needs to be added to the model, schema, and test data -# TODO: the monitoring frequency field needs to be added to lexicon -# the monitoring status field from NM_Aquifer contains a multitude of information, like having three codes (6AC), so the transfer and model/schemas will need to take this into account -# could create descriptor table like WellPurpose and CasingMaterial @then("the response should include the monitoring frequency (new field)") def step_impl(context): - for group in context.water_well_data["groups"]: - assert "monitoring_frequency" in group + assert "monitoring_frequencies" in context.water_well_data - assert context.water_well_data["monitoring_frequency"] == "Monthly" + assert len(context.water_well_data["monitoring_frequencies"]) == 1 + assert context.water_well_data["monitoring_frequencies"][0] == { + "monitoring_frequency": "Annual", + "start_date": "2020-01-01", + "end_date": None, + } -# TODO: this needs to be added to the model, schema, and test data -# the monitoring status field from NM_Aquifer contains a multitude of information, like having three codes (6AC), so the transfer and model/schemas will need to take this into account -# could create descriptor table like WellPurpose and CasingMaterial @then( "the response should include whether the well is currently being monitored with status text if applicable (from previous MonitoringStatus field)" ) def step_impl(context): assert "monitoring_status" in context.water_well_data - status_history = context.objects["wells"][0].status_history - monitoring_status = [ - sh for sh in status_history if sh.status_type == "monitoring_status" - ] - monitoring_status_sorted = sorted( - monitoring_status, key=lambda sh: sh.start_date, reverse=True + monitoring_status_record = retrieve_latest_polymorphic_table_record( + context.objects["wells"][0], "status_history", "Monitoring Status" ) - assert ( context.water_well_data["monitoring_status"] - == monitoring_status_sorted[0].status_value + == monitoring_status_record.status_value ) @@ -183,7 +184,6 @@ def step_impl(context): # ------------------------------------------------------------------------------ -# TODO: this needs to be added to the model, schema, and test data @then("the response should include the description of the measuring point") def step_impl(context): assert "measuring_point_description" in context.water_well_data @@ -194,7 +194,6 @@ def step_impl(context): ) -# TODO: this needs to be added to the model, schema, and test data @then("the response should include the measuring point height in feet") def step_impl(context): assert "measuring_point_height" in context.water_well_data @@ -211,8 +210,6 @@ def step_impl(context): # Location Information # GeoJSON spec format RFC 7946 (Aug 2016) requires coordinates to be decimal degrees in WGS84 # ------------------------------------------------------------------------------ - - @then( "the response should include location information in GeoJSON spec format RFC 7946" ) @@ -227,13 +224,14 @@ def step_impl(context): assert context.water_well_data["current_location"]["type"] == "Feature" -# TODO: the LocationResponse schema needs to be updated @then( 'the response should include a geometry object with type "Point" and coordinates array [longitude, latitude, elevation]' ) def step_impl(context): - latitude = context.objects["locations"][0].point.y - longitude = context.objects["locations"][0].point.x + point_wkb = context.objects["locations"][0].point + point_wkt = to_shape(point_wkb) + latitude = point_wkt.y + longitude = point_wkt.x elevation_m = context.objects["locations"][0].elevation assert context.water_well_data["current_location"]["geometry"] == { @@ -242,8 +240,6 @@ def step_impl(context): } -# TODO: elevation should be returned in ft, not meters, conversion should occur in schema -# TODO: add elevation_unit: str = "ft" to LocationResponse schema @then( "the response should include the elevation in feet with vertical datum NAVD88 in the properties" ) @@ -252,7 +248,7 @@ def step_impl(context): assert "elevation_unit" in context.water_well_data["current_location"]["properties"] assert "vertical_datum" in context.water_well_data["current_location"]["properties"] - elevation_ft = context.objects["locations"][0].elevation * 3.28084 + elevation_ft = convert_m_to_ft(context.objects["locations"][0].elevation) assert ( context.water_well_data["current_location"]["properties"]["elevation"] @@ -281,7 +277,6 @@ def step_impl(context): ) -# TODO: this needs to be added to the LocationResponse schema @then( "the response should include the UTM coordinates with datum NAD83 in the properties" ) @@ -291,9 +286,9 @@ def step_impl(context): "utm_coordinates" in context.water_well_data["current_location"]["properties"] ) - point_utm_zone_13 = transform_srid( - context.objects["locations"][0].point, SRID_WGS84, SRID_UTM_ZONE_13N - ) + point_wkb = context.objects["locations"][0].point + point_wkt = to_shape(point_wkb) + point_utm_zone_13 = transform_srid(point_wkt, SRID_WGS84, SRID_UTM_ZONE_13N) assert context.water_well_data["current_location"]["properties"][ "utm_coordinates" @@ -310,8 +305,6 @@ def step_impl(context): # ------------------------------------------------------------------------------ -# TODO: This needs to be added to the test data -# TODO: id link schema needs to use lexicon enums for relation and alternate_organization @then( "the response should include any alternate IDs for the well like the NMBGMR site_name (i.e. John Smith Well), USGS site number, or the OSE well ID and OSE well tag ID" )