Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
839c924
refactor: type-hint observation table
jacob-a-brown Sep 15, 2025
fb8dbc5
feat: update waterlevels transfer for model revisions
jacob-a-brown Sep 15, 2025
fea2535
Merge branch 'staging' into jab-observation-updates
jacob-a-brown Sep 15, 2025
4f90b5e
feat: rename measurement_method to sample_method for new style
jacob-a-brown Sep 15, 2025
6dc2d7a
refactor: use sample_type instead of observation_class
jacob-a-brown Sep 16, 2025
d17bce6
note: note validations that need to be written
jacob-a-brown Sep 16, 2025
e259f72
refactor: update tests for new sample fixtures
jacob-a-brown Sep 16, 2025
7ce9114
refactor: rename sample_type to activity_type
jacob-a-brown Sep 16, 2025
5613e9d
refactor: update qc_sample lexicon
jacob-a-brown Sep 16, 2025
fb3765f
feat: update wl observation and sample transfers
jacob-a-brown Sep 16, 2025
16997b4
note: add developers note to model_patcher
jacob-a-brown Sep 17, 2025
04759de
WIP: sample, field, observation updates
jacob-a-brown Sep 17, 2025
0ee9b04
feat: implement POST for new sample/field
jacob-a-brown Sep 18, 2025
6044b68
refactor: revise test_409_add_sample_ivnalid_sample_name for updates
jacob-a-brown Sep 18, 2025
bf843f2
feat: enable many-to-many fieldevent/contact
jacob-a-brown Sep 18, 2025
3dff2fa
feat: enable many-to-many fieldevent/contact
jacob-a-brown Sep 18, 2025
ec550c0
refactor: remove sample helper - use association proxies instead for DRY
jacob-a-brown Sep 18, 2025
bb7b26d
note: remove old notes
jacob-a-brown Sep 18, 2025
85d19f5
refactor: update sample API and tests for revised schema
jacob-a-brown Sep 18, 2025
f94f51a
note: note validations required for sample
jacob-a-brown Sep 18, 2025
fb167f7
refactor: update PATCH sample tests for revised schema
jacob-a-brown Sep 18, 2025
85d36ac
feat: add field_activity to sample response for frontend use
jacob-a-brown Sep 18, 2025
95c65c8
note: note reusable utc converter function for schemas
jacob-a-brown Sep 18, 2025
63623ff
refactor: remove outdate note
jacob-a-brown Sep 18, 2025
2d12e78
refactor: update all sample tests for revised schema
jacob-a-brown Sep 18, 2025
a962de3
Merge branch 'staging' into jab-observation-sample-field-revisions
jacob-a-brown Sep 19, 2025
2b6637a
refactor: use joinedload to prevent N+1 issues with lazy loading
jacob-a-brown Sep 19, 2025
9dab2fb
refactor: revise observation models for revised schemas
jacob-a-brown Sep 19, 2025
c0d0e4a
test: skip geochronology tests for now
jacob-a-brown Sep 19, 2025
8a5d0c3
refactor: deprecate geothermal for now
jacob-a-brown Sep 19, 2025
d967c5b
refactor: only enable event contacts to relate to a sample
jacob-a-brown Sep 19, 2025
23c4567
WIP: water level transfers
jacob-a-brown Sep 19, 2025
7ebd8b8
refactor: PR 141 feedback
jacob-a-brown Sep 22, 2025
8a518e5
refactor: remove cascade behavior of field event contact association
jacob-a-brown Sep 22, 2025
9cf0efe
fix: ensure correct relationships for FieldEventContactAssociation
jacob-a-brown Sep 22, 2025
61301ef
refactor: update FieldEvent documentation per PR feedback
jacob-a-brown Sep 22, 2025
31df3ec
refactor: PR 141 feedback - update documentation
jacob-a-brown Sep 23, 2025
b4ae588
refactor: address PR 141 feedback
jacob-a-brown Sep 23, 2025
955831e
refactor: infer collecting organization from contact
jacob-a-brown Sep 23, 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
149 changes: 77 additions & 72 deletions api/observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,33 +21,38 @@
from core.dependencies import (
session_dependency,
amp_admin_dependency,
admin_dependency,
amp_viewer_dependency,
viewer_dependency,
)
from db import Observation
from schemas.observation import (
CreateGroundwaterLevelObservation,
GroundwaterLevelObservationResponse,
CreateWaterChemistryObservation,
WaterChemistryObservationResponse,
CreateGeothermalObservation,
GeothermalObservationResponse,
# CreateGeothermalObservation,
# GeothermalObservationResponse,
ObservationResponse,
UpdateGroundwaterLevelObservation,
UpdateWaterChemistryObservation,
UpdateGeothermalObservation,
# UpdateGeothermalObservation,
)
from services.crud_helper import model_deleter, model_adder
from services.query_helper import simple_get_by_id
from services.observation_helper import (
get_observations,
observation_model_patcher,
get_observation_of_an_observation_class_by_id,
get_observation_of_an_activity_type_by_id,
)

router = APIRouter(prefix="/observation", tags=["observation"])

"""
TODO

- add validation that the sample_id exists in the database before creating observation
- add validation that the activity_type of the sample corresponds with the endpoint where the observation is posted/patched
"""


# ============= Post =============================================
@router.post("/groundwater-level", status_code=HTTP_201_CREATED)
Expand Down Expand Up @@ -75,17 +80,17 @@ async def add_water_chemistry_observation(
return model_adder(session, Observation, obs_data, user=user)


@router.post("/geothermal", status_code=HTTP_201_CREATED)
async def add_geothermal_observation(
obs_data: CreateGeothermalObservation,
session: session_dependency,
user: admin_dependency,
) -> GeothermalObservationResponse:
"""
Add a new geothermal observation to the database.
This endpoint is currently a placeholder and does not implement any functionality.
"""
return model_adder(session, Observation, obs_data, user=user)
# @router.post("/geothermal", status_code=HTTP_201_CREATED)
# async def add_geothermal_observation(
# obs_data: CreateGeothermalObservation,
# session: session_dependency,
# user: admin_dependency,
# ) -> GeothermalObservationResponse:
# """
# Add a new geothermal observation to the database.
# This endpoint is currently a placeholder and does not implement any functionality.
# """
# return model_adder(session, Observation, obs_data, user=user)


# PATCH ========================================================================
Expand Down Expand Up @@ -119,18 +124,18 @@ async def update_water_chemistry_observation(
return observation_model_patcher(session, request, observation_id, obs_data, user)


@router.patch("/geothermal/{observation_id}", status_code=HTTP_200_OK)
async def update_geothermal_observation(
observation_id: int,
obs_data: UpdateGeothermalObservation,
session: session_dependency,
user: admin_dependency,
request: Request,
) -> GeothermalObservationResponse:
"""
Update an existing geothermal observation in the database.
"""
return observation_model_patcher(session, request, observation_id, obs_data, user)
# @router.patch("/geothermal/{observation_id}", status_code=HTTP_200_OK)
# async def update_geothermal_observation(
# observation_id: int,
# obs_data: UpdateGeothermalObservation,
# session: session_dependency,
# user: admin_dependency,
# request: Request,
# ) -> GeothermalObservationResponse:
# """
# Update an existing geothermal observation in the database.
# """
# return observation_model_patcher(session, request, observation_id, obs_data, user)


# ============= Get ==============================================
Expand Down Expand Up @@ -177,7 +182,7 @@ async def get_groundwater_level_observation_by_id(
user: amp_viewer_dependency,
observation_id: int,
) -> GroundwaterLevelObservationResponse:
return get_observation_of_an_observation_class_by_id(
return get_observation_of_an_activity_type_by_id(
session=session,
request=request,
observation_id=observation_id,
Expand Down Expand Up @@ -224,54 +229,54 @@ async def get_water_chemistry_observation_by_id(
user: amp_viewer_dependency,
observation_id: int,
) -> WaterChemistryObservationResponse:
return get_observation_of_an_observation_class_by_id(
return get_observation_of_an_activity_type_by_id(
session=session,
request=request,
observation_id=observation_id,
)


@router.get("/geothermal", summary="Get geothermal observations")
async def get_geothermal_observations(
request: Request,
session: session_dependency,
user: viewer_dependency,
thing_id: int | None = None,
sensor_id: int | None = None,
sample_id: int | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
sort: str | None = None,
order: str | None = None,
filter_: str = Query(alias="filter", default=None),
) -> CustomPage[GeothermalObservationResponse]:
"""
Retrieve all geothermal observations from the database.
"""
return get_observations(
request=request,
session=session,
thing_id=thing_id,
sensor_id=sensor_id,
sample_id=sample_id,
start_time=start_time,
end_time=end_time,
sort=sort,
order=order,
filter_=filter_,
)


@router.get("/geothermal/{observation_id}", summary="Get geothermal observation by ID")
async def get_geothermal_observation_by_id(
session: session_dependency,
request: Request,
user: amp_viewer_dependency,
observation_id: int,
) -> GeothermalObservationResponse:
return get_observation_of_an_observation_class_by_id(
session=session, request=request, observation_id=observation_id
)
# @router.get("/geothermal", summary="Get geothermal observations")
# async def get_geothermal_observations(
# request: Request,
# session: session_dependency,
# user: viewer_dependency,
# thing_id: int | None = None,
# sensor_id: int | None = None,
# sample_id: int | None = None,
# start_time: datetime | None = None,
# end_time: datetime | None = None,
# sort: str | None = None,
# order: str | None = None,
# filter_: str = Query(alias="filter", default=None),
# ) -> CustomPage[GeothermalObservationResponse]:
# """
# Retrieve all geothermal observations from the database.
# """
# return get_observations(
# request=request,
# session=session,
# thing_id=thing_id,
# sensor_id=sensor_id,
# sample_id=sample_id,
# start_time=start_time,
# end_time=end_time,
# sort=sort,
# order=order,
# filter_=filter_,
# )


# @router.get("/geothermal/{observation_id}", summary="Get geothermal observation by ID")
# async def get_geothermal_observation_by_id(
# session: session_dependency,
# request: Request,
# user: amp_viewer_dependency,
# observation_id: int,
# ) -> GeothermalObservationResponse:
# return get_observation_of_an_activity_type_by_id(
# session=session, request=request, observation_id=observation_id
# )


@router.get("", summary="Get all observations")
Expand Down
49 changes: 23 additions & 26 deletions api/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,23 @@
from db.sample import Sample
from schemas import ResourceNotFoundResponse
from schemas.sample import SampleResponse, CreateSample, UpdateSample
from services.query_helper import paginated_all_getter, simple_get_by_id
from services.query_helper import simple_get_by_id
from services.crud_helper import model_patcher, model_deleter, model_adder
from services.exceptions_helper import PydanticStyleException
from services.sample_helper import get_db_samples

router = APIRouter(
prefix="/sample",
tags=["sample"],
)


# TODO: add the following database validation handlers
# invalid sample_id
# invalid lexicon terms
# sample_date of the Sample model cannot be before the event_date of the FieldEvent model


def database_error_handler(
payload: CreateSample | UpdateSample, error: IntegrityError | ProgrammingError
) -> None:
Expand All @@ -47,23 +54,23 @@ def database_error_handler(
error_message = error.orig.args[0]["M"]
if (
error_message
== 'duplicate key value violates unique constraint "sample_field_sample_id_key"'
== 'duplicate key value violates unique constraint "sample_sample_name_key"'
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.

I worry about the fragility of this. If the error text ever changes even slightly this breaks. No action needed now but let's consider ways to improve the database error handler

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I see what you mean - it's another place where we need to keep track of names. Instead of having separate if statements for each potential error, we can keep track of all constraints - most often foreign key and unique - and then catch those errors if they happen and return appropriate messages by decoding the error message.

):
detail = {
"loc": ["body", "field_sample_id"],
"msg": f"Sample with field_sample_id {payload.field_sample_id} already exists.",
"loc": ["body", "sample_name"],
"msg": f"Sample with sample_name {payload.sample_name} already exists.",
"type": "value_error",
"input": {"field_sample_id": payload.field_sample_id},
"input": {"sample_name": payload.sample_name},
}
elif (
error_message
== 'insert or update on table "sample" violates foreign key constraint "sample_thing_id_fkey"'
== 'insert or update on table "sample" violates foreign key constraint "sample_field_activity_id_fkey"'
):
detail = {
"loc": ["body", "thing_id"],
"msg": f"Thing with ID {payload.thing_id} does not exist.",
"loc": ["body", "field_activity_id"],
"msg": f"FieldActivity with ID {payload.field_activity_id} does not exist.",
"type": "value_error",
"input": {"thing_id": payload.thing_id},
"input": {"field_activity_id": payload.field_activity_id},
}

raise PydanticStyleException(status_code=HTTP_409_CONFLICT, detail=[detail])
Expand All @@ -78,6 +85,8 @@ async def add_sample(
Endpoint to add a sample.
"""
try:
# since this is only one instance N+1 is not a concern for
# FieldActivity, FieldEvent, and Thing
return model_adder(session, Sample, sample_data, user=user)
except (IntegrityError, ProgrammingError) as e:
database_error_handler(sample_data, e)
Expand All @@ -94,20 +103,9 @@ async def update_sample(
"""
Endpoint to update a sample.
"""

"""
Development notes:

What do we do if the field is nullable and the schema defaults to None?
If that occurs, then we update the field to None, which may not have
been the intension of the user. We could set some string to indicate
DO NOT UPDATE. Perhaps coordination between the front and backends?


This is handled by the `model_patcher` function, which excludes unset fields from
the update.
"""
try:
# since this is only one instance N+1 is not a concern for
# FieldActivity, FieldEvent, and Thing
return model_patcher(session, Sample, sample_id, sample_data, user=user)
except (IntegrityError, ProgrammingError) as e:
database_error_handler(sample_data, e)
Expand All @@ -125,10 +123,7 @@ async def get_samples(
"""
Endpoint to retrieve samples.
"""

return paginated_all_getter(
session, Sample, sort=sort, order=order, filter_=filter_
)
return get_db_samples(session, sort=sort, order=order, filter_=filter_)


@router.get("/{sample_id}", summary="Get Sample by ID")
Expand All @@ -138,6 +133,8 @@ async def get_sample_by_id(
"""
Endpoint to retrieve a sample by its ID.
"""
# since this is only one instance N+1 is not a concern
# FieldActivity, FieldEvent, and Thing
return simple_get_by_id(session, Sample, sample_id)


Expand Down
Loading
Loading