Skip to content

Commit 7345dab

Browse files
committed
feat: add well_purposes and well_casing_materials to thing model
1 parent 15123a0 commit 7345dab

6 files changed

Lines changed: 241 additions & 41 deletions

File tree

api/search.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
Phone,
2828
Address,
2929
Thing,
30+
WellCasingMaterial,
31+
WellPurpose,
3032
Asset,
3133
AssetThingAssociation,
3234
search,
@@ -70,15 +72,28 @@ def _get_contact_results(session: Session, q: str, limit: int) -> list[dict]:
7072

7173

7274
def _get_thing_results(session: Session, q: str, limit: int) -> list[dict]:
73-
vector = Thing.search_vector
75+
well_vector = (
76+
func.coalesce(Thing.search_vector, text("''::tsvector"))
77+
.op("||")(func.coalesce(WellCasingMaterial.search_vector, text("''::tsvector")))
78+
.op("||")(func.coalesce(WellPurpose.search_vector, text("''::tsvector")))
79+
)
80+
7481
water_well_query = search(
75-
select(Thing).where(Thing.thing_type == "water well"),
82+
select(Thing)
83+
.outerjoin(WellCasingMaterial)
84+
.outerjoin(WellPurpose)
85+
.where(Thing.thing_type == "water well"),
7686
q,
77-
vector=vector,
87+
vector=well_vector,
7888
limit=limit,
7989
)
90+
91+
spring_vector = Thing.search_vector
8092
spring_well_query = search(
81-
select(Thing).where(Thing.thing_type == "spring"), q, vector=vector, limit=limit
93+
select(Thing).where(Thing.thing_type == "spring"),
94+
q,
95+
vector=spring_vector,
96+
limit=limit,
8297
)
8398

8499
# unique needs to be called because of eager loads

db/thing.py

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,6 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix
9898
info={"unit": "feet below ground surface"},
9999
comment="Depth of the well casing from ground surface to the bottom of the casing (in feet).",
100100
)
101-
well_casing_material: Mapped[str] = lexicon_term(
102-
nullable=True,
103-
comment="Material of the well casing (e.g., 'PVC', 'Steel', 'Concrete', 'Wood').",
104-
)
105101

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

@@ -211,6 +207,22 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix
211207
passive_deletes=True,
212208
)
213209

210+
well_purposes: Mapped[List["WellPurpose"]] = relationship(
211+
"WellPurpose",
212+
back_populates="thing",
213+
cascade="all, delete-orphan",
214+
passive_deletes=True,
215+
lazy="joined",
216+
)
217+
218+
well_casing_materials: Mapped[List["WellCasingMaterial"]] = relationship(
219+
"WellCasingMaterial",
220+
back_populates="thing",
221+
cascade="all, delete-orphan",
222+
passive_deletes=True,
223+
lazy="joined",
224+
)
225+
214226
# --- Association Proxies ---
215227
assets: AssociationProxy[list["Asset"]] = association_proxy(
216228
"asset_associations", "asset"
@@ -237,11 +249,7 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix
237249
)
238250

239251
# Full-text search vector
240-
search_vector = Column(
241-
TSVectorType(
242-
"name", "well_construction_notes", "well_purpose", "well_casing_material"
243-
)
244-
)
252+
search_vector = Column(TSVectorType("name", "well_construction_notes"))
245253

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

304312

313+
class WellPurpose(Base, AutoBaseMixin, ReleaseMixin):
314+
"""
315+
Represents a controlled vocabulary term for well purposes.
316+
"""
317+
318+
thing_id: Mapped[int] = mapped_column(
319+
Integer, ForeignKey("thing.id", ondelete="CASCADE"), nullable=False
320+
)
321+
purpose: Mapped[str] = lexicon_term(nullable=False, unique=True)
322+
323+
search_vector: Mapped[TSVectorType] = mapped_column(TSVectorType("purpose"))
324+
325+
thing: Mapped["Thing"] = relationship("Thing", back_populates="well_purposes")
326+
327+
328+
class WellCasingMaterial(Base, AutoBaseMixin, ReleaseMixin):
329+
"""
330+
Represents a controlled vocabulary term for well casing materials.
331+
"""
332+
333+
thing_id: Mapped[int] = mapped_column(
334+
Integer, ForeignKey("thing.id", ondelete="CASCADE"), nullable=False
335+
)
336+
337+
material: Mapped[str] = lexicon_term(nullable=False, unique=True)
338+
339+
search_vector: Mapped[TSVectorType] = mapped_column(TSVectorType("material"))
340+
341+
thing: Mapped["Thing"] = relationship(
342+
"Thing", back_populates="well_casing_materials"
343+
)
344+
345+
305346
# TODO: this could be the model used to handle AMP monitoring
306347
# class FieldSamplingAdministation(Base, AutoBaseMixin):
307348
# # the thing being monitored

schemas/thing.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# ===============================================================================
1616
from typing import List
1717

18-
from pydantic import BaseModel, model_validator, PastDate, Field
18+
from pydantic import BaseModel, model_validator, PastDate, Field, field_validator
1919

2020
from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel
2121
from schemas.location import LocationResponse
@@ -89,7 +89,7 @@ class CreateWell(CreateBaseThing, ValidateWell):
8989
Schema for creating a well.
9090
"""
9191

92-
well_purpose: str | None = None
92+
well_purposes: list[str] | None = None
9393
well_depth: float | None = Field(
9494
default=None, gt=0, description="Well depth in feet"
9595
)
@@ -103,7 +103,7 @@ class CreateWell(CreateBaseThing, ValidateWell):
103103
well_casing_depth: float | None = Field(
104104
default=None, gt=0, description="Well casing depth in feet"
105105
)
106-
well_casing_material: str | None = None
106+
well_casing_materials: list[str] | None = None
107107

108108

109109
class CreateSpring(CreateBaseThing):
@@ -148,7 +148,7 @@ class WellResponse(BaseThingResponse):
148148
Response schema for well details.
149149
"""
150150

151-
well_purpose: str | None = None # e.g., "Production", "Observation", etc.
151+
well_purposes: list[str] = []
152152
well_depth: float | None = None
153153
well_depth_unit: str = "ft"
154154
hole_depth: float | None = None
@@ -157,9 +157,28 @@ class WellResponse(BaseThingResponse):
157157
well_casing_diameter_unit: str = "in"
158158
well_casing_depth: float | None = None
159159
well_casing_depth_unit: str = "ft"
160-
well_casing_material: str | None = None
160+
well_casing_materials: list[str] = []
161161
well_construction_notes: str | None = None
162162

163+
@field_validator("well_purposes", mode="before")
164+
def populate_well_purposes_with_strings(cls, well_purposes):
165+
if len(well_purposes) > 0:
166+
purposes = [well_purpose.purpose for well_purpose in well_purposes]
167+
else:
168+
purposes = []
169+
return purposes
170+
171+
@field_validator("well_casing_materials", mode="before")
172+
def populate_well_casing_materials_with_strings(cls, well_casing_materials):
173+
if len(well_casing_materials) > 0:
174+
materials = [
175+
well_casing_material.material
176+
for well_casing_material in well_casing_materials
177+
]
178+
else:
179+
materials = []
180+
return materials
181+
163182

164183
class SpringResponse(BaseThingResponse):
165184
"""
@@ -249,13 +268,13 @@ class UpdateThing(BaseUpdateModel):
249268

250269
class UpdateWell(UpdateThing, ValidateWell):
251270

252-
well_purpose: str | None = None
271+
well_purposes: list[str] | None = None
253272
well_depth: float | None = None # in feet
254273
hole_depth: float | None = None # in feet
255274
well_construction_notes: str | None = None
256275
well_casing_diameter: float | None = None # in inches
257276
well_casing_depth: float | None = None # in feet
258-
well_casing_material: str | None = None
277+
well_casing_materials: list[str] | None = None
259278

260279

261280
class UpdateSpring(UpdateThing):

services/thing_helper.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,15 @@
2020
from sqlalchemy.orm import Session, aliased
2121
from starlette.status import HTTP_404_NOT_FOUND, HTTP_409_CONFLICT
2222

23-
from db import LocationThingAssociation, Thing, Base, Location, WellScreen
23+
from db import (
24+
LocationThingAssociation,
25+
Thing,
26+
Base,
27+
Location,
28+
WellScreen,
29+
WellPurpose,
30+
WellCasingMaterial,
31+
)
2432
from db.group import GroupThingAssociation
2533
from services.audit_helper import audit_add
2634
from services.crud_helper import model_patcher
@@ -136,6 +144,8 @@ def add_thing(
136144

137145
location_id = data.pop("location_id", None)
138146
group_id = data.pop("group_id", None)
147+
well_purposes = data.pop("well_purposes", None)
148+
well_casing_materials = data.pop("well_casing_materials", None)
139149

140150
try:
141151
thing = Thing(**data)
@@ -163,6 +173,22 @@ def add_thing(
163173
assoc.thing_id = thing.id
164174
session.add(assoc)
165175

176+
if well_purposes:
177+
for well_purpose in well_purposes:
178+
wp = WellPurpose()
179+
audit_add(user, wp)
180+
wp.thing_id = thing.id
181+
wp.purpose = well_purpose
182+
session.add(wp)
183+
184+
if well_casing_materials:
185+
for well_casing_material in well_casing_materials:
186+
wcm = WellCasingMaterial()
187+
audit_add(user, wcm)
188+
wcm.thing_id = thing.id
189+
wcm.material = well_casing_material
190+
session.add(wcm)
191+
166192
session.commit()
167193
session.refresh(thing)
168194
except Exception as e:
@@ -213,6 +239,43 @@ def patch_thing(
213239

214240
verify_thing_type_correspondence(thing, request)
215241

242+
data = payload.model_dump(exclude_unset=True)
243+
244+
if "water-well" in request.url.path:
245+
well_purposes = data.pop("well_purposes", None)
246+
well_casing_materials = data.pop("well_casing_materials", None)
247+
248+
if well_purposes is not None:
249+
# delete existing purposes
250+
session.query(WellPurpose).filter(WellPurpose.thing_id == thing.id).delete()
251+
# add new purposes
252+
for well_purpose in well_purposes:
253+
wp = WellPurpose()
254+
audit_add(user, wp)
255+
wp.thing_id = thing.id
256+
wp.purpose = well_purpose
257+
session.add(wp)
258+
session.commit()
259+
260+
if well_casing_materials is not None:
261+
# delete existing materials
262+
session.query(WellCasingMaterial).filter(
263+
WellCasingMaterial.thing_id == thing.id
264+
).delete()
265+
# add new materials
266+
for well_casing_material in well_casing_materials:
267+
wcm = WellCasingMaterial()
268+
audit_add(user, wcm)
269+
wcm.thing_id = thing.id
270+
wcm.material = well_casing_material
271+
session.add(wcm)
272+
session.commit()
273+
274+
# remove these fields from payload after they have been handled
275+
for field in ["well_purposes", "well_casing_materials"]:
276+
if hasattr(payload, field):
277+
delattr(payload, field)
278+
216279
thing = model_patcher(session, Thing, thing_id, payload, user)
217280
return thing
218281

tests/conftest.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,11 @@ def water_well_thing(location):
5555
first_visit_date="2023-03-03",
5656
thing_type="water well",
5757
release_status="draft",
58-
well_purpose="Domestic",
5958
well_depth=10,
6059
hole_depth=10,
6160
well_construction_notes="Test well construction notes",
6261
well_casing_diameter=5.0,
6362
well_casing_depth=10.0,
64-
well_casing_material="PVC",
6563
)
6664
session.add(water_well)
6765
session.commit()
@@ -81,6 +79,66 @@ def water_well_thing(location):
8179
session.commit()
8280

8381

82+
@pytest.fixture()
83+
def pvc_well_casing_material(water_well_thing):
84+
with session_ctx() as session:
85+
casing_material = WellCasingMaterial(
86+
thing_id=water_well_thing.id,
87+
material="PVC",
88+
release_status="draft",
89+
)
90+
session.add(casing_material)
91+
session.commit()
92+
yield casing_material
93+
session.delete(casing_material)
94+
session.commit()
95+
96+
97+
@pytest.fixture(scope="function")
98+
def steel_well_casing_material(water_well_thing):
99+
with session_ctx() as session:
100+
casing_material = WellCasingMaterial(
101+
thing_id=water_well_thing.id,
102+
material="Steel",
103+
release_status="draft",
104+
)
105+
session.add(casing_material)
106+
session.commit()
107+
yield casing_material
108+
session.delete(casing_material)
109+
session.commit()
110+
111+
112+
@pytest.fixture()
113+
def irrigation_well_purpose(water_well_thing):
114+
with session_ctx() as session:
115+
purpose = WellPurpose(
116+
thing_id=water_well_thing.id,
117+
purpose="Irrigation",
118+
release_status="draft",
119+
)
120+
session.add(purpose)
121+
session.commit()
122+
yield purpose
123+
session.delete(purpose)
124+
session.commit()
125+
126+
127+
@pytest.fixture()
128+
def domestic_well_purpose(water_well_thing):
129+
with session_ctx() as session:
130+
purpose = WellPurpose(
131+
thing_id=water_well_thing.id,
132+
purpose="Domestic",
133+
release_status="draft",
134+
)
135+
session.add(purpose)
136+
session.commit()
137+
yield purpose
138+
session.delete(purpose)
139+
session.commit()
140+
141+
84142
@pytest.fixture()
85143
def well_screen(water_well_thing):
86144
with session_ctx() as session:

0 commit comments

Comments
 (0)