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
23 changes: 19 additions & 4 deletions api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
Phone,
Address,
Thing,
WellCasingMaterial,
WellPurpose,
Asset,
AssetThingAssociation,
search,
Expand Down Expand Up @@ -70,15 +72,28 @@ def _get_contact_results(session: Session, q: str, limit: int) -> list[dict]:


def _get_thing_results(session: Session, q: str, limit: int) -> list[dict]:
vector = Thing.search_vector
well_vector = (
func.coalesce(Thing.search_vector, text("''::tsvector"))
.op("||")(func.coalesce(WellCasingMaterial.search_vector, text("''::tsvector")))
.op("||")(func.coalesce(WellPurpose.search_vector, text("''::tsvector")))
)

water_well_query = search(
select(Thing).where(Thing.thing_type == "water well"),
select(Thing)
.outerjoin(WellCasingMaterial)
.outerjoin(WellPurpose)
.where(Thing.thing_type == "water well"),
q,
vector=vector,
vector=well_vector,
limit=limit,
)

spring_vector = Thing.search_vector
spring_well_query = search(
select(Thing).where(Thing.thing_type == "spring"), q, vector=vector, limit=limit
select(Thing).where(Thing.thing_type == "spring"),
q,
vector=spring_vector,
limit=limit,
)

# unique needs to be called because of eager loads
Expand Down
22 changes: 18 additions & 4 deletions api/thing.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@
editor_dependency,
viewer_dependency,
)
from db.thing import Thing, WellScreen
from db.thing import ThingIdLink
from db.thing import Thing, ThingIdLink, WellScreen
from schemas.thing import (
CreateThingIdLink,
CreateWell,
Expand Down Expand Up @@ -62,6 +61,8 @@
add_well_screen,
get_db_things,
get_thing_of_a_thing_type_by_id,
modify_well_descriptor_tables,
WELL_DESCRIPTOR_MODEL_MAP,
)
from services.lexicon_helper import get_terms_by_category

Expand Down Expand Up @@ -379,7 +380,9 @@ async def create_well(
Create a new water well in the database.
"""
try:
return add_thing(session=session, data=thing_data, request=request, user=user)
thing = add_thing(session=session, data=thing_data, request=request, user=user)
modify_well_descriptor_tables(session, thing, thing_data, user)
return thing
except ProgrammingError as e:
database_error_handler(thing_data, e)

Expand Down Expand Up @@ -443,7 +446,18 @@ async def update_water_well(
"""
Update an existing well by ID.
"""
return patch_thing(session, request, thing_id, thing_data, user=user)
well_descriptor_data = thing_data.model_copy(deep=True)

# remove these fields from payload otherwise patch_thing will try to process
# and raise an error because they are not found in the Thing model
for field in WELL_DESCRIPTOR_MODEL_MAP.keys():
if hasattr(thing_data, field):
delattr(thing_data, field)

thing = patch_thing(session, request, thing_id, thing_data, user=user)
modify_well_descriptor_tables(session, thing, well_descriptor_data, user)

return thing


@router.patch(
Expand Down
59 changes: 50 additions & 9 deletions db/thing.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,6 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix
info={"unit": "feet below ground surface"},
comment="Depth of the well casing from ground surface to the bottom of the casing (in feet).",
)
well_casing_material: Mapped[str] = lexicon_term(
nullable=True,
comment="Material of the well casing (e.g., 'PVC', 'Steel', 'Concrete', 'Wood').",
)

well_construction_notes: Mapped[str] = mapped_column(Text, nullable=True)

Expand Down Expand Up @@ -211,6 +207,22 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix
passive_deletes=True,
)

well_purposes: Mapped[List["WellPurpose"]] = relationship(
"WellPurpose",
back_populates="thing",
cascade="all, delete-orphan",
passive_deletes=True,
lazy="joined",
)

well_casing_materials: Mapped[List["WellCasingMaterial"]] = relationship(
"WellCasingMaterial",
back_populates="thing",
cascade="all, delete-orphan",
passive_deletes=True,
lazy="joined",
)

# --- Association Proxies ---
assets: AssociationProxy[list["Asset"]] = association_proxy(
"asset_associations", "asset"
Expand All @@ -237,11 +249,7 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix
)

# Full-text search vector
search_vector = Column(
TSVectorType(
"name", "well_construction_notes", "well_purpose", "well_casing_material"
)
)
search_vector = Column(TSVectorType("name", "well_construction_notes"))

@property
def current_location(self):
Expand Down Expand Up @@ -302,6 +310,39 @@ class WellScreen(Base, AutoBaseMixin, ReleaseMixin):
thing: Mapped["Thing"] = relationship("Thing", back_populates="screens")


class WellPurpose(Base, AutoBaseMixin, ReleaseMixin):
"""
Represents a controlled vocabulary term for well purposes.
"""

thing_id: Mapped[int] = mapped_column(
Integer, ForeignKey("thing.id", ondelete="CASCADE"), nullable=False
)
purpose: Mapped[str] = lexicon_term(nullable=False)

search_vector: Mapped[TSVectorType] = mapped_column(TSVectorType("purpose"))

thing: Mapped["Thing"] = relationship("Thing", back_populates="well_purposes")


class WellCasingMaterial(Base, AutoBaseMixin, ReleaseMixin):
"""
Represents a controlled vocabulary term for well casing materials.
"""

thing_id: Mapped[int] = mapped_column(
Integer, ForeignKey("thing.id", ondelete="CASCADE"), nullable=False
)

material: Mapped[str] = lexicon_term(nullable=False)

search_vector: Mapped[TSVectorType] = mapped_column(TSVectorType("material"))

thing: Mapped["Thing"] = relationship(
"Thing", back_populates="well_casing_materials"
)


# TODO: this could be the model used to handle AMP monitoring
# class FieldSamplingAdministation(Base, AutoBaseMixin):
# # the thing being monitored
Expand Down
45 changes: 28 additions & 17 deletions schemas/thing.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# ===============================================================================
from typing import List

from pydantic import BaseModel, model_validator, PastDate, Field
from pydantic import BaseModel, model_validator, PastDate, Field, field_validator

from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel
from schemas.location import LocationResponse
Expand All @@ -31,19 +31,11 @@ class ValidateWell(BaseModel):
@model_validator(mode="after")
def check_depths(self):
if (
self.well_depth is not None
and self.hole_depth is not None
self.hole_depth is not None
and self.well_depth is not None
and self.well_depth > self.hole_depth
):
raise ValueError("well depth must be less than than or equal to hole depth")
elif (
self.well_depth is not None
and self.well_casing_depth is not None
and self.well_casing_depth > self.well_depth
):
raise ValueError(
"well casing depth must be less than or equal to well depth"
)
elif (
self.hole_depth is not None
and self.well_casing_depth is not None
Expand Down Expand Up @@ -89,7 +81,7 @@ class CreateWell(CreateBaseThing, ValidateWell):
Schema for creating a well.
"""

well_purpose: str | None = None
well_purposes: list[str] | None = None
well_depth: float | None = Field(
default=None, gt=0, description="Well depth in feet"
)
Expand All @@ -103,7 +95,7 @@ class CreateWell(CreateBaseThing, ValidateWell):
well_casing_depth: float | None = Field(
default=None, gt=0, description="Well casing depth in feet"
)
well_casing_material: str | None = None
well_casing_materials: list[str] | None = None


class CreateSpring(CreateBaseThing):
Expand Down Expand Up @@ -148,7 +140,7 @@ class WellResponse(BaseThingResponse):
Response schema for well details.
"""

well_purpose: str | None = None # e.g., "Production", "Observation", etc.
well_purposes: list[str] = []
well_depth: float | None = None
well_depth_unit: str = "ft"
hole_depth: float | None = None
Expand All @@ -157,9 +149,28 @@ class WellResponse(BaseThingResponse):
well_casing_diameter_unit: str = "in"
well_casing_depth: float | None = None
well_casing_depth_unit: str = "ft"
well_casing_material: str | None = None
well_casing_materials: list[str] = []
well_construction_notes: str | None = None

@field_validator("well_purposes", mode="before")
def populate_well_purposes_with_strings(cls, well_purposes):
if well_purposes is not None:
purposes = [well_purpose.purpose for well_purpose in well_purposes]
else:
purposes = []
return purposes

@field_validator("well_casing_materials", mode="before")
def populate_well_casing_materials_with_strings(cls, well_casing_materials):
if well_casing_materials is not None:
materials = [
well_casing_material.material
for well_casing_material in well_casing_materials
]
else:
materials = []
return materials


class SpringResponse(BaseThingResponse):
"""
Expand Down Expand Up @@ -249,13 +260,13 @@ class UpdateThing(BaseUpdateModel):

class UpdateWell(UpdateThing, ValidateWell):

well_purpose: str | None = None
well_purposes: list[str] | None = None
well_depth: float | None = None # in feet
hole_depth: float | None = None # in feet
well_construction_notes: str | None = None
well_casing_diameter: float | None = None # in inches
well_casing_depth: float | None = None # in feet
well_casing_material: str | None = None
well_casing_materials: list[str] | None = None


class UpdateSpring(UpdateThing):
Expand Down
48 changes: 46 additions & 2 deletions services/thing_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,15 @@
from sqlalchemy.orm import Session, aliased
from starlette.status import HTTP_404_NOT_FOUND, HTTP_409_CONFLICT

from db import LocationThingAssociation, Thing, Base, Location, WellScreen
from db import (
LocationThingAssociation,
Thing,
Base,
Location,
WellScreen,
WellPurpose,
WellCasingMaterial,
)
from db.group import GroupThingAssociation
from services.audit_helper import audit_add
from services.crud_helper import model_patcher
Expand All @@ -30,6 +38,11 @@
from shapely import wkb
from shapely.geometry import mapping

WELL_DESCRIPTOR_MODEL_MAP = {
"well_purposes": (WellPurpose, "purpose"),
"well_casing_materials": (WellCasingMaterial, "material"),
}


def wkb_to_geojson(wkb_element):
if wkb_element is None:
Expand Down Expand Up @@ -132,7 +145,8 @@ def add_thing(
thing_type = get_thing_type_from_request(request)

if isinstance(data, BaseModel):
data = data.model_dump()
well_descriptor_table_list = list(WELL_DESCRIPTOR_MODEL_MAP.keys())
data = data.model_dump(exclude=well_descriptor_table_list)

location_id = data.pop("location_id", None)
group_id = data.pop("group_id", None)
Expand Down Expand Up @@ -217,4 +231,34 @@ def patch_thing(
return thing


def modify_well_descriptor_tables(
session: Session, thing: Thing, payload: BaseModel, user: dict
) -> None:
"""
This function is to add and update well descriptor tables when a Thing is created
or updated. It deletes existing descriptor table records for the Thing if they
exist and then adds the new data.
"""
try:
for descriptor_table in WELL_DESCRIPTOR_MODEL_MAP.keys():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for key,value in WELL_DESCRIPTOR_MODEL_MAP.items():

db_table, field_name = WELL_DESCRIPTOR_MODEL_MAP[descriptor_table]
descriptor_table_data = payload.model_dump(exclude_unset=True).pop(
descriptor_table, None
)
if descriptor_table_data:
session.query(db_table).filter(db_table.thing_id == thing.id).delete()
for ctd in descriptor_table_data:
Comment thread
jirhiker marked this conversation as resolved.
inserts = {"thing_id": thing.id, field_name: ctd}
record = db_table(**inserts)
audit_add(user, record)
session.add(record)
session.commit()

# Thing needs to be refreshed to find associated child table data
session.refresh(thing)
except Exception as e:
session.rollback()
raise e


# ============= EOF =============================================
Loading
Loading