Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 1 addition & 3 deletions core/lexicon.json
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,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": "Altimeter", "definition": "ALtimeter"},
{"categories": ["collection_method"], "term": "Differentially corrected GPS", "definition": "Differentially corrected GPS"},
{"categories": ["collection_method"], "term": "Survey-grade GPS", "definition": "Survey-grade GPS"},
Expand All @@ -584,9 +585,6 @@
{"categories": ["collection_method"], "term": "USGS National Elevation Dataset (NED)", "definition": "USGS National Elevation Dataset (NED)"},
{"categories": ["collection_method"], "term": "Transit, theodolite, or other survey method", "definition": "Transit, theodolite, or other survey method"},
{"categories": ["role"], "term": "Principal Investigator", "definition": "Principal Investigator"},
{"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"},
{"categories": ["role"], "term": "Manager", "definition": "Manager"},
{"categories": ["role"], "term": "Operator", "definition": "Operator"},
Expand Down
1 change: 1 addition & 0 deletions db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from db.thing import *
from db.transducer import *
from db.measuring_point_history import *
from db.data_provenance import *

from sqlalchemy import (
func,
Expand Down
21 changes: 21 additions & 0 deletions db/data_provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,24 @@ def data_provenance(cls):
lazy="selectin",
viewonly=True,
)

def _get_data_provenance_attribute(self, field_name, attribute):
"""
Returns the specified attribute from the DataProvenance record
for the given field_name, or None if not found.

Args:
field_name (str): The name of the field to look up provenance for.
attribute (str): The attribute of the DataProvenance record to return.

Returns:
The value of the specified attribute, or None if no record found.
"""
data_provenance_records = self.data_provenance
record = next(
(r for r in data_provenance_records if r.field_name == field_name), None
)
if record:
return getattr(record, attribute)
else:
return None
12 changes: 6 additions & 6 deletions db/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
from sqlalchemy.orm import relationship, Mapped, mapped_column

from constants import SRID_WGS84
from db.base import Base, AutoBaseMixin, ReleaseMixin, DataProvenanceMixin
from db.lexicon import lexicon_term
from db.base import Base, AutoBaseMixin, ReleaseMixin
from db.data_provenance import DataProvenanceMixin

if TYPE_CHECKING:
from db.thing import Thing
Expand All @@ -58,10 +58,6 @@ class Location(Base, AutoBaseMixin, ReleaseMixin, DataProvenanceMixin):
notes: Mapped[str] = mapped_column(Text, nullable=True)
nma_notes_location: Mapped[str] = mapped_column(Text, nullable=True)
nma_coordinate_notes: Mapped[str] = mapped_column(Text, nullable=True)
elevation_accuracy: Mapped[float] = mapped_column(nullable=True)
elevation_method: Mapped[str] = lexicon_term(nullable=True)
coordinate_accuracy: Mapped[float] = mapped_column(nullable=True)
coordinate_method: Mapped[str] = lexicon_term(nullable=True)

# --- Relationship Definitions ---
thing_associations: Mapped[list["LocationThingAssociation"]] = relationship(
Expand All @@ -83,6 +79,10 @@ def latlon(self):
p = to_shape(point)
return p.y, p.x

@property
def elevation_method(self) -> str | None:
return self._get_data_provenance_attribute("elevation", "collection_method")


class LocationThingAssociation(Base, AutoBaseMixin):
location_id: Mapped[int] = mapped_column(
Expand Down
12 changes: 8 additions & 4 deletions db/thing.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@
Base,
ReleaseMixin,
PermissionMixin,
DataProvenanceMixin,
)
from db.status_history import StatusHistoryMixin
from db.measuring_point_history import MeasuringPointHistory
from services.util import retrieve_latest_polymorphic_table_record
from db.data_provenance import DataProvenanceMixin
from services.util import retrieve_latest_polymorphic_history_table_record

if TYPE_CHECKING:
from db.location import Location
Expand Down Expand Up @@ -313,7 +313,7 @@ def well_status(self) -> str | None:

Since status_history is eagerly loaded, this should not introduce N+1 query issues.
"""
latest_status = retrieve_latest_polymorphic_table_record(
latest_status = retrieve_latest_polymorphic_history_table_record(
self, "status_history", "Well Status"
)
return latest_status.status_value if latest_status else None
Expand All @@ -326,7 +326,7 @@ def monitoring_status(self) -> str | None:

Since status_history is eagerly loaded, this should not introduce N+1 query issues.
"""
latest_status = retrieve_latest_polymorphic_table_record(
latest_status = retrieve_latest_polymorphic_history_table_record(
self, "status_history", "Monitoring Status"
)
return latest_status.status_value if latest_status else None
Expand Down Expand Up @@ -363,6 +363,10 @@ def measuring_point_description(self) -> str | None:
else:
return None

@property
def well_depth_source(self) -> str | None:
return self._get_data_provenance_attribute("well_depth", "origin_source")


class ThingIdLink(Base, AutoBaseMixin, ReleaseMixin):
"""
Expand Down
4 changes: 2 additions & 2 deletions run_bdd.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,13 @@ export BASE_URL=${BASE_URL:-http://localhost:8000}
#uv run behave tests/features --tags=@backend
#uv run behave tests/features/sensor-notes.feature --tags=@backend

uv run behave tests/features/transducer-data-response.feature
# uv run behave tests/features/transducer-data-response.feature

#uv run behave tests/features/transducer-data-response.feature \
# tests/features/thing-type-path-parameters.feature \
# tests/features/thing-query-parameters.feature

#uv run behave tests/features/well-inventory-csv.feature

uv run behave tests/features/well-core-information.feature --capture

echo "✅ BDD test run complete."
3 changes: 3 additions & 0 deletions schemas/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ def populate_fields(cls, data: Any) -> Any:
if not isinstance(data, dict):
data_dict = {c.name: getattr(data, c.name) for c in data.__table__.columns}

# @property need to be added manually
data_dict["elevation_method"] = data.elevation_method

# add empty fields as necessary
data_dict["geometry"] = {}
data_dict["properties"] = {}
Expand Down
1 change: 1 addition & 0 deletions schemas/thing.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ class WellResponse(BaseThingResponse):
well_purposes: list[WellPurpose] = []
well_depth: float | None = None
well_depth_unit: str = "ft"
well_depth_source: str | None
hole_depth: float | None = None
hole_depth_unit: str = "ft"
well_casing_diameter: float | None = None # in inches
Expand Down
5 changes: 4 additions & 1 deletion services/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def get_epqs_elevation_from_point(lon: float, lat: float) -> float | None:
return data["value"]


def retrieve_latest_polymorphic_table_record(
def retrieve_latest_polymorphic_history_table_record(
target_record: DeclarativeBase,
polymorphic_relationship: str,
polymorphic_type: str,
Expand All @@ -142,6 +142,9 @@ def retrieve_latest_polymorphic_table_record(
parent class has the correct mixin to support retrieval via an attribute. This
requires end_date to be None

This function does not apply to the DataProvenance table since it is not
a history table.

Parameters:
----------
target_record : DeclarativeBase
Expand Down
61 changes: 56 additions & 5 deletions tests/features/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
WellPurpose,
MeasuringPointHistory,
MonitoringFrequencyHistory,
DataProvenance,
)
from db.engine import session_ctx

Expand All @@ -57,10 +58,10 @@ def add_location(context, session):
point="POINT(-107.949533 33.809665)",
elevation=2464.9,
release_status="draft",
elevation_accuracy=100,
elevation_method="Survey-grade GPS",
coordinate_accuracy=50,
coordinate_method="GPS, uncorrected",
# elevation_accuracy=100,
# elevation_method="Survey-grade GPS",
# coordinate_accuracy=50,
# coordinate_method="GPS, uncorrected",
)
session.add(loc)
session.commit()
Expand Down Expand Up @@ -294,6 +295,36 @@ def add_id_link(
return id_link


@add_context_object_container("data_provenance")
def add_data_provenance(
context,
session,
target_id,
target_table,
field_name,
origin_source,
collection_method=None,
accuracy_value=None,
accuracy_unit=None,
):
data_provenance = DataProvenance(
field_name=field_name,
collection_method=collection_method,
target_id=target_id,
target_table=target_table,
origin_source=origin_source,
accuracy_value=accuracy_value,
accuracy_unit=accuracy_unit,
)

session.add(data_provenance)
session.commit()
session.refresh(data_provenance)

context.objects["data_provenance"].append(data_provenance)
return data_provenance


@add_context_object_container("transducer_observations")
def add_transducer_observation(context, session, block, deployment_id, value):
obs = TransducerObservation(
Expand Down Expand Up @@ -428,6 +459,25 @@ def before_all(context):

group = add_group(context, session, [well_1, well_2])

elevation_method = add_data_provenance(
context,
session,
target_id=loc_1.id,
target_table="location",
field_name="elevation",
origin_source="Private geologist, consultant or univ associate",
collection_method="LiDAR DEM",
)

well_depth_source = add_data_provenance(
context,
session,
target_id=well_1.id,
target_table="thing",
field_name="well_depth",
origin_source="Other",
)

for purpose in ["Domestic", "Irrigation"]:
add_well_purpose(context, session, well_1, purpose)

Expand All @@ -441,8 +491,9 @@ def before_all(context):

session.commit()

# the well needs to be refreshed to get all the new relationships
# the following needs to be refreshed to get all the new relationships
session.refresh(well_1)
session.refresh(loc_1)


def after_all(context):
Expand Down
21 changes: 20 additions & 1 deletion tests/features/steps/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
from behave import then, given
from behave import then, given, when
from starlette.testclient import TestClient

from core.dependencies import (
Expand Down Expand Up @@ -65,6 +65,25 @@ def closure():
assert context.client is not None, "TestClient failed to initialize"


@when("the user retrieves the well by ID via path parameter")
def step_impl(context):
context.response = context.client.get(
f"thing/water-well/{context.objects['wells'][0].id}"
)
context.water_well_data = context.response.json()
context.notes = {}


@then(
"null values in the response should be represented as JSON null (not placeholder strings)"
)
def step_impl(context):
data = context.response.json()
for k, v in data.items():
if v == "":
assert v is None, f"Value for key {k} is an empty string but should be null"


@then("I should receive a successful response")
def step_impl(context):
assert (
Expand Down
Loading
Loading