Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
01df48f
refactor: only get things of type throughh path params
jacob-a-brown Aug 26, 2025
f2b53c2
refactor: organize API and implement thing helper functions
jacob-a-brown Aug 26, 2025
151bf37
test: implement GET tests for water well things
jacob-a-brown Aug 26, 2025
7fd1fe1
test: implement get spring thing tests
jacob-a-brown Aug 26, 2025
5a1a81d
Merge branch 'jab-api-coverage-lexicon' into jab-api-coverage-thing
jacob-a-brown Aug 27, 2025
4120d35
feat: implement GET well screens
jacob-a-brown Aug 27, 2025
03a7f52
feat: implement GET thing id link
jacob-a-brown Aug 27, 2025
03f8343
refactor: remove location info from thing responses
jacob-a-brown Aug 27, 2025
3b9d8b9
feat: implement POST water well
jacob-a-brown Aug 27, 2025
67a20a9
fix: remove location/thing artifact
jacob-a-brown Aug 27, 2025
f0e9bc1
feat: implement POST well screen
jacob-a-brown Aug 27, 2025
15aede3
doc: remove outdate note
jacob-a-brown Aug 27, 2025
04d587e
feat: implement POST thing id link
jacob-a-brown Aug 27, 2025
6815ee9
feat: enable location's name to be PATCHed
jacob-a-brown Aug 27, 2025
d46710d
fix: close all database sessions
jacob-a-brown Aug 28, 2025
ee93a3f
fix: change tests to use spring/water well fixtures
jacob-a-brown Aug 28, 2025
4d7813a
refactor: let thing_type by set by request or string
jacob-a-brown Aug 28, 2025
d0d5dc7
feat: implement PATCH water well
jacob-a-brown Aug 28, 2025
380feee
feat: implement PATCH spring thing
jacob-a-brown Aug 28, 2025
1fa14d6
feat: implement PATCH thing id link
jacob-a-brown Aug 28, 2025
bfefba8
feat: implement PATCH well screen
jacob-a-brown Aug 28, 2025
add9f71
feat: implement DELETE for all thing models
jacob-a-brown Aug 28, 2025
6420519
Merge branch 'pre-production' into jab-api-coverage-thing
jacob-a-brown Aug 28, 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
474 changes: 306 additions & 168 deletions api/thing.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion schemas/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
from geoalchemy2 import WKBElement
from geoalchemy2.shape import to_shape
from pydantic import BaseModel, field_validator
from shapely import wkt

from schemas import ORMBaseModel
from services.validation.geospatial import validate_wkt_geometry
Expand Down Expand Up @@ -94,6 +93,7 @@ class UpdateLocation(BaseModel):
Schema for updating a location.
"""

name: str | None = None
notes: str | None = None
point: str | None = None
release_status: str | None = None
Expand Down
47 changes: 16 additions & 31 deletions schemas/thing.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,26 @@ class CreateThingIdLink(BaseModel):


class CreateBaseThing(BaseModel):
"""
Developer's notes

thing_type does not need to be set by the user, this is determined by the
POST endpoint

e.g. POST /thing/water-well, POST /thing/spring determines the thing_type
"""

location_id: int | None = None # Optional location ID for the thing
group_id: int | None = None # Optional group ID for the thing
name: str # Name of the thing
group: str | None = None # Optional group ID for the thing
thing_type: str | None = None # Type of the thing (e.g., "Well", "Spring", etc.)
release_status: str | None = "draft" # Release status of the thing
release_status: str # Release status of the thing


class CreateWell(CreateBaseThing):
"""
Schema for creating a well.
"""

# api_id: str | None = None
# ose_pod_id: str | None = None
well_type: str | None = None
well_depth: float | None = None # in feet
hole_depth: float | None = None # in feet
Expand Down Expand Up @@ -79,19 +85,6 @@ class CreateWellScreen(BaseModel):
screen_type: str | None = None
screen_description: str | None = None

@model_validator(mode="after")
def validate_screen_type(self):
if self.screen_type is not None:
valid_screen_types = [
"PVC",
] # todo: get valid screen types from database
if self.screen_type not in valid_screen_types:
raise ValueError(
f"Invalid screen_type: {self.screen_type}. "
f"Valid options are: {', '.join(valid_screen_types)}."
)
return self

# validate that screen depth bottom is greater than top
@model_validator(mode="after")
def check_depths(self):
Expand All @@ -106,7 +99,6 @@ def check_depths(self):
class BaseThingResponse(ORMBaseModel):
name: str
thing_type: str
id: int
release_status: str


Expand Down Expand Up @@ -135,8 +127,7 @@ class SpringResponse(BaseThingResponse):


class ThingResponse(WellResponse, SpringResponse):
location: LocationResponse | None = None # Optional location details
geometry: dict | None = None
pass


class ThingIdLinkResponse(ORMBaseModel):
Expand Down Expand Up @@ -207,32 +198,26 @@ class UpdateThing(BaseModel):
Schema for updating a thing.
"""

# location_id: int | None = None # Optional location ID for the thing
name: str | None = None # Optional name for the thing
release_status: str | None = None
# group: str | None = None # Optional group for the thing
# description: str | None = None # Optional description of the thing
# tags: list[str] | None = None # Optional tags associated with the thing


class UpdateWell(UpdateThing):
# location_id: int | None = None # Optional location ID for the well
# name: str | None = None # Optional name for the well
# api_id: str | None = None
# ose_pod_id: str | None = None

well_type: str | None = None
well_depth: float | None = None # in feet
hole_depth: float | None = None # in feet
well_construction_notes: str | None = None

# group: str | None = None # Optional group for the well

class UpdateSpring(UpdateThing):
spring_type: str | None = None


class UpdateThingIdLink(BaseModel):
alternate_organization: str | None = None
alternate_id: str | None = None
relation: str | None = None
thing_id: int | None = None


class UpdateWellScreen(BaseModel):
Expand Down
223 changes: 130 additions & 93 deletions services/thing_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,20 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
from fastapi import Request
from fastapi_pagination.ext.sqlalchemy import paginate
from pydantic import BaseModel
from sqlalchemy import select, func, and_
from sqlalchemy import select
from sqlalchemy.orm import Session
from starlette.status import HTTP_404_NOT_FOUND, HTTP_409_CONFLICT

from db import LocationThingAssociation, Thing, Base, Location
from schemas.location import LocationResponse
from db.group import Group, GroupThingAssociation
from db import LocationThingAssociation, Thing, Base, Location, WellScreen
from db.group import GroupThingAssociation
from services.audit_helper import audit_add
from services.crud_helper import model_patcher
from services.exceptions_helper import PydanticStyleException
from services.geospatial_helper import make_within_wkt
from services.query_helper import make_query, order_sort_filter
from services.query_helper import make_query, order_sort_filter, simple_get_by_id
from shapely import wkb
from shapely.geometry import mapping

Expand All @@ -41,124 +44,158 @@ def get_db_things(
query,
session,
sort,
thing_type: str | list[str] = None,
with_location: bool = False,
thing_type: str = None,
within: str = None,
):
) -> list:

if query:
sql = select(Thing).where(make_query(Thing, query))
else:
sql = select(Thing)

if with_location or within:
if thing_type:
sql = sql.where(Thing.thing_type == thing_type)

if within:
sql = sql.join(
LocationThingAssociation, Thing.id == LocationThingAssociation.thing_id
)
sql = sql.join(Location)
sql = make_within_wkt(sql, within)

if isinstance(thing_type, str):
thing_type = thing_type.lower()
thing_type = [thing_type]
elif isinstance(thing_type, list):
thing_type = [t.lower() for t in thing_type]

sql = sql.where(Thing.thing_type.in_(thing_type)) if thing_type else sql
sql = order_sort_filter(sql, Thing, sort, order, filter_)
if within:

sql = make_within_wkt(sql, within)

def transformer(records):
thing_ids = sorted([record.id for record in records])
subq = (
select(
LocationThingAssociation.thing_id,
func.max(LocationThingAssociation.effective_start).label("max_start"),
)
.where(LocationThingAssociation.thing_id.in_(thing_ids))
.group_by(LocationThingAssociation.thing_id)
.subquery()
)
stmt = (
select(Location)
.join(
LocationThingAssociation,
Location.id == LocationThingAssociation.location_id,
)
.join(Thing)
.join(
subq,
and_(
LocationThingAssociation.thing_id == subq.c.thing_id,
LocationThingAssociation.effective_start == subq.c.max_start,
),
)
.order_by(Thing.id.asc())
return paginate(query=sql, conn=session)


def get_thing_type_from_request(request: Request) -> str:
path = request.url.path
path_components = path.split("/")
if len(path_components) == 2:
# no thing type specified in path
thing_type_in_path = path_components[1]
if len(path_components) >= 3:
# thing type specified in path
thing_type_in_path = path_components[2]

thing_type = thing_type_in_path.replace("-", " ")
return thing_type


def verify_thing_type_correspondence(thing: Thing, request: Request):
thing_type = get_thing_type_from_request(request)
if thing.thing_type != thing_type:
raise PydanticStyleException(
status_code=HTTP_404_NOT_FOUND,
detail=[
{
"loc": ["path", "thing_id"],
"type": "value_error",
"input": {"thing_id": thing.id},
"msg": f"Thing with ID {thing.id} is not a {thing_type} Thing. It is a {thing.thing_type} Thing.",
}
],
)
locations = session.scalars(stmt).all()

for r, l in zip(records, locations):

r.location = LocationResponse.model_validate(l)
r.geometry = wkb_to_geojson(l.point) if l.point else None
def get_thing_of_a_thing_type_by_id(session: Session, request: Request, thing_id: int):
thing = simple_get_by_id(session, Thing, thing_id)

return records
verify_thing_type_correspondence(thing, request)

return paginate(query=sql, conn=session, transformer=transformer)
return thing


# REFACTOR TODO: use enums (or enum-like object) for thing_type
def add_thing(
session: Session, data: BaseModel | dict, thing_type: str = None, user: dict = None
session: Session,
data: BaseModel | dict,
user: dict = None,
request: Request | None = None,
thing_type: str | None = None, # to be used only for data transfers, not the API
) -> Base:
if request is not None:
thing_type = get_thing_type_from_request(request)

if isinstance(data, BaseModel):
data = data.model_dump()

location_id = data.pop("location_id", None)

group_id = data.pop("group_id", None)
if not group_id:
group_name = data.pop("group", None)
if group_name is not None:
sql = select(Group).where(Group.name == group_name)
dbg = session.scalars(sql).one_or_none()
if dbg:
group_id = dbg.id
else:
raise ValueError(f"Group '{group_name}' not found.")

if not thing_type:
thing_type = data.get("thing_type", None)
if not thing_type:
raise ValueError("Thing type must be specified.")

thing = Thing(**data)
thing.thing_type = thing_type

audit_add(user, thing)

session.add(thing)
session.commit()
session.refresh(thing)

if group_id:
assoc = GroupThingAssociation()
audit_add(user, assoc)
assoc.group_id = group_id
assoc.thing_id = thing.id
session.add(assoc)

if location_id is not None:
assoc = LocationThingAssociation()
audit_add(user, assoc)
assoc.location_id = location_id
assoc.thing_id = thing.id
session.add(assoc)

session.commit()

try:
thing = Thing(**data)
thing.thing_type = thing_type

audit_add(user, thing)

session.add(thing)
session.flush()
session.refresh(thing)

# endpoint catches ProgrammingError if location_id or group_id do not exist
if group_id:
assoc = GroupThingAssociation()
audit_add(user, assoc)
assoc.group_id = group_id
assoc.thing_id = thing.id
session.add(assoc)

if location_id is not None:
assoc = LocationThingAssociation()
audit_add(user, assoc)
assoc.location_id = location_id
assoc.thing_id = thing.id
session.add(assoc)

session.commit()
except Exception as e:
session.rollback()
raise e
return thing


def add_well_screen(session, well_screen_data: BaseModel, user: dict = None):
try:
well_screen_data_dump = well_screen_data.model_dump()
well_screen = WellScreen(**well_screen_data_dump)
audit_add(user, well_screen)

session.add(well_screen)
session.flush()

thing = session.get(Thing, well_screen_data.thing_id)
if thing.thing_type != "water well":
raise PydanticStyleException(
status_code=HTTP_409_CONFLICT,
detail=[
{
"loc": ["body", "thing_id"],
"type": "value_error",
"input": {"thing_id": thing.id},
"msg": f"Thing with ID {thing.id} is not a water well Thing. It is a {thing.thing_type} Thing.",
}
],
)

session.commit()
except Exception as e:
session.rollback()
raise e
return well_screen


def patch_thing(
session: Session,
request: Request,
thing_id: int,
payload: BaseModel,
user: dict,
):
thing = simple_get_by_id(session, Thing, thing_id)

verify_thing_type_correspondence(thing, request)

return model_patcher(session, Thing, thing_id, payload, user)


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