Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
d1a4b34
fix: import lexicon from db
jacob-a-brown Nov 10, 2025
84a2817
feat: make GroupType and MonitoringFrequency enums
jacob-a-brown Nov 10, 2025
d8f69c6
feat: update GroupResponse and add to ThingResponse
jacob-a-brown Nov 10, 2025
81d960f
refactor: update bdd tests for updated group
jacob-a-brown Nov 10, 2025
d22f0da
feat: implement well purposes in behave tests
jacob-a-brown Nov 10, 2025
31c7070
refactor: make status_type and status_value lexicon terms
jacob-a-brown Nov 10, 2025
ba002e2
feat: add monitoring statuses to lexicon
jacob-a-brown Nov 10, 2025
eb5de1e
feat: add well status to thing
jacob-a-brown Nov 10, 2025
33e478e
feat: function to convert m to ft
jacob-a-brown Nov 10, 2025
dc33da4
feat: pass test for well status
jacob-a-brown Nov 10, 2025
f7c0ffb
feat: pass monitoring frequency bdd test
jacob-a-brown Nov 10, 2025
c3018cc
feat: implement monitoring status
jacob-a-brown Nov 10, 2025
9185229
refactor: remove outdated note
jacob-a-brown Nov 10, 2025
e7636dd
refactor: return GeoJSON for current_location
jacob-a-brown Nov 11, 2025
bdeb210
fix: transform wkb to wkt for tests
jacob-a-brown Nov 11, 2025
79e73d5
fix: transform wkb to wkt for tests
jacob-a-brown Nov 11, 2025
505a64e
notes: remove outdated TODO
jacob-a-brown Nov 11, 2025
12998f8
feat: add alternate ids to ThingResponse
jacob-a-brown Nov 11, 2025
a74168f
refactor: use Organiation enum for alternate organization
jacob-a-brown Nov 11, 2025
a10ed45
Merge branch 'staging' into bdms-221-jab-updates-to-pass-tests
jacob-a-brown Nov 11, 2025
2ba1271
fix: current_location is not nullable
jacob-a-brown Nov 11, 2025
a61c958
Merge branch 'bdms-221' into bdms-221-jab-updates-to-pass-tests
jacob-a-brown Nov 12, 2025
5581ce2
feat: add PLSS as an organization to lexicon
jacob-a-brown Nov 12, 2025
cafbb92
refactor: round m and ft conversion to 6 decimal places
jacob-a-brown Nov 12, 2025
3e1203c
refactor: set start/end date to date not datetime
jacob-a-brown Nov 12, 2025
2201ec1
refactor: use target_id and target_table in status_history
jacob-a-brown Nov 12, 2025
1b87a3c
refactor: use cls for status history mixin
jacob-a-brown Nov 12, 2025
3090f93
Merge branch 'bdms-221' into bdms-221-jab-updates-to-pass-tests
jacob-a-brown Nov 12, 2025
bcfff8f
feat: eagerly load measuring point history records
jacob-a-brown Nov 12, 2025
7658fb5
feat: get mp height/description from latest record
jacob-a-brown Nov 12, 2025
2b5d489
refactor: use MeasuringPointHistory table for mp data
jacob-a-brown Nov 12, 2025
f130c42
feat: implement MonitoringFrequencyHistory table
jacob-a-brown Nov 12, 2025
494486b
refactor: remove monitoring frequency from group
jacob-a-brown Nov 12, 2025
9feb596
refactor: use function to retrieve polymorphic records
jacob-a-brown Nov 12, 2025
3de8553
fix: remove polymorphic record retrieval from tests
jacob-a-brown Nov 12, 2025
49b3a8c
refactor: use function to retrieve polymorphic records
jacob-a-brown Nov 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 =============================================
3 changes: 3 additions & 0 deletions core/lexicon.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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"},
Expand Down
1 change: 1 addition & 0 deletions db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 0 additions & 17 deletions db/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 2 additions & 6 deletions db/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
44 changes: 30 additions & 14 deletions db/status_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
90 changes: 88 additions & 2 deletions db/thing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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 ---
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion schemas/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading