diff --git a/api/observation.py b/api/observation.py index 27da81974..74719912a 100644 --- a/api/observation.py +++ b/api/observation.py @@ -21,9 +21,7 @@ from core.dependencies import ( session_dependency, amp_admin_dependency, - admin_dependency, amp_viewer_dependency, - viewer_dependency, ) from db import Observation from schemas.observation import ( @@ -31,23 +29,30 @@ 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) @@ -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 ======================================================================== @@ -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 ============================================== @@ -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, @@ -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") diff --git a/api/sample.py b/api/sample.py index a16b69d4e..f26b9c6db 100644 --- a/api/sample.py +++ b/api/sample.py @@ -28,9 +28,10 @@ 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", @@ -38,6 +39,12 @@ ) +# 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: @@ -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"' ): 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]) @@ -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) @@ -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) @@ -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") @@ -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) diff --git a/core/lexicon.json b/core/lexicon.json index 1cd498752..41826dcb4 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -1,7 +1,11 @@ [ + {"categories": [{"name": "qc_type", "description": null}], "term": "Normal", "definition": "The primary environmental sample collected from the well, spring, or soil boring."}, + {"categories": [{"name": "qc_type", "description": null}], "term": "Duplicate", "definition": "A second, independent sample collected at the same location, at the same time, and in the same manner as the normal sample. This sample is sent to the primary laboratory."}, + {"categories": [{"name": "qc_type", "description": null}], "term": "Split", "definition": "A subsample of a primary environmental sample that is sent to a separate, independent laboratory for analysis."}, + {"categories": [{"name": "qc_type", "description": null}], "term": "Field Blank", "definition": "A sample of certified pure water that is taken to the field, opened, and processed through the same sampling procedure as a normal sample (e.g., poured into a sample bottle)."}, + {"categories": [{"name": "qc_type", "description": null}], "term": "Trip Blank", "definition": "A sample of certified pure water that is prepared in the lab, taken to the field, and brought back to the lab without ever being opened."}, + {"categories": [{"name": "qc_type", "description": null}], "term": "Equipment Blank", "definition": "A sample of certified pure water that is run through the sampling equipment (like a pump and tubing) before the normal sample is collected."}, - {"categories": [{"name": "qc_sample", "description": null}], "term": "original", "definition": ""}, - {"categories": [{"name": "qc_sample", "description": null}], "term": "duplicate", "definition": ""}, {"categories" : [{"name": "vertical_datum", "description": null}], "term": "NAVD88", "definition": "North American Vertical Datum of 1988"}, {"categories" : [{"name": "vertical_datum", "description": null}], "term": "NGVD29", "definition": "National Geodetic Vertical Datum of 1929"}, @@ -23,19 +27,12 @@ {"categories": [{"name": "elevation_method", "description": null}], "term": "Survey-grade Global Navigation Satellite Sys, Lvl1", "definition": "Survey-grade Global Navigation Satellite Sys, Lvl1"}, {"categories": [{"name": "elevation_method", "description": null}], "term": "USGS National Elevation Dataset (NED)", "definition": "USGS National Elevation Dataset (NED)"}, {"categories": [{"name": "elevation_method", "description": null}, - {"name": "collection_method", "description": null}, + {"name": "sample_method", "description": null}, {"name": "coordinate_method", "description": null}, {"name": "current_use", "description": null}, {"name": "status", "description": null}, {"name": "organization", "description": null}], "term": "Unknown", "definition": "Unknown"}, - {"categories": [{"name": "collection_method", "description": null}], "term": "bailer", "definition": "bailer"}, - {"categories": [{"name": "collection_method", "description": null}], "term": "faucet at well head", "definition": "faucet at well head"}, - {"categories": [{"name": "collection_method", "description": null}], "term": "faucet or outlet at house", "definition": "faucet or outlet at house"}, - {"categories": [{"name": "collection_method", "description": null}], "term": "grab sample", "definition": "grab sample"}, - {"categories": [{"name": "collection_method", "description": null}], "term": "pump", "definition": "pump"}, - {"categories": [{"name": "collection_method", "description": null}], "term": "thief sampler", "definition": "thief sampler"}, - {"categories": [{"name": "construction_method", "description": null}], "term": "Air-rotary", "definition": "Air-rotary"}, {"categories": [{"name": "construction_method", "description": null}], "term": "Bored or augered", "definition": "Bored or augered"}, {"categories": [{"name": "construction_method", "description": null}], "term": "Cable-tool", "definition": "Cable-tool"}, @@ -118,123 +115,123 @@ {"categories": [{"name": "unit", "description": null}], "term": "deg second", "definition": "degree second"}, {"categories": [{"name": "unit", "description": null}], "term": "deg minute", "definition": "degree minute"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "groundwater level:groundwater level", "definition": "groundwater level measurement" }, - {"categories": [{"name": "observed_property", "description": null}], "term": "geothermal:temperature", "definition": "Temperature measurement"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:pH", "definition": "pH"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Alkalinity, Total", "definition": "Alkalinity, Total"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Alkalinity as CaCO3", "definition": "Alkalinity as CaCO3"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Alkalinity as OH-", "definition": "Alkalinity as OH-"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Calcium", "definition": "Calcium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Calcium, total, unfiltered", "definition": "Calcium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Chloride", "definition": "Chloride"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Carbonate", "definition": "Carbonate"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Conductivity, laboratory", "definition": "Conductivity, laboratory"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Bicarbonate", "definition": "Bicarbonate"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Hardness (CaCO3)", "definition": "Hardness (CaCO3)"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Ion Balance", "definition": "Ion Balance"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Potassium", "definition": "Potassium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Potassium, total, unfiltered", "definition": "Potassium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Magnesium", "definition": "Magnesium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Magnesium, total, unfiltered", "definition": "Magnesium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Sodium", "definition": "Sodium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Sodium, total, unfiltered", "definition": "Sodium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Sodium and Potassium combined", "definition": "Sodium and Potassium combined"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Sulfate", "definition": "Sulfate"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Total Anions", "definition": "Total Anions"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Total Cations", "definition": "Total Cations"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Total Dissolved Solids", "definition": "Total Dissolved Solids"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Tritium", "definition": "Tritium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Age of Water using dissolved gases", "definition": "Age of Water using dissolved gases"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Silver", "definition": "Silver"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Silver, total, unfiltered", "definition": "Silver, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Aluminum", "definition": "Aluminum"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Aluminum, total, unfiltered", "definition": "Aluminum, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Arsenic", "definition": "Arsenic"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Arsenic, total, unfiltered", "definition": "Arsenic, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Boron", "definition": "Boron"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Boron, total, unfiltered", "definition": "Boron, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Barium", "definition": "Barium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Barium, total, unfiltered", "definition": "Barium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Beryllium", "definition": "Beryllium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Beryllium, total, unfiltered", "definition": "Beryllium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Bromide", "definition": "Bromide"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:13C:12C ratio", "definition": "13C:12C ratio"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:14C content, pmc", "definition": "14C content, pmc"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Uncorrected C14 age", "definition": "Uncorrected C14 age"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Cadmium", "definition": "Cadmium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Cadmium, total, unfiltered", "definition": "Cadmium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Chlorofluorocarbon-11 avg age", "definition": "Chlorofluorocarbon-11 avg age"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Chlorofluorocarbon-113 avg age", "definition": "Chlorofluorocarbon-113 avg age"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Chlorofluorocarbon-113/12 avg RATIO age", "definition": "Chlorofluorocarbon-113/12 avg RATIO age"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Chlorofluorocarbon-12 avg age", "definition": "Chlorofluorocarbon-12 avg age"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Cobalt", "definition": "Cobalt"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Cobalt, total, unfiltered", "definition": "Cobalt, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Chromium", "definition": "Chromium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Chromium, total, unfiltered", "definition": "Chromium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Copper", "definition": "Copper"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Copper, total, unfiltered", "definition": "Copper, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:delta O18 sulfate", "definition": "delta O18 sulfate"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Sulfate 34 isotope ratio", "definition": "Sulfate 34 isotope ratio"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Fluoride", "definition": "Fluoride"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Iron", "definition": "Iron"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Iron, total, unfiltered", "definition": "Iron, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Deuterium:Hydrogen ratio", "definition": "Deuterium:Hydrogen ratio"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Mercury", "definition": "Mercury"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Mercury, total, unfiltered", "definition": "Mercury, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Lithium", "definition": "Lithium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Lithium, total, unfiltered", "definition": "Lithium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Manganese", "definition": "Manganese"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Manganese, total, unfiltered", "definition": "Manganese, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Molybdenum", "definition": "Molybdenum"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Molybdenum, total, unfiltered", "definition": "Molybdenum, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Nickel", "definition": "Nickel"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Nickel, total, unfiltered", "definition": "Nickel, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Nitrite (as NO2)", "definition": "Nitrite (as NO2)"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Nitrite (as N)", "definition": "Nitrite (as N)"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Nitrate (as NO3)", "definition": "Nitrate (as NO3)"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Nitrate (as N)", "definition": "Nitrate (as N)"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:18O:16O ratio", "definition": "18O:16O ratio"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Lead", "definition": "Lead"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Lead, total, unfiltered", "definition": "Lead, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Phosphate", "definition": "Phosphate"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Antimony", "definition": "Antimony"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Antimony, total, unfiltered", "definition": "Antimony, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Selenium", "definition": "Selenium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Selenium, total, unfiltered", "definition": "Selenium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Sulfur hexafluoride", "definition": "Sulfur hexafluoride"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Silicon", "definition": "Silicon"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Silicon, total, unfiltered", "definition": "Silicon, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Silica", "definition": "Silica"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Tin", "definition": "Tin"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Tin, total, unfiltered", "definition": "Tin, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Strontium", "definition": "Strontium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Strontium, total, unfiltered", "definition": "Strontium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Strontium 87:86 ratio", "definition": "Strontium 87:86 ratio"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Thorium", "definition": "Thorium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Thorium, total, unfiltered", "definition": "Thorium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Titanium", "definition": "Titanium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Titanium, total, unfiltered", "definition": "Titanium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Thallium", "definition": "Thallium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Thallium, total, unfiltered", "definition": "Thallium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Uranium (total, by ICP-MS)", "definition": "Uranium (total, by ICP-MS)"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Uranium, total, unfiltered", "definition": "Uranium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Vanadium", "definition": "Vanadium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Vanadium, total, unfiltered", "definition": "Vanadium, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Zinc", "definition": "Zinc"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Zinc, total, unfiltered", "definition": "Zinc, total, unfiltered"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Corrected C14 in years", "definition": "Corrected C14 in years"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Arsenite (arsenic species)", "definition": "Arsenite (arsenic species)"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Arsenate (arsenic species)", "definition": "Arsenate (arsenic species)"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Cyanide", "definition": "Cyanide"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Estimated recharge temperature", "definition": "Estimated recharge temperature"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Hydrogen sulfide", "definition": "Hydrogen sulfide"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Ammonia", "definition": "Ammonia"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Ammonium", "definition": "Ammonium"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Total nitrogen", "definition": "Total nitrogen"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Total Kjeldahl nitrogen", "definition": "Total Kjeldahl nitrogen"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Dissolved organic carbon", "definition": "Dissolved organic carbon"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Total organic carbon", "definition": "Total organic carbon"}, - {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:delta C13 of dissolved inorganic carbon", "definition": "delta C13 of dissolved inorganic carbon"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "groundwater level", "definition": "groundwater level measurement" }, + {"categories": [{"name": "observed_property", "description": null}], "term": "temperature", "definition": "Temperature measurement"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "pH", "definition": "pH"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Alkalinity, Total", "definition": "Alkalinity, Total"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Alkalinity as CaCO3", "definition": "Alkalinity as CaCO3"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Alkalinity as OH-", "definition": "Alkalinity as OH-"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Calcium", "definition": "Calcium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Calcium, total, unfiltered", "definition": "Calcium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Chloride", "definition": "Chloride"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Carbonate", "definition": "Carbonate"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Conductivity, laboratory", "definition": "Conductivity, laboratory"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Bicarbonate", "definition": "Bicarbonate"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Hardness (CaCO3)", "definition": "Hardness (CaCO3)"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Ion Balance", "definition": "Ion Balance"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Potassium", "definition": "Potassium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Potassium, total, unfiltered", "definition": "Potassium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Magnesium", "definition": "Magnesium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Magnesium, total, unfiltered", "definition": "Magnesium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Sodium", "definition": "Sodium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Sodium, total, unfiltered", "definition": "Sodium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Sodium and Potassium combined", "definition": "Sodium and Potassium combined"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Sulfate", "definition": "Sulfate"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Total Anions", "definition": "Total Anions"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Total Cations", "definition": "Total Cations"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Total Dissolved Solids", "definition": "Total Dissolved Solids"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Tritium", "definition": "Tritium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Age of Water using dissolved gases", "definition": "Age of Water using dissolved gases"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Silver", "definition": "Silver"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Silver, total, unfiltered", "definition": "Silver, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Aluminum", "definition": "Aluminum"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Aluminum, total, unfiltered", "definition": "Aluminum, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Arsenic", "definition": "Arsenic"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Arsenic, total, unfiltered", "definition": "Arsenic, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Boron", "definition": "Boron"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Boron, total, unfiltered", "definition": "Boron, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Barium", "definition": "Barium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Barium, total, unfiltered", "definition": "Barium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Beryllium", "definition": "Beryllium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Beryllium, total, unfiltered", "definition": "Beryllium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Bromide", "definition": "Bromide"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "13C:12C ratio", "definition": "13C:12C ratio"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "14C content, pmc", "definition": "14C content, pmc"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Uncorrected C14 age", "definition": "Uncorrected C14 age"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Cadmium", "definition": "Cadmium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Cadmium, total, unfiltered", "definition": "Cadmium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Chlorofluorocarbon-11 avg age", "definition": "Chlorofluorocarbon-11 avg age"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Chlorofluorocarbon-113 avg age", "definition": "Chlorofluorocarbon-113 avg age"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Chlorofluorocarbon-113/12 avg RATIO age", "definition": "Chlorofluorocarbon-113/12 avg RATIO age"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Chlorofluorocarbon-12 avg age", "definition": "Chlorofluorocarbon-12 avg age"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Cobalt", "definition": "Cobalt"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Cobalt, total, unfiltered", "definition": "Cobalt, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Chromium", "definition": "Chromium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Chromium, total, unfiltered", "definition": "Chromium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Copper", "definition": "Copper"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Copper, total, unfiltered", "definition": "Copper, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "delta O18 sulfate", "definition": "delta O18 sulfate"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Sulfate 34 isotope ratio", "definition": "Sulfate 34 isotope ratio"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Fluoride", "definition": "Fluoride"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Iron", "definition": "Iron"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Iron, total, unfiltered", "definition": "Iron, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Deuterium:Hydrogen ratio", "definition": "Deuterium:Hydrogen ratio"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Mercury", "definition": "Mercury"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Mercury, total, unfiltered", "definition": "Mercury, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Lithium", "definition": "Lithium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Lithium, total, unfiltered", "definition": "Lithium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Manganese", "definition": "Manganese"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Manganese, total, unfiltered", "definition": "Manganese, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Molybdenum", "definition": "Molybdenum"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Molybdenum, total, unfiltered", "definition": "Molybdenum, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Nickel", "definition": "Nickel"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Nickel, total, unfiltered", "definition": "Nickel, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Nitrite (as NO2)", "definition": "Nitrite (as NO2)"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Nitrite (as N)", "definition": "Nitrite (as N)"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Nitrate (as NO3)", "definition": "Nitrate (as NO3)"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Nitrate (as N)", "definition": "Nitrate (as N)"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "18O:16O ratio", "definition": "18O:16O ratio"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Lead", "definition": "Lead"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Lead, total, unfiltered", "definition": "Lead, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Phosphate", "definition": "Phosphate"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Antimony", "definition": "Antimony"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Antimony, total, unfiltered", "definition": "Antimony, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Selenium", "definition": "Selenium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Selenium, total, unfiltered", "definition": "Selenium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Sulfur hexafluoride", "definition": "Sulfur hexafluoride"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Silicon", "definition": "Silicon"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Silicon, total, unfiltered", "definition": "Silicon, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Silica", "definition": "Silica"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Tin", "definition": "Tin"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Tin, total, unfiltered", "definition": "Tin, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Strontium", "definition": "Strontium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Strontium, total, unfiltered", "definition": "Strontium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Strontium 87:86 ratio", "definition": "Strontium 87:86 ratio"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Thorium", "definition": "Thorium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Thorium, total, unfiltered", "definition": "Thorium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Titanium", "definition": "Titanium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Titanium, total, unfiltered", "definition": "Titanium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Thallium", "definition": "Thallium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Thallium, total, unfiltered", "definition": "Thallium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Uranium (total, by ICP-MS)", "definition": "Uranium (total, by ICP-MS)"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Uranium, total, unfiltered", "definition": "Uranium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Vanadium", "definition": "Vanadium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Vanadium, total, unfiltered", "definition": "Vanadium, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Zinc", "definition": "Zinc"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Zinc, total, unfiltered", "definition": "Zinc, total, unfiltered"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Corrected C14 in years", "definition": "Corrected C14 in years"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Arsenite (arsenic species)", "definition": "Arsenite (arsenic species)"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Arsenate (arsenic species)", "definition": "Arsenate (arsenic species)"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Cyanide", "definition": "Cyanide"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Estimated recharge temperature", "definition": "Estimated recharge temperature"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Hydrogen sulfide", "definition": "Hydrogen sulfide"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Ammonia", "definition": "Ammonia"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Ammonium", "definition": "Ammonium"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Total nitrogen", "definition": "Total nitrogen"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Total Kjeldahl nitrogen", "definition": "Total Kjeldahl nitrogen"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Dissolved organic carbon", "definition": "Dissolved organic carbon"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "Total organic carbon", "definition": "Total organic carbon"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "delta C13 of dissolved inorganic carbon", "definition": "delta C13 of dissolved inorganic carbon"}, {"categories": [{"name": "release_status", "description": null}], "term": "draft", "definition": "draft version"}, @@ -251,7 +248,30 @@ {"categories": [{"name": "relation", "description": null}], "term": "OSEPOD", "definition": "NM OSE 'Point of Diversion' ID"}, {"categories": [{"name": "relation", "description": null}], "term": "PLSS", "definition": "Public Land Survey System ID"}, - {"categories": [{"name": "sample_type", "description": null}], "term": "groundwater", "definition": "groundwater sample from a well"}, + {"categories": [{"name": "activity_type", "description": null}], "term": "groundwater level", "definition": "groundwater level"}, + {"categories": [{"name": "activity_type", "description": null}], "term": "water chemistry", "definition": "water chemistry"}, + + + {"categories": [{"name": "field_contact_role", "description": null}], "term": "Lead", "definition": "the leader of the field event"}, + {"categories": [{"name": "field_contact_role", "description": null}], "term": "Participant", "definition": "a person participating in the field event"}, + {"categories": [{"name": "field_contact_role", "description": null}], "term": "Observer", "definition": "a person observing the field event"}, + {"categories": [{"name": "field_contact_role", "description": null}], "term": "Visitor", "definition": "a person visiting the field event"}, + + + {"categories": [{"name": "sample_matrix", "description": null}], "term": "water", "definition": "water"}, + {"categories": [{"name": "sample_matrix", "description": null}], "term": "soil", "definition": "soil"}, + + {"categories": [{"name": "thing_type", "description": null}], "term": "observation well", "definition": "a well used to monitor groundwater levels"}, + {"categories": [{"name": "thing_type", "description": null}], "term": "piezometer", "definition": "a type of observation well that measures pressure head in the aquifer"}, + {"categories": [{"name": "thing_type", "description": null}], "term": "monitoring well", "definition": "a well used to monitor groundwater quality or levels"}, + {"categories": [{"name": "thing_type", "description": null}], "term": "production well", "definition": "a well used to extract groundwater for use"}, + {"categories": [{"name": "thing_type", "description": null}], "term": "injection well", "definition": "a well used to inject water or other fluids into the ground"}, + {"categories": [{"name": "thing_type", "description": null}], "term": "exploration well", "definition": "a well drilled to explore for groundwater or other resources"}, + {"categories": [{"name": "thing_type", "description": null}], "term": "test well", "definition": "a well drilled to test the properties of the aquifer"}, + {"categories": [{"name": "thing_type", "description": null}], "term": "abandoned well", "definition": "a well that is no longer in use and has been properly sealed"}, + {"categories": [{"name": "thing_type", "description": null}], "term": "dry hole", "definition": "a well that did not produce water or other resources"}, + {"categories": [{"name": "thing_type", "description": null}], "term": "artesian well", "definition": "a well that taps a confined aquifer where the water level is above the top of the aquifer"}, + {"categories": [{"name": "thing_type", "description": null}], "term": "dug well", "definition": "a shallow well dug by hand or with machinery, typically lined with stones or bricks"}, {"categories": [{"name": "thing_type", "description": null}], "term": "water well", "definition": "a hole drill into the ground to access groundwater"}, {"categories": [{"name": "thing_type", "description": null}], "term": "spring", "definition": "a natural discharge of groundwater at the surface"}, @@ -288,29 +308,36 @@ {"categories": [{"name": "status", "description": null}], "term": "Destroyed, exists but not usable", "definition": "Destroyed, exists but not usable"}, {"categories": [{"name": "status", "description": null}], "term": "Inactive, exists but not used", "definition": "Inactive, exists but not used"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Airline measurement", "definition": "Airline measurement"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Analog or graphic recorder", "definition": "Analog or graphic recorder"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Calibrated airline measurement", "definition": "Calibrated airline measurement"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Differential GPS; especially applicable to surface expression of ground water", "definition": "Differential GPS; especially applicable to surface expression of ground water"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Estimated", "definition": "Estimated"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Transducer", "definition": "Transducer"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Pressure-gage measurement", "definition": "Pressure-gage measurement"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Calibrated pressure-gage measurement", "definition": "Calibrated pressure-gage measurement"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Interpreted from geophysical logs", "definition": "Interpreted from geophysical logs"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Manometer", "definition": "Manometer"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Non-recording gage", "definition": "Non-recording gage"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Observed (required for F, N, and W water level status)", "definition": "Observed (required for F, N, and W water level status)"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Sonic water level meter (acoustic pulse)", "definition": "Sonic water level meter (acoustic pulse)"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Reported, method not known", "definition": "Reported, method not known"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Steel-tape measurement", "definition": "Steel-tape measurement"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Electric tape measurement (E-probe)", "definition": "Electric tape measurement (E-probe)"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Unknown (for legacy data only; not for new data entry)", "definition": "Unknown (for legacy data only; not for new data entry)"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Calibrated electric tape; accuracy of equipment has been checked", "definition": "Calibrated electric tape; accuracy of equipment has been checked"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Calibrated electric cable", "definition": "Calibrated electric cable"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Uncalibrated electric cable", "definition": "Uncalibrated electric cable"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Continuous acoustic sounder", "definition": "Continuous acoustic sounder"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "Measurement not attempted", "definition": "Measurement not attempted"}, - {"categories": [{"name": "measurement_method", "description": null}], "term": "null placeholder", "definition": "null placeholder"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Airline measurement", "definition": "Airline measurement"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Analog or graphic recorder", "definition": "Analog or graphic recorder"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Calibrated airline measurement", "definition": "Calibrated airline measurement"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Differential GPS; especially applicable to surface expression of ground water", "definition": "Differential GPS; especially applicable to surface expression of ground water"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Estimated", "definition": "Estimated"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Transducer", "definition": "Transducer"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Pressure-gage measurement", "definition": "Pressure-gage measurement"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Calibrated pressure-gage measurement", "definition": "Calibrated pressure-gage measurement"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Interpreted from geophysical logs", "definition": "Interpreted from geophysical logs"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Manometer", "definition": "Manometer"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Non-recording gage", "definition": "Non-recording gage"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Observed (required for F, N, and W water level status)", "definition": "Observed (required for F, N, and W water level status)"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Sonic water level meter (acoustic pulse)", "definition": "Sonic water level meter (acoustic pulse)"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Reported, method not known", "definition": "Reported, method not known"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Steel-tape measurement", "definition": "Steel-tape measurement"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Electric tape measurement (E-probe)", "definition": "Electric tape measurement (E-probe)"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Unknown (for legacy data only; not for new data entry)", "definition": "Unknown (for legacy data only; not for new data entry)"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Calibrated electric tape; accuracy of equipment has been checked", "definition": "Calibrated electric tape; accuracy of equipment has been checked"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Calibrated electric cable", "definition": "Calibrated electric cable"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Uncalibrated electric cable", "definition": "Uncalibrated electric cable"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Continuous acoustic sounder", "definition": "Continuous acoustic sounder"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "Measurement not attempted", "definition": "Measurement not attempted"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "null placeholder", "definition": "null placeholder"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "bailer", "definition": "bailer"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "faucet at well head", "definition": "faucet at well head"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "faucet or outlet at house", "definition": "faucet or outlet at house"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "grab sample", "definition": "grab sample"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "pump", "definition": "pump"}, + {"categories": [{"name": "sample_method", "description": null}], "term": "thief sampler", "definition": "thief sampler"}, + {"categories": [{"name": "organization", "description": null}], "term": "USGS", "definition": "US Geological Survey"}, {"categories": [{"name": "organization", "description": null}], "term": "TWDB", "definition": "Texas Water Development Board"}, @@ -428,9 +455,11 @@ {"categories": [{"name": "casing_material", "description": null}], "term": "Steel", "definition": "Steel"}, {"categories": [{"name": "casing_material", "description": null}], "term": "Concrete", "definition": "Concrete"}, - {"categories": [{"name": "quality_control_status", "description": null}], "term": "Provisional", "definition": "Provisional quality control status"}, - {"categories": [{"name": "quality_control_status", "description": null}], "term": "Approved", "definition": "Approved quality control status"}, - {"categories": [{"name": "quality_control_status", "description": null}], "term": "Rejected", "definition": "Rejected quality control status"}, + {"categories": [{"name": "quality_flag", "description": null}], "term": "Good", "definition": "The measurement was collected and analyzed according to standard procedures and passed all QA/QC checks."}, + {"categories": [{"name": "quality_flag", "description": null}], "term": "Questionable", "definition": "The measurement is suspect due to a known issue during collection or analysis, but it may still be usable."}, + {"categories": [{"name": "quality_flag", "description": null}], "term": "Estimated", "definition": "The value is not a direct measurement but an estimate derived from other data or models."}, + {"categories": [{"name": "quality_flag", "description": null}], "term": "Rejected", "definition": "Rejected"}, + {"categories": [{"name": "drilling_fluid", "description": null}], "term": "mud", "definition": "drilling mud"}, diff --git a/db/__init__.py b/db/__init__.py index 6d71baf61..b75475493 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -24,6 +24,7 @@ from db.contact import * from db.geochronology import * from db.geothermal import * +from db.field import * from db.group import * from db.lexicon import * from db.location import * diff --git a/db/contact.py b/db/contact.py index bbff76178..7a4f79340 100644 --- a/db/contact.py +++ b/db/contact.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== from sqlalchemy import Integer, ForeignKey, String -from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy from sqlalchemy.orm import relationship, Mapped, mapped_column from sqlalchemy_utils import TSVectorType from typing import List @@ -36,7 +36,7 @@ class ThingContactAssociation(Base, AutoBaseMixin): class Contact(Base, AutoBaseMixin, ReleaseMixin): name: Mapped[str] = mapped_column(String(100), nullable=True) - organization: Mapped[str] = mapped_column(String(100), nullable=True) + organization: Mapped[str] = lexicon_term(nullable=True) role: Mapped[str] = lexicon_term(nullable=False) contact_type: Mapped[str] = lexicon_term(nullable=False) nma_pk_owners: Mapped[str] = mapped_column(String(100), nullable=True) @@ -62,14 +62,33 @@ class Contact(Base, AutoBaseMixin, ReleaseMixin): cascade="all, delete-orphan", ) ) - authors = association_proxy("author_associations", "author") + authors: AssociationProxy[list["Author"]] = association_proxy( # noqa: F821 + "author_associations", "author" + ) thing_associations: Mapped[List["ThingContactAssociation"]] = relationship( "ThingContactAssociation", back_populates="contact", cascade="all, delete-orphan", passive_deletes=True, ) - things = association_proxy("thing_associations", "thing") + things: AssociationProxy[list["Thing"]] = association_proxy( # noqa: F821 + "thing_associations", "thing" + ) + + # Proxy to directly access the FieldEvent objects in which this Contact participated. + # fmt: off + field_event_contact_associations: Mapped[list["FieldEventContactAssociation"]] = ( # noqa: F821 + relationship( + "FieldEventContactAssociation", + back_populates="contact", + cascade="all, delete-orphan", + passive_deletes=True, + ) + ) + # fmt: on + field_events: AssociationProxy[list["FieldEvent"]] = ( # noqa: F821 + association_proxy("field_event_contact_associations", "field_event") + ) class Phone(Base, AutoBaseMixin, ReleaseMixin): diff --git a/db/field.py b/db/field.py new file mode 100644 index 000000000..ca7b99372 --- /dev/null +++ b/db/field.py @@ -0,0 +1,146 @@ +from datetime import datetime +from sqlalchemy import DateTime, ForeignKey +from sqlalchemy.orm import mapped_column, relationship, Mapped +from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy + +from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term +from db.contact import Contact + + +class FieldEventContactAssociation(Base, AutoBaseMixin, ReleaseMixin): + """ + This association table is to create a many-to-many relationship between + FieldEvent and Contact. These are participants in the field event. + """ + + # --- Foreign keys --- + field_event_id: Mapped[int] = mapped_column( + ForeignKey("field_event.id", ondelete="CASCADE"), + nullable=False, + comment="Foreign key to the FieldEvent table.", + ) + contact_id: Mapped[str] = mapped_column( + ForeignKey("contact.id", ondelete="CASCADE"), + nullable=False, + comment="Foreign key to the Contact table", + ) + + # TODO: get AMP feedback on the roles + field_contact_role: Mapped[str] = lexicon_term( + nullable=False, comment="Role of the contact in the field event" + ) + + # --- Relationships --- + field_event: Mapped["FieldEvent"] = relationship( + "FieldEvent", back_populates="field_event_contact_associations" + ) + contact: Mapped["Contact"] = relationship( # noqa: F821 + "Contact", back_populates="field_event_contact_associations" + ) + + # map associated contacts to samples to restrict the people who could have + # taken a sample to those present at the field event + samples: Mapped[list["Sample"]] = relationship( # noqa: F821 + "Sample", + back_populates="field_event_contact", + passive_deletes=True, + ) + + +class FieldEvent(Base, AutoBaseMixin, ReleaseMixin): + """ + This table serves as the master log for all field visits. Each + record in this table represents a single, continuous collection event at a + specific Thing (e.g., a well) by a specific person on a specific date. + + This table's purpose is to store event-level metadata that is true for the + entire visit, such as the date, time, and the person responsible. It acts as + the parent container for all activities performed and all samples collected + during that single visit. + + Its purpose is to store the "where and when" of the event. + Information about who participated is managed in the + FieldEventContactAssociation table. Information about the "what" of the + event is managed in the FieldActivity and Sample tables. + """ + + # Foreign Keys + thing_id: Mapped[int] = mapped_column( + ForeignKey("thing.id", ondelete="CASCADE"), + nullable=False, + comment="Foreign key to the Thing (e.g., sampling location) table.", + ) + + # Columns + event_date: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + comment="Date and time of the field event.", + ) + notes: Mapped[str] = mapped_column( + nullable=True, + comment="Notes or comments about the field event.", + ) + # --- Relationships --- + thing: Mapped["Thing"] = relationship(back_populates="field_events") # noqa: F821 + field_activities: Mapped[list["FieldActivity"]] = relationship( + "FieldActivity", back_populates="field_event" + ) + field_event_contact_associations: Mapped[list["FieldEventContactAssociation"]] = ( + relationship( + "FieldEventContactAssociation", + back_populates="field_event", + cascade="all, delete-orphan", + passive_deletes=True, + ) + ) + + # --- Association Proxies --- + # Proxy to directly access the Contact objects participating in this event. + contacts: AssociationProxy[list["Contact"]] = association_proxy( # noqa: F821 + "field_event_contact_associations", "contact" + ) + + +class FieldActivity(Base, AutoBaseMixin, ReleaseMixin): + """ + This table serves as a log of the specific, distinct tasks + performed during a single `FieldEvent`. Its purpose is to correctly model + the one-to-many relationship where a single field visit can have multiple + objectives (e.g., collecting a water level and also collecting a water + sample for the lab). + + Each record in this table represents one type of work, such as + 'groundwater level', 'geochemical', or 'water chemistry'. By linking a + Sample record to a specific FieldActivity, the schema creates a clear and + unambiguous chain of custody, ensuring that every observation can be traced + back to the precise task that generated it. + """ + + # Foreign Keys + field_event_id: Mapped[int] = mapped_column( + ForeignKey("field_event.id", ondelete="CASCADE"), + nullable=False, + comment="Foreign key to the FieldEvent table.", + ) + + # Columns + activity_type: Mapped[str] = lexicon_term( + nullable=False, + comment="The type of activity performed during the field event (e.g., 'groundwater level', 'water chemistry', 'geothermal').", + ) + notes: Mapped[str] = mapped_column( + nullable=True, + comment="Notes or comments about the field activity.", + ) + + # Relationships + field_event: Mapped["FieldEvent"] = relationship( + "FieldEvent", back_populates="field_activities" + ) + samples: Mapped[list["Sample"]] = relationship( # noqa: F821 + "Sample", + back_populates="field_activity", + cascade="all, delete-orphan", + passive_deletes=True, + ) diff --git a/db/lexicon.py b/db/lexicon.py index 4c8f68f63..ba03cec5c 100644 --- a/db/lexicon.py +++ b/db/lexicon.py @@ -15,7 +15,7 @@ # =============================================================================== from sqlalchemy import String, ForeignKey, Integer from sqlalchemy.orm import mapped_column, relationship -from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy from db.base import AutoBaseMixin, Base, lexicon_term @@ -35,7 +35,9 @@ class LexiconTerm(Base, AutoBaseMixin): back_populates="term", cascade="all, delete-orphan", ) - categories = association_proxy("category_associations", "category") + categories: AssociationProxy[list["LexiconCategory"]] = association_proxy( + "category_associations", "category" + ) def __repr__(self): return f"" diff --git a/db/observation.py b/db/observation.py index 1b49196ef..720fcda0f 100644 --- a/db/observation.py +++ b/db/observation.py @@ -13,13 +13,12 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +from datetime import datetime from sqlalchemy import ( ForeignKey, - Integer, - Float, DateTime, ) -from sqlalchemy.orm import mapped_column, relationship +from sqlalchemy.orm import mapped_column, relationship, Mapped from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term @@ -27,47 +26,49 @@ class Observation(Base, AutoBaseMixin, ReleaseMixin): __versioned__ = {} - sample_id = mapped_column( - Integer, + # NM_Aquifer fields for audits + nma_pk_waterlevels: Mapped[str] = mapped_column(nullable=True) + + sample_id: Mapped[int] = mapped_column( ForeignKey("sample.id", ondelete="CASCADE"), nullable=False, ) - sensor_id = mapped_column( - Integer, + sensor_id: Mapped[int] = mapped_column( ForeignKey("sensor.id", ondelete="CASCADE"), - nullable=False, + nullable=True, ) - observation_datetime = mapped_column( + observation_datetime: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, doc="Timestamp of the observation" ) - observed_property = lexicon_term() - value = mapped_column( - Float, + observed_property: Mapped[str] = lexicon_term(nullable=False) + value: Mapped[float] = mapped_column( nullable=True, ) - unit = lexicon_term() + unit: Mapped[str] = lexicon_term(nullable=False) # groundwater - measuring_point_height = mapped_column( - Float, + measuring_point_height: Mapped[float] = mapped_column( nullable=True, doc="Height of the measuring point above the ground surface in ft", info={"unit": "ft"}, ) - level_status = lexicon_term() + level_status: Mapped[str] = lexicon_term(nullable=True) # geothermal - observation_depth = mapped_column( - Float, + observation_depth: Mapped[float] = mapped_column( nullable=True, info={"unit": "feet"}, doc="Depth of the geothermal observation in feet", ) - sensor = relationship("Sensor") - sample = relationship("Sample") + sensor: Mapped["Sensor"] = relationship( # noqa: F821 + "Sensor", back_populates="observations", passive_deletes=True + ) # noqa: F821 + sample: Mapped["Sample"] = relationship( # noqa: F821 + "Sample", back_populates="observations", passive_deletes=True + ) # noqa: F821 # ============= EOF ============================================= diff --git a/db/publication.py b/db/publication.py index 6b04691b3..2eec8213a 100644 --- a/db/publication.py +++ b/db/publication.py @@ -17,9 +17,9 @@ from db import lexicon_term from db.base import AutoBaseMixin, Base, AuditMixin -from sqlalchemy import Column, Integer, String, Text, Date, ForeignKey, Table, DateTime +from sqlalchemy import Column, Integer, String, Text, ForeignKey from sqlalchemy.orm import relationship -from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy class Publication(Base, AutoBaseMixin): @@ -45,7 +45,9 @@ class Publication(Base, AutoBaseMixin): back_populates="publication", cascade="all, delete-orphan", ) - authors = association_proxy("author_associations", "author") + authors: AssociationProxy[list["Author"]] = association_proxy( + "author_associations", "author" + ) search_vector = Column(TSVectorType("title", "abstract", "doi", "publisher", "url")) @@ -65,14 +67,18 @@ class Author(Base, AutoBaseMixin): back_populates="author", cascade="all, delete-orphan", ) - publications = association_proxy("publication_associations", "publication") + publications: AssociationProxy[list["Publication"]] = association_proxy( + "publication_associations", "publication" + ) contact_associations = relationship( "AuthorContactAssociation", back_populates="author", cascade="all, delete-orphan", ) - contacts = association_proxy("author_associations", "contact") + contacts: AssociationProxy[list["Contact"]] = association_proxy( # noqa: F821 + "author_associations", "contact" + ) search_vector = Column(TSVectorType("name", "affiliation")) diff --git a/db/sample.py b/db/sample.py index 1e6c749b8..0c3c8d3ca 100644 --- a/db/sample.py +++ b/db/sample.py @@ -13,117 +13,96 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from sqlalchemy import DateTime, ForeignKey, UniqueConstraint, Float +from sqlalchemy import DateTime, ForeignKey from sqlalchemy.orm import mapped_column, relationship, Mapped +from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy # import models from classes that are defined in separate files -from db.base import Base, AutoBaseMixin, ReleaseMixin -from db.thing import Thing -from db.sensor import Sensor - -from typing import Optional +from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term import datetime class Sample(Base, AutoBaseMixin, ReleaseMixin): """ - Defines the Sample table, which stores data for individual - sampling events. + This table serves as an inventory of all the individual items collected or + generated by a FieldActivity. Each record represents a single, discrete + sample, which can be either a physical object (like a bottle of water or a + jar of soil) or a "virtual" object (representing the act of taking a + measurement, like a water level). + + Its purpose is to store the specific properties of each discrete sample, + such as its unique sample name (the label on the bottle), its matrix, and + any relevant physical measurements. """ # __table_name__ is inherited from AutoBaseMixin. - # --- Column Definitions --- - # Foreign Keys - thing_id: Mapped[int] = mapped_column( - ForeignKey("thing.id", ondelete="CASCADE"), - nullable=False, - comment="Foreign key to the Thing (e.g., sampling location) table.", + # --- Foreign Key Definitions --- + field_activity_id: Mapped[int] = mapped_column( + ForeignKey("field_activity.id"), nullable=False ) - sensor_id: Mapped[Optional[int]] = mapped_column( - ForeignKey("sensor.id"), - comment="Foreign key for the specific equipment used.", + + field_event_contact_id: Mapped[str] = mapped_column( + ForeignKey("field_event_contact_association.id"), nullable=True ) - # Sample Attributes + # --- Columns --- sample_date: Mapped[datetime.datetime] = mapped_column( DateTime(timezone=True), nullable=False, comment="Date and time of sample collection.", ) - # REFACTOR TODO: update with enum/restricted values - sample_matrix: Mapped[Optional[str]] = mapped_column( - comment="The material of the sample (e.g., 'gw', 'soil')." - ) - # REFACTOR TODO: update with enum/restricted values - sample_method: Mapped[Optional[str]] = mapped_column( - comment="Method used to collect the sample." + sample_name: Mapped[str] = mapped_column( + nullable=False, + unique=True, + comment="The unique identifier physically written on the sample container or used in field logs. Use for tracking/auditing purposes.", ) - field_sample_id: Mapped[str] = mapped_column( - unique=True, nullable=False, comment="User-defined ID for field tracking." + sample_matrix: Mapped[str] = lexicon_term( + nullable=False, comment="The material of the sample (e.g., 'gw', 'soil')." ) - # REFACTOR TODO: update with enum/restricted values - sampler_name: Mapped[Optional[str]] = mapped_column( - nullable=False, comment="Name of the person who collected the sample." + sample_method: Mapped[str] = lexicon_term( + comment="Method used to collect the sample.", nullable=False ) - # REFACTOR TODO: update with enum/restricted values - qc_sample: Mapped[str] = mapped_column( - default="Original", + qc_type: Mapped[str] = mapped_column( + default="Normal", nullable=False, - comment="Quality control sample type (e.g., 'Original', 'field dupe').", - ) - sample_top: Mapped[Optional[float]] = mapped_column( - Float, comment="Top depth of a discrete sample interval." + comment="Quality control sample type (e.g., 'Normal', 'Split', 'Field duplicate').", ) - sample_bottom: Mapped[Optional[float]] = mapped_column( - Float, comment="Bottom depth of a discrete sample interval." + depth_top: Mapped[float] = mapped_column( + nullable=True, comment="Top depth of a discrete sample interval in ft." ) - duplicate_sample_number: Mapped[int] = mapped_column( - default=0, - comment="Identifier for duplicate samples (0 = original sample, not a duplicate, 1 = dup no.1, 2 = dup no.2, etc.).", - ) - sample_type: Mapped[str] = mapped_column( - comment="The type of sample (e.g., 'geochemical', 'geothermal', 'groundwater')." + depth_bottom: Mapped[float] = mapped_column( + nullable=True, comment="Bottom depth of a discrete sample interval in ft." ) + notes: Mapped[str] = mapped_column(nullable=True) # --- Relationship Definitions --- - thing: Mapped["Thing"] = relationship(back_populates="samples") - sensor: Mapped[Optional["Sensor"]] = relationship(back_populates="sample") - - # --- Table-level Arguments (e.g., Constraints) --- - # Unique samples should be based on the station_id, sample_date, sample_matrix, - # sample_top, sample_bottom, duplicate_sample, field_sample_id, and qc_sample fields. - __table_args__ = ( - UniqueConstraint( - "thing_id", - "sample_date", - "sample_matrix", - "sample_top", - "sample_bottom", - "duplicate_sample_number", - "field_sample_id", - "qc_sample", - name="uix_sample_uniqueness", - ), + field_activity: Mapped["FieldActivity"] = relationship( # noqa: F821 + back_populates="samples" ) + # fmt: off + field_event_contact: Mapped["FieldEventContactAssociation"] = relationship( # noqa: F821 + back_populates="samples" + ) + # fmt: on - # ---Jake original code--- - # collection_timestamp = mapped_column(DateTime, nullable=False) - # collection_method = lexicon_term(nullable=False) - # - # thing_id = mapped_column( - # Integer, Foreign collection_timestamp = mapped_column(DateTime, nullable=False) - # collection_method = lexicon_term(nullable=False) - # - # thing_id = mapped_column( - # Integer, ForeignKey("thing.id", ondelete="CASCADE"), nullable=False - # ) - # thing = relationship("Thing")Key("thing.id", ondelete="CASCADE"), nullable=False - # ) - # thing = relationship("Thing") - - # wells = association_proxy("author_associations", "author") + # association proxies to help keep code DRY + field_event: AssociationProxy[list["FieldEvent"]] = association_proxy( # noqa: F821 + "field_activity", "field_event" + ) + thing: AssociationProxy[list["Thing"]] = association_proxy( # noqa: F821 + "field_activity", "field_event.thing" + ) + contact: AssociationProxy[list["Contact"]] = association_proxy( # noqa: F821 + "field_event_contact", "contact" + ) # noqa: F821 + observations: Mapped[list["Observation"]] = relationship( # noqa: F821 + "Observation", + back_populates="sample", + cascade="all, delete-orphan", + passive_deletes=True, + ) # ============= EOF ============================================= diff --git a/db/sensor.py b/db/sensor.py index 1b7b52166..6ab04b7c9 100644 --- a/db/sensor.py +++ b/db/sensor.py @@ -40,11 +40,9 @@ class Sensor(Base, AutoBaseMixin, ReleaseMixin): recording_interval: Mapped[int] = mapped_column(Integer, nullable=True) notes: Mapped[str] = mapped_column(String(50), nullable=True) - sample = relationship( - "Sample", + observations: Mapped[list["Observation"]] = relationship( # noqa: F821 + "Observation", back_populates="sensor", - cascade="all, delete-orphan", - uselist=False, ) diff --git a/db/thing.py b/db/thing.py index b6117b9d0..f09fa4930 100644 --- a/db/thing.py +++ b/db/thing.py @@ -14,13 +14,12 @@ # limitations under the License. # =============================================================================== from sqlalchemy import Integer, ForeignKey, String, Column, Float -from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy from sqlalchemy.orm import relationship, mapped_column, Mapped from sqlalchemy_utils import TSVectorType -from uuid import UUID - from db import lexicon_term +from db.asset import Asset from db.base import AutoBaseMixin, Base, ReleaseMixin @@ -37,7 +36,9 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin): overlaps="things", cascade="all, delete-orphan", ) - assets = association_proxy("asset_associations", "asset") + assets: AssociationProxy[list["Asset"]] = association_proxy( + "asset_associations", "asset" + ) location_associations = relationship( "LocationThingAssociation", @@ -46,7 +47,9 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin): cascade="all, delete-orphan", order_by="LocationThingAssociation.effective_start.desc()", ) - locations = association_proxy("location_associations", "location") + locations: AssociationProxy[list["Location"]] = association_proxy( # noqa: F821 + "location_associations", "location" + ) contact_associations = relationship( "ThingContactAssociation", @@ -54,7 +57,9 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin): overlaps="contacts", cascade="all, delete-orphan", ) - contacts = association_proxy("contact_associations", "contact") + contacts: AssociationProxy[list["Contact"]] = association_proxy( # noqa: F821 + "contact_associations", "contact" + ) # Well fields well_depth = Column( @@ -82,8 +87,8 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin): ) ) - samples = relationship( - "Sample", back_populates="thing", cascade="all, delete-orphan", uselist=True + field_events = relationship( + "FieldEvent", back_populates="thing", cascade="all, delete-orphan", uselist=True ) diff --git a/main.py b/main.py index 7a4f3cbcb..915d7460b 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,6 @@ import os import sentry_sdk from dotenv import load_dotenv -from starlette.middleware.base import BaseHTTPMiddleware -from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware load_dotenv() @@ -44,8 +42,6 @@ from api.lexicon import router as lexicon_router -from api.geothermal import router as geothermal_router -from api.geochronology import router as geochronology_router from api.publication import router as publication_router from api.author import router as author_router from api.asset import router as asset_router @@ -55,9 +51,7 @@ app.include_router(asset_router) app.include_router(author_router) app.include_router(contact_router) -app.include_router(geochronology_router) app.include_router(geospatial_router) -app.include_router(geothermal_router) app.include_router(group_router) app.include_router(lexicon_router) app.include_router(location_router) diff --git a/schemas/__init__.py b/schemas/__init__.py index 698dc02dd..9fdd22198 100644 --- a/schemas/__init__.py +++ b/schemas/__init__.py @@ -39,4 +39,14 @@ class BaseResponseModel(BaseModel): ) +# TODO: write function to convert any datetime field to UTC for use throughout +# for schema field_validators +# e.g. +# def convert_datetime_field_to_utc(dt_field): +# ... +# +# @field_validator("dt_field_name") +# def convert_to_utc(dt_field_name): +# return convert_datetime_field_to_utc(dt_field_name) + # ============= EOF ============================================= diff --git a/schemas/field.py b/schemas/field.py new file mode 100644 index 000000000..9f32ed72f --- /dev/null +++ b/schemas/field.py @@ -0,0 +1,17 @@ +from pydantic import AwareDatetime + +from schemas import BaseResponseModel + + +# RESPONSE --------------------------------------------------------------------- + + +class FieldActivityResponse(BaseResponseModel): + field_event_id: int + activity_type: str + + +class FieldEventResponse(BaseResponseModel): + thing_id: int + event_date: AwareDatetime + notes: str | None diff --git a/schemas/observation.py b/schemas/observation.py index a5269f91d..1e052fec3 100644 --- a/schemas/observation.py +++ b/schemas/observation.py @@ -36,7 +36,6 @@ class ValidateObservation(BaseModel): - _observation_class: str observed_property: str observation_datetime: AwareDatetime @@ -55,21 +54,11 @@ def convert_observation_datetime_to_utc( return observation_datetime.astimezone(timezone.utc) return observation_datetime - @model_validator(mode="after") - def prepend_observed_property(self: Self) -> Self: - observed_property = self.observed_property - observation_class = self._observation_class - if observed_property is not None: - observation_class = self._observation_class - if not observed_property.startswith(f"{observation_class}:"): - self.observed_property = f"{observation_class}:{observed_property}" - return self - # -------- CREATE ---------- class CreateBaseObservation(BaseCreateModel, ValidateObservation): observation_datetime: Annotated[AwareDatetime, PastDatetime()] - sample_id: int | None = None + sample_id: int sensor_id: int observed_property: str release_status: str @@ -78,18 +67,16 @@ class CreateBaseObservation(BaseCreateModel, ValidateObservation): class CreateGroundwaterLevelObservation(CreateBaseObservation): - _observation_class: str = "groundwater level" measuring_point_height: float level_status: str class CreateWaterChemistryObservation(CreateBaseObservation): - _observation_class: str = "water chemistry" + pass -class CreateGeothermalObservation(CreateBaseObservation): - _observation_class: str = "geothermal" - observation_depth: float +# class CreateGeothermalObservation(CreateBaseObservation): +# observation_depth: float # -------- UPDATE ------------ @@ -106,18 +93,16 @@ class UpdateBaseObservation(BaseUpdateModel, ValidateObservation): class UpdateGroundwaterLevelObservation(UpdateBaseObservation): - _observation_class: str = "groundwater level" measuring_point_height: float | None = None level_status: str | None = None class UpdateWaterChemistryObservation(UpdateBaseObservation): - _observation_class: str = "water chemistry" + pass -class UpdateGeothermalObservation(UpdateBaseObservation): - _observation_class: str = "geothermal" - observation_depth: float | None = None +# class UpdateGeothermalObservation(UpdateBaseObservation): +# observation_depth: float | None = None # -------- RESPONSE ---------- @@ -130,11 +115,6 @@ class BaseObservationResponse(BaseResponseModel): value: float | None unit: str - @field_validator("observed_property") - def remove_observed_property_prefix(cls, v: str) -> str: - colon_index = v.find(":") - return v[colon_index + 1 :] - class GroundwaterLevelObservationResponse(BaseObservationResponse): depth_to_water_bgs: float | None @@ -156,17 +136,19 @@ class WaterChemistryObservationResponse(BaseObservationResponse): pass -class GeothermalObservationResponse(BaseObservationResponse): - observation_depth: float | None +# class GeothermalObservationResponse(BaseObservationResponse): +# observation_depth: float | None class ObservationResponse( - GroundwaterLevelObservationResponse, GeothermalObservationResponse + GroundwaterLevelObservationResponse, WaterChemistryObservationResponse ): """ Response model for observations. Combines groundwater level and geothermal observation responses. """ + pass + # ============= EOF ============================================= diff --git a/schemas/sample.py b/schemas/sample.py index 8ed753deb..aec115d9a 100644 --- a/schemas/sample.py +++ b/schemas/sample.py @@ -26,11 +26,8 @@ from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel from schemas.thing import ThingResponse - -""" -REFACTOR TODO: can we use inheritance for commonly defined fields and then set them as optional -or not between Create, Update, and Response schemas? -""" +from schemas.field import FieldEventResponse, FieldActivityResponse +from schemas.contact import ContactResponse # -------- VALIDATE ---------- @@ -54,22 +51,22 @@ class ValidateSample(BaseModel): # return sample_bottom sample_date: AwareDatetime | None = None - sample_top: float | None = None - sample_bottom: float | None = None + depth_top: float | None = None + depth_bottom: float | None = None @model_validator(mode="after") def validate_top_and_bottom(self) -> Self: """ - Validate that sample_top and sample_bottom are both defined or both None. + Validate that depth_top and depth_bottom are both defined or both None. """ - sample_top = getattr(self, "sample_top", None) - sample_bottom = getattr(self, "sample_bottom", None) + depth_top = getattr(self, "depth_top", None) + depth_bottom = getattr(self, "depth_bottom", None) - if (sample_top is not None and sample_bottom is None) or ( - sample_top is None and sample_bottom is not None + if (depth_top is not None and depth_bottom is None) or ( + depth_top is None and depth_bottom is not None ): raise ValueError( - "Sample top and bottom must both be defined or both must be None." + "Depth top and bottom must both be defined or both must be None." ) return self @@ -86,78 +83,57 @@ def convert_sample_date_to_utc(sample_date: AwareDatetime) -> AwareDatetime: # -------- CREATE ---------- class CreateSample(BaseCreateModel, ValidateSample): - thing_id: int - sample_type: str - field_sample_id: str + field_activity_id: int + field_event_contact_id: int sample_date: Annotated[AwareDatetime, PastDatetime()] - sampler_name: str # REFACTOR TODO: update with enum/restricted values - qc_sample: str = "Original" - - sensor_id: int | None = None - sample_matrix: str | None = ( - None # REFACTOR TODO: update with enum/restricted values - ) - sample_method: str | None = ( - None # REFACTOR TODO: update with enum/restricted values - ) - - duplicate_sample_number: int | None = 0 - - # REFACTOR TODO: update with numeric restrictions? Are negative values below ground and positive above? - # for example: wells below, rain above, and soil/rock could be at ground surface - sample_top: float | None = None - sample_bottom: float | None = None + sample_name: str + sample_matrix: str + sample_method: str + qc_type: str + notes: str | None = None + depth_top: float | None = None + depth_bottom: float | None = None # -------- UPDATE ---------- class UpdateSample(BaseUpdateModel, ValidateSample): - """ - Development notes: - - setting = None makes the field optional, but if it is defined it must be of that type. - """ - - thing_id: int | None = None # REFACTOR TODO: should users be able to change this? - sample_type: str | None = None - field_sample_id: str | None = None + field_activity_id: int | None = None # TODO: should this be editable? + field_event_contact_id: int | None = None sample_date: Annotated[AwareDatetime, PastDatetime()] | None = None - sampler_name: str | None = None # REFACTOR TODO: update with enum/restricted values - qc_sample: str | None = None - - sensor_id: int | None = None # REFACTOR TODO: should users be able to change this? - sample_matrix: str | None = ( - None # REFACTOR TODO: update with enum/restricted values - ) - sample_method: str | None = ( - None # REFACTOR TODO: update with enum/restricted values - ) - - duplicate_sample_number: int | None = None - - # REFACTOR TODO: update with numeric restrictions? Are negative values below ground and positive above? - # for example: wells below, rain above, and soil/rock could be at ground surface - sample_top: float | None = None - sample_bottom: float | None = None + sample_name: str | None = None + sample_matrix: str | None = None + sample_method: str | None = None + qc_type: str | None = None + notes: str | None = None + depth_top: float | None = None + depth_bottom: float | None = None # -------- RESPONSE ---------- class SampleResponse(BaseResponseModel): - thing: ThingResponse - sample_type: str - field_sample_id: str - sample_date: AwareDatetime - release_status: str - sampler_name: str - qc_sample: str - - sensor_id: int | None - sample_matrix: str | None - sample_method: str | None + """ + Developer's note - duplicate_sample_number: int | None + The frontend uses multiple fields for a thing, field_even, and field_activity, + which is why full ThingResponse, FieldEventResponse, and FieldActivityResponse + are returned. If the response becomes too large and slow, we can use + .model_dump() and exlude fields to reduce the size. + """ - sample_top: float | None - sample_bottom: float | None + thing: ThingResponse + field_event: FieldEventResponse + field_activity: FieldActivityResponse + contact: ContactResponse + field_activity_id: int + field_event_contact_id: int + sample_date: Annotated[AwareDatetime, PastDatetime()] + sample_name: str + sample_matrix: str + sample_method: str + qc_type: str + notes: str | None + depth_top: float | None + depth_bottom: float | None # ============= EOF ============================================= diff --git a/services/crud_helper.py b/services/crud_helper.py index daa4bbf3d..6ef4d80e4 100644 --- a/services/crud_helper.py +++ b/services/crud_helper.py @@ -52,6 +52,13 @@ def model_patcher( # simple_get_by_id raises HTTP_404_NOT_FOUND if the item is not found item = simple_get_by_id(session, model, item_id) + """ + Developer's notes + + exclude_unset ensures that fields that are not set in the payload do not + update record fields to None + """ + for key, value in payload.model_dump(exclude_unset=True).items(): setattr(item, key, value) diff --git a/services/observation_helper.py b/services/observation_helper.py index 8f4561c32..7d935c346 100644 --- a/services/observation_helper.py +++ b/services/observation_helper.py @@ -8,29 +8,29 @@ from datetime import datetime from core.dependencies import session_dependency -from db import Observation, Sample +from db import Observation, Sample, FieldActivity, FieldEvent, Thing from schemas.observation import ( ObservationResponse, WaterChemistryObservationResponse, - GeothermalObservationResponse, + # GeothermalObservationResponse, GroundwaterLevelObservationResponse, ) from services.exceptions_helper import PydanticStyleException from services.query_helper import simple_get_by_id, order_sort_filter -def get_observation_class_from_request(request: Request) -> str: +def get_activity_type_from_request(request: Request) -> str: path = request.url.path path_components = path.split("/") if len(path_components) == 2: - # no observation class specified in path - observation_class_in_path = path_components[1] + # no sample type specified in path + activity_type_in_path = path_components[1] if len(path_components) >= 3: - # observation class specified in path - observation_class_in_path = path_components[2] + # sample type specified in path + activity_type_in_path = path_components[2] - observation_class = observation_class_in_path.replace("-", " ") - return observation_class + activity_type = activity_type_in_path.replace("-", " ") + return activity_type def get_observations( @@ -47,18 +47,23 @@ def get_observations( ) -> ( List[ObservationResponse] | List[WaterChemistryObservationResponse] - | List[GeothermalObservationResponse] + # | List[GeothermalObservationResponse] | List[GroundwaterLevelObservationResponse] ): """ Retrieve all observations """ - observation_class = get_observation_class_from_request(request) + activity_type_is_retrievable = False + activity_type = get_activity_type_from_request(request) sql = select(Observation) if thing_id is not None: - sql = sql.join(Sample) - sql = sql.where(Sample.thing_id == thing_id) + activity_type_is_retrievable = True + sql = sql.join(Sample, Sample.id == Observation.sample_id) + sql = sql.join(FieldActivity, FieldActivity.id == Sample.field_activity_id) + sql = sql.join(FieldEvent, FieldEvent.id == FieldActivity.field_event_id) + sql = sql.join(Thing, Thing.id == FieldEvent.thing_id) + sql = sql.where(Thing.id == thing_id) if sample_id is not None: sql = sql.where(Observation.sample_id == sample_id) if sensor_id is not None: @@ -70,8 +75,11 @@ def get_observations( sql = sql.where(Observation.observation_datetime <= end_time) # root of path is /observation - if observation_class != "observation": - sql = sql.where(Observation.observed_property.like(f"{observation_class}:%")) + if activity_type != "observation": + if activity_type_is_retrievable is False: + sql = sql.join(Sample, Sample.id == Observation.sample_id) + sql = sql.join(FieldActivity, FieldActivity.id == Sample.field_activity_id) + sql = sql.where(FieldActivity.activity_type == activity_type) sql = order_sort_filter(sql, Observation, sort, order, filter_) @@ -81,16 +89,21 @@ def get_observations( return paginate(query=sql, conn=session) -def verify_observed_property_corresponds_with_observation_class( +def verify_observed_property_corresponds_with_activity_type( observation: Observation, request: Request ): - observation_class = get_observation_class_from_request(request) + """ + Developer's notes & TODO - observed_property = observation.observed_property - colon_index = observed_property.find(":") - actual_observation_class = observed_property[:colon_index] + This is only used when getting one observation by its ID, and when patching + a single observation. Since it uses lazy loads that shouldn't be much of an + issue, but if we notice performance problems getting the single record + should use joinedloads so everything is done in a single database query. + """ + requested_activity_type = get_activity_type_from_request(request) + actual_activity_type = observation.sample.field_activity.activity_type - if actual_observation_class != observation_class: + if actual_activity_type != requested_activity_type: raise PydanticStyleException( status_code=HTTP_404_NOT_FOUND, detail=[ @@ -98,13 +111,13 @@ def verify_observed_property_corresponds_with_observation_class( "loc": ["path", "observation_id"], "type": "value_error", "input": {"observation_id": observation.id}, - "msg": f"Observation with ID {observation.id} is not a {observation_class} observation. It is a {actual_observation_class} observation.", + "msg": f"Observation with ID {observation.id} is not a {requested_activity_type} observation. It is a {actual_activity_type} observation.", } ], ) -def get_observation_of_an_observation_class_by_id( +def get_observation_of_an_activity_type_by_id( session: Session, request: Request, observation_id: int ) -> Observation: """ @@ -112,7 +125,7 @@ def get_observation_of_an_observation_class_by_id( """ observation = simple_get_by_id(session, Observation, observation_id) - verify_observed_property_corresponds_with_observation_class(observation, request) + verify_observed_property_corresponds_with_activity_type(observation, request) return observation @@ -130,7 +143,7 @@ def observation_model_patcher( # simple_get_by_id raises HTTP_404_NOT_FOUND if the item is not found observation = simple_get_by_id(session, Observation, observation_id) - verify_observed_property_corresponds_with_observation_class(observation, request) + verify_observed_property_corresponds_with_activity_type(observation, request) for key, value in payload.model_dump(exclude_unset=True).items(): setattr(observation, key, value) diff --git a/services/sample_helper.py b/services/sample_helper.py new file mode 100644 index 000000000..dc02506e0 --- /dev/null +++ b/services/sample_helper.py @@ -0,0 +1,26 @@ +from sqlalchemy.orm import Session, joinedload +from fastapi_pagination.ext.sqlalchemy import paginate + +from db import FieldEvent, FieldActivity, FieldEventContactAssociation, Sample +from services.query_helper import order_sort_filter + + +def get_db_samples( + session: Session, + order: str | None = None, + sort: str | None = None, + filter_: str | None = None, +): + query = session.query(Sample).options( + # Eagerly load related FieldActivity and FieldEvent to avoid N+1 problem + joinedload(Sample.field_activity) + .joinedload(FieldActivity.field_event) + .joinedload(FieldEvent.thing), + joinedload(Sample.field_event_contact).joinedload( + FieldEventContactAssociation.contact + ), # Eagerly load related Contact + ) + + query = order_sort_filter(query, Sample, sort, order, filter_) + + return paginate(query) diff --git a/tests/conftest.py b/tests/conftest.py index 2e14dbdc2..250754c0a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -219,54 +219,6 @@ def second_sensor(): session.commit() -@pytest.fixture(scope="session") -def sample(water_well_thing, sensor): - with session_ctx() as session: - sample = Sample( - sample_date="2025-01-01T00:00:00Z", - thing_id=water_well_thing.id, - sample_type="groundwater", - sampler_name="Test Sampler", - release_status="draft", - field_sample_id=f"FS-{uuid.uuid4()}", - qc_sample="Original", - sensor_id=sensor.id, - sample_matrix="water", - sample_method="manual", - duplicate_sample_number=0, - sample_top=None, - sample_bottom=None, - ) - session.add(sample) - session.commit() - yield sample - - -@pytest.fixture(scope="function") -def second_sample(water_well_thing, sensor): - with session_ctx() as session: - sample = Sample( - thing_id=water_well_thing.id, - sample_type="groundwater", - field_sample_id="FS-9999999", - sample_date="2025-01-01T00:00:00Z", - release_status="draft", - sampler_name="Test Sampler", - qc_sample="Duplicate", - sensor_id=sensor.id, - sample_matrix="water", - sample_method="manual", - duplicate_sample_number=3, - sample_top=2, - sample_bottom=3, - ) - session.add(sample) - session.commit() - yield sample - session.delete(sample) - session.commit() - - @pytest.fixture(scope="session") def contact(water_well_thing): with session_ctx() as session: @@ -275,7 +227,7 @@ def contact(water_well_thing): name="Test Contact", role="Owner", contact_type="Primary", - organization="Test Organization", + organization="NMBGMR", ) session.add(contact) session.commit() @@ -425,7 +377,7 @@ def third_contact(): name=None, role="Owner", contact_type="Primary", - organization="Third Organization", + organization="NMBGMR", ) session.add(contact) session.commit() @@ -507,13 +459,133 @@ def second_asset(): @pytest.fixture(scope="session") -def groundwater_level_observation(sensor, sample): +def field_event(water_well_thing): + with session_ctx() as session: + field_event = FieldEvent( + thing_id=water_well_thing.id, + event_date="2025-01-01T00:00:00Z", + notes="field event fixture notes", + release_status="draft", + ) + session.add(field_event) + session.commit() + yield field_event + + +@pytest.fixture(scope="session") +def field_event_contact(field_event, contact): + with session_ctx() as session: + field_event_contact = FieldEventContactAssociation( + field_event_id=field_event.id, + contact_id=contact.id, + field_contact_role="Lead", + ) + session.add(field_event_contact) + session.commit() + yield field_event_contact + + +@pytest.fixture(scope="session") +def groundwater_level_field_activity(field_event): + with session_ctx() as session: + field_activity = FieldActivity( + field_event_id=field_event.id, + activity_type="groundwater level", + notes="field activity fixture notes", + release_status="draft", + ) + session.add(field_activity) + session.commit() + yield field_activity + + +@pytest.fixture(scope="session") +def water_chemistry_field_activity(field_event): + with session_ctx() as session: + field_activity = FieldActivity( + field_event_id=field_event.id, + activity_type="water chemistry", + notes="field activity fixture notes", + release_status="draft", + ) + session.add(field_activity) + session.commit() + yield field_activity + + +@pytest.fixture(scope="session") +def groundwater_level_sample(groundwater_level_field_activity, field_event_contact): + with session_ctx() as session: + sample = Sample( + field_activity_id=groundwater_level_field_activity.id, + field_event_contact_id=field_event_contact.id, + sample_date="2025-01-01T12:00:00Z", + sample_name="groundwater level sample name", + sample_matrix="water", + sample_method="Steel-tape measurement", + qc_type="Normal", + depth_top=None, + depth_bottom=None, + notes="groundwater level sample fixture notes", + release_status="draft", + ) + session.add(sample) + session.commit() + yield sample + + +@pytest.fixture(scope="session") +def water_chemistry_sample(water_chemistry_field_activity, field_event_contact): + with session_ctx() as session: + sample = Sample( + field_activity_id=water_chemistry_field_activity.id, + field_event_contact_id=field_event_contact.id, + sample_date="2025-01-01T13:00:00Z", + sample_name="water chemistry sample name", + sample_matrix="water", + sample_method="grab sample", + qc_type="Normal", + depth_top=None, + depth_bottom=None, + notes="water chemistry sample fixture notes", + release_status="draft", + ) + session.add(sample) + session.commit() + yield sample + + +@pytest.fixture(scope="function") +def sample_to_delete(water_chemistry_field_activity, field_event_contact): + with session_ctx() as session: + sample = Sample( + field_activity_id=water_chemistry_field_activity.id, + field_event_contact_id=field_event_contact.id, + sample_date="2025-01-01T13:00:00Z", + sample_name="sample to delete", + sample_matrix="water", + sample_method="grab sample", + qc_type="Normal", + depth_top=None, + depth_bottom=None, + notes="water chemistry sample fixture notes", + release_status="draft", + ) + session.add(sample) + session.commit() + yield sample + session.delete(sample) + session.commit() + + +@pytest.fixture(scope="session") +def groundwater_level_observation(sensor, groundwater_level_sample): with session_ctx() as session: observation = Observation( observation_datetime="2025-01-01T00:04:00Z", - sample_id=sample.id, + sample_id=groundwater_level_sample.id, sensor_id=sensor.id, - observed_property="groundwater level:groundwater level", + observed_property="groundwater level", release_status="draft", value=10.0, unit="ft", @@ -526,13 +598,13 @@ def groundwater_level_observation(sensor, sample): @pytest.fixture(scope="session") -def water_chemistry_observation(sensor, sample): +def water_chemistry_observation(sensor, water_chemistry_sample): with session_ctx() as session: observation = Observation( observation_datetime="2025-01-01T00:03:00Z", - sample_id=sample.id, + sample_id=water_chemistry_sample.id, sensor_id=sensor.id, - observed_property="water chemistry:pH", + observed_property="pH", release_status="draft", value=4.0, unit="dimensionless", @@ -543,13 +615,13 @@ def water_chemistry_observation(sensor, sample): @pytest.fixture(scope="session") -def geothermal_observation(sensor, sample): +def geothermal_observation(sensor, geothermal_sample): with session_ctx() as session: observation = Observation( observation_datetime="2025-01-01T00:02:00Z", - sample_id=sample.id, + sample_id=geothermal_sample.id, sensor_id=sensor.id, - observed_property="geothermal:temperature", + observed_property="temperature", release_status="draft", value=20.0, unit="deg C", @@ -561,13 +633,13 @@ def geothermal_observation(sensor, sample): @pytest.fixture(scope="function") -def observation_to_delete(sample, sensor): +def observation_to_delete(water_chemistry_sample, sensor): with session_ctx() as session: observation = Observation( observation_datetime="2019-01-01T00:03:00Z", - sample_id=sample.id, + sample_id=water_chemistry_sample.id, sensor_id=sensor.id, - observed_property="water chemistry:pH", + observed_property="pH", release_status="draft", value=4.0, unit="dimensionless", diff --git a/tests/test_contact.py b/tests/test_contact.py index b505ff4cb..22b5ad65a 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -72,7 +72,7 @@ def test_add_contact(spring_thing): "name": "Test Contact 2", "role": "Owner", "contact_type": "Primary", - "organization": "Well Owner LLC", + "organization": "NMBGMR", "thing_id": spring_thing.id, "emails": [ { @@ -160,7 +160,7 @@ def test_add_contact_409_bad_thing_id(): "name": "Test Contact 3", "role": "Owner", "contact_type": "Primary", - "organization": "Well Owner LLC", + "organization": "NMBGMR", "thing_id": bad_thing_id, "emails": [ { diff --git a/tests/test_geochronology.py b/tests/test_geochronology.py index 241d96629..c6b32ce1f 100644 --- a/tests/test_geochronology.py +++ b/tests/test_geochronology.py @@ -13,10 +13,13 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== - +import pytest from tests import client +@pytest.mark.skip( + reason="Not implemented and may be fully deprecated for the observation table/router" +) def test_add_geochronology_age(): response = client.post( "/geochronology/age", diff --git a/tests/test_observation.py b/tests/test_observation.py index 293b59023..f7c206699 100644 --- a/tests/test_observation.py +++ b/tests/test_observation.py @@ -42,13 +42,13 @@ def override_authentication_dependency_fixture(): # ============= Post tests ================= -def test_add_water_chemistry_observation(sample, sensor): +def test_add_water_chemistry_observation(water_chemistry_sample, sensor): payload = { "observation_datetime": "2025-01-01T00:00:00Z", "release_status": "draft", "value": 7.5, "unit": "dimensionless", - "sample_id": sample.id, + "sample_id": water_chemistry_sample.id, "sensor_id": sensor.id, "observed_property": "pH", } @@ -69,13 +69,13 @@ def test_add_water_chemistry_observation(sample, sensor): cleanup_post_test(Observation, data["id"]) -def test_add_groundwater_level_observation(sample, sensor): +def test_add_groundwater_level_observation(groundwater_level_sample, sensor): payload = { "observation_datetime": "2025-01-01T00:00:00Z", "release_status": "draft", "value": 101, "measuring_point_height": 53, - "sample_id": sample.id, + "sample_id": groundwater_level_sample.id, "sensor_id": sensor.id, "level_status": "Water level not affected by status", "observed_property": "groundwater level", @@ -102,33 +102,33 @@ def test_add_groundwater_level_observation(sample, sensor): cleanup_post_test(Observation, data["id"]) -def test_add_geothermal_observation(sample, sensor): - payload = { - "observation_datetime": "2025-01-01T00:00:00Z", - "release_status": "draft", - "observation_depth": 100, - "value": 25.5, - "sample_id": sample.id, - "sensor_id": sensor.id, - "observed_property": "temperature", - "unit": "deg C", - } - response = client.post("/observation/geothermal", json=payload) - data = response.json() - assert response.status_code == 201 - - assert "id" in data - assert "created_at" in data - assert data["observation_datetime"] == payload["observation_datetime"] - assert data["release_status"] == payload["release_status"] - assert data["observation_depth"] == payload["observation_depth"] - assert data["value"] == payload["value"] - assert data["sample_id"] == payload["sample_id"] - assert data["sensor_id"] == payload["sensor_id"] - assert data["observed_property"] == payload["observed_property"] - assert data["unit"] == payload["unit"] - - cleanup_post_test(Observation, data["id"]) +# def test_add_geothermal_observation(geothermal_sample, sensor): +# payload = { +# "observation_datetime": "2025-01-01T00:00:00Z", +# "release_status": "draft", +# "observation_depth": 100, +# "value": 25.5, +# "sample_id": geothermal_sample.id, +# "sensor_id": sensor.id, +# "observed_property": "temperature", +# "unit": "deg C", +# } +# response = client.post("/observation/geothermal", json=payload) +# data = response.json() +# assert response.status_code == 201 + +# assert "id" in data +# assert "created_at" in data +# assert data["observation_datetime"] == payload["observation_datetime"] +# assert data["release_status"] == payload["release_status"] +# assert data["observation_depth"] == payload["observation_depth"] +# assert data["value"] == payload["value"] +# assert data["sample_id"] == payload["sample_id"] +# assert data["sensor_id"] == payload["sensor_id"] +# assert data["observed_property"] == payload["observed_property"] +# assert data["unit"] == payload["unit"] + +# cleanup_post_test(Observation, data["id"]) # PATCH tests ================================================================== @@ -160,26 +160,22 @@ def test_patch_groundwater_level_observation_404_not_found( assert data["detail"] == f"Observation with ID {bad_id} not found." -def test_patch_groundwater_level_observation_404_wrong_observation_class( - water_chemistry_observation, geothermal_observation +def test_patch_groundwater_level_observation_404_wrong_activity_type( + water_chemistry_observation, ): - for obs in water_chemistry_observation, geothermal_observation: - payload = {"measuring_point_height": 3} - response = client.patch( - f"/observation/groundwater-level/{obs.id}", json=payload - ) - assert response.status_code == 404 - data = response.json() + payload = {"measuring_point_height": 3} + response = client.patch( + f"/observation/groundwater-level/{water_chemistry_observation.id}", json=payload + ) + assert response.status_code == 404 + data = response.json() - if obs.observed_property == "geothermal:temperature": - observation_class = "geothermal" - else: - observation_class = "water chemistry" + actual_activity_type = "water chemistry" - assert ( - data["detail"][0]["msg"] - == f"Observation with ID {obs.id} is not a groundwater level observation. It is a {observation_class} observation." - ) + assert ( + data["detail"][0]["msg"] + == f"Observation with ID {water_chemistry_observation.id} is not a groundwater level observation. It is a {actual_activity_type} observation." + ) def test_patch_water_chemistry_observation(water_chemistry_observation): @@ -206,90 +202,95 @@ def test_patch_water_chemistry_observation_404_not_found(water_chemistry_observa assert data["detail"] == f"Observation with ID {bad_id} not found." -def test_patch_water_chemistry_observation_404_wrong_observation_class( - groundwater_level_observation, geothermal_observation +def test_patch_water_chemistry_observation_404_wrong_activity_type( + groundwater_level_observation, ): - for obs in groundwater_level_observation, geothermal_observation: - payload = {"value": 8} - response = client.patch(f"/observation/water-chemistry/{obs.id}", json=payload) - assert response.status_code == 404 - data = response.json() + payload = {"value": 8} + response = client.patch( + f"/observation/water-chemistry/{groundwater_level_observation.id}", json=payload + ) + assert response.status_code == 404 + data = response.json() - if obs.observed_property == "geothermal:temperature": - observation_class = "geothermal" - else: - observation_class = "groundwater level" + actualy_activity_type = "groundwater level" - assert ( - data["detail"][0]["msg"] - == f"Observation with ID {obs.id} is not a water chemistry observation. It is a {observation_class} observation." - ) + assert ( + data["detail"][0]["msg"] + == f"Observation with ID {groundwater_level_observation.id} is not a water chemistry observation. It is a {actualy_activity_type} observation." + ) -def test_patch_geothermal_observation(geothermal_observation): - payload = {"observation_depth": 4, "release_status": "private"} - response = client.patch( - f"/observation/geothermal/{geothermal_observation.id}", json=payload - ) - assert response.status_code == 200 - data = response.json() - assert data["observation_depth"] == payload["observation_depth"] - assert data["release_status"] == payload["release_status"] +# def test_patch_geothermal_observation(geothermal_observation): +# payload = {"observation_depth": 4, "release_status": "private"} +# response = client.patch( +# f"/observation/geothermal/{geothermal_observation.id}", json=payload +# ) +# assert response.status_code == 200 +# data = response.json() +# assert data["observation_depth"] == payload["observation_depth"] +# assert data["release_status"] == payload["release_status"] - cleanup_patch_test(Observation, payload, geothermal_observation) +# cleanup_patch_test(Observation, payload, geothermal_observation) -def test_patch_geothermal_observation_404_not_found(geothermal_observation): - bad_id = 999999 - payload = {"observation_depth": 8} - response = client.patch(f"/observation/geothermal/{bad_id}", json=payload) - assert response.status_code == 404 - data = response.json() - assert data["detail"] == f"Observation with ID {bad_id} not found." +# def test_patch_geothermal_observation_404_not_found(geothermal_observation): +# bad_id = 999999 +# payload = {"observation_depth": 8} +# response = client.patch(f"/observation/geothermal/{bad_id}", json=payload) +# assert response.status_code == 404 +# data = response.json() +# assert data["detail"] == f"Observation with ID {bad_id} not found." -def test_patch_geothermal_observation_404_wrong_observation_class( - groundwater_level_observation, water_chemistry_observation -): - for obs in groundwater_level_observation, water_chemistry_observation: - payload = {"value": 8} - response = client.patch(f"/observation/geothermal/{obs.id}", json=payload) - assert response.status_code == 404 - data = response.json() +# def test_patch_geothermal_observation_404_wrong_activity_type( +# groundwater_level_observation, water_chemistry_observation +# ): +# for obs in groundwater_level_observation, water_chemistry_observation: +# payload = {"value": 8} +# response = client.patch(f"/observation/geothermal/{obs.id}", json=payload) +# assert response.status_code == 404 +# data = response.json() - if obs.observed_property == "groundwater level:groundwater level": - observation_class = "groundwater level" - else: - observation_class = "water chemistry" +# if obs.observed_property == "groundwater level": +# activity_type = "groundwater level" +# else: +# activity_type = "water chemistry" - assert ( - data["detail"][0]["msg"] - == f"Observation with ID {obs.id} is not a geothermal observation. It is a {observation_class} observation." - ) +# assert ( +# data["detail"][0]["msg"] +# == f"Observation with ID {obs.id} is not a geothermal observation. It is a {activity_type} observation." +# ) # ============= Get tests ================= def test_get_all_observations( - groundwater_level_observation, water_chemistry_observation, geothermal_observation + groundwater_level_observation, water_chemistry_observation ): response = client.get("/observation") assert response.status_code == 200 data = response.json() - assert data["total"] == 3 - assert data["items"][0]["id"] == groundwater_level_observation.id - assert data["items"][1]["id"] == water_chemistry_observation.id - assert data["items"][2]["id"] == geothermal_observation.id + + assert data["total"] == 2 + for item in data["items"]: + assert "id" in item + assert "created_at" in item + assert "release_status" in item + assert "sample_id" in item + assert "sensor_id" in item + assert "observation_datetime" in item + assert "observed_property" in item + assert "value" in item + assert "unit" in item def test_get_observation_by_id( - groundwater_level_observation, water_chemistry_observation, geothermal_observation + groundwater_level_observation, water_chemistry_observation ): for obs in ( groundwater_level_observation, water_chemistry_observation, - geothermal_observation, ): response = client.get(f"/observation/{obs.id}") assert response.status_code == 200 @@ -298,19 +299,14 @@ def test_get_observation_by_id( assert data["id"] == obs.id assert data["created_at"] == obs.created_at.isoformat().replace("+00:00", "Z") assert data["release_status"] == obs.release_status - if obs.observed_property == "groundwater level:groundwater level": + if obs.observed_property == "groundwater level": assert data["depth_to_water_bgs"] == obs.value - obs.measuring_point_height - assert data["observation_depth"] is None - elif obs.observed_property == "geothermal:temperature": - assert data["depth_to_water_bgs"] is None - assert data["observation_depth"] == obs.observation_depth else: assert data["depth_to_water_bgs"] is None - assert data["observation_depth"] is None def test_get_observation_by_id_404_not_found( - groundwater_level_observation, water_chemistry_observation, geothermal_observation + groundwater_level_observation, water_chemistry_observation ): bad_id = 999999 response = client.get(f"/observation/{bad_id}") @@ -319,9 +315,7 @@ def test_get_observation_by_id_404_not_found( assert data["detail"] == f"Observation with ID {bad_id} not found." -def test_get_groundwater_level_observations( - groundwater_level_observation, water_chemistry_observation, geothermal_observation -): +def test_get_groundwater_level_observations(groundwater_level_observation): response = client.get("/observation/groundwater-level") assert response.status_code == 200 data = response.json() @@ -412,32 +406,35 @@ def test_get_groundwater_level_observation_by_id_404_not_found( assert data["detail"] == f"Observation with ID {bad_id} not found." -def test_get_groundwater_level_observation_by_id_404_wrong_observation_class( - water_chemistry_observation, geothermal_observation +def test_get_groundwater_level_observation_by_id_404_wrong_activity_type( + water_chemistry_observation, ): - for obs in water_chemistry_observation, geothermal_observation: - response = client.get(f"/observation/groundwater-level/{obs.id}") - assert response.status_code == 404 - data = response.json() + response = client.get( + f"/observation/groundwater-level/{water_chemistry_observation.id}" + ) + assert response.status_code == 404 + data = response.json() - if obs.observed_property == "geothermal:temperature": - actual_observation_class = "geothermal" - else: - actual_observation_class = "water chemistry" + actual_activity_type = "water chemistry" - assert ( - data["detail"][0]["msg"] - == f"Observation with ID {obs.id} is not a groundwater level observation. It is a {actual_observation_class} observation." - ) - assert data["detail"][0]["type"] == "value_error" - assert data["detail"][0]["input"] == {"observation_id": obs.id} - assert data["detail"][0]["loc"] == ["path", "observation_id"] + assert ( + data["detail"][0]["msg"] + == f"Observation with ID {water_chemistry_observation.id} is not a groundwater level observation. It is a {actual_activity_type} observation." + ) + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == { + "observation_id": water_chemistry_observation.id + } + assert data["detail"][0]["loc"] == ["path", "observation_id"] -def test_get_groundwater_observation_by_sample(sample): +def test_get_groundwater_observation_by_sample(groundwater_level_sample): response = client.get( "/observation/groundwater-level", - params={"sample_id": sample.id, "observed_property": "groundwater level"}, + params={ + "sample_id": groundwater_level_sample.id, + "observed_property": "groundwater level", + }, ) assert response.status_code == 200 data = response.json() @@ -566,107 +563,110 @@ def test_get_water_chemistry_observation_by_id_404_not_found( assert data["detail"] == f"Observation with ID {bad_id} not found." -def test_get_water_chemistry_observation_by_id_404_wrong_observation_class( - groundwater_level_observation, geothermal_observation +def test_get_water_chemistry_observation_by_id_404_wrong_activity_type( + groundwater_level_observation, ): - for obs in groundwater_level_observation, geothermal_observation: - response = client.get(f"/observation/water-chemistry/{obs.id}") - assert response.status_code == 404 - data = response.json() - - if obs.observed_property == "groundwater level:groundwater level": - actual_observation_class = "groundwater level" - else: - actual_observation_class = "geothermal" - - assert ( - data["detail"][0]["msg"] - == f"Observation with ID {obs.id} is not a water chemistry observation. It is a {actual_observation_class} observation." - ) - assert data["detail"][0]["type"] == "value_error" - assert data["detail"][0]["input"] == {"observation_id": obs.id} - assert data["detail"][0]["loc"] == ["path", "observation_id"] - - -def test_get_geothermal_observations(geothermal_observation): - response = client.get("/observation/geothermal") - assert response.status_code == 200 - data = response.json() - assert data["total"] == 1 - assert data["items"][0]["id"] == geothermal_observation.id - assert data["items"][0][ - "created_at" - ] == geothermal_observation.created_at.isoformat().replace("+00:00", "Z") - assert data["items"][0]["release_status"] == geothermal_observation.release_status - assert data["items"][0]["sample_id"] == geothermal_observation.sample_id - assert data["items"][0]["sensor_id"] == geothermal_observation.sensor_id - assert ( - data["items"][0]["observation_datetime"] - == geothermal_observation.observation_datetime - ) - colon_index = geothermal_observation.observed_property.find(":") - assert ( - data["items"][0]["observed_property"] - == geothermal_observation.observed_property[colon_index + 1 :] - ) - assert data["items"][0]["value"] == geothermal_observation.value - assert data["items"][0]["unit"] == geothermal_observation.unit - assert ( - data["items"][0]["observation_depth"] - == geothermal_observation.observation_depth - ) - - -def test_get_geothermal_observation_by_id(geothermal_observation): - response = client.get(f"/observation/geothermal/{geothermal_observation.id}") - assert response.status_code == 200 - data = response.json() - assert data["id"] == geothermal_observation.id - assert data["created_at"] == geothermal_observation.created_at.isoformat().replace( - "+00:00", "Z" - ) - assert data["release_status"] == geothermal_observation.release_status - assert data["sample_id"] == geothermal_observation.sample_id - assert data["sensor_id"] == geothermal_observation.sensor_id - assert data["observation_datetime"] == geothermal_observation.observation_datetime - colon_index = geothermal_observation.observed_property.find(":") - assert ( - data["observed_property"] - == geothermal_observation.observed_property[colon_index + 1 :] + response = client.get( + f"/observation/water-chemistry/{groundwater_level_observation.id}" ) - assert data["value"] == geothermal_observation.value - assert data["unit"] == geothermal_observation.unit - assert data["observation_depth"] == geothermal_observation.observation_depth - - -def test_get_geothermal_observation_by_id_404_not_found(geothermal_observation): - bad_id = 99999 - response = client.get(f"/observation/geothermal/{bad_id}") assert response.status_code == 404 data = response.json() - assert data["detail"] == f"Observation with ID {bad_id} not found." + if groundwater_level_observation.observed_property == "groundwater level": + actual_activity_type = "groundwater level" + else: + actual_activity_type = "geothermal" -def test_get_geothermal_observation_by_id_404_wrong_observation_class( - water_chemistry_observation, groundwater_level_observation -): - for obs in water_chemistry_observation, groundwater_level_observation: - response = client.get(f"/observation/geothermal/{obs.id}") - assert response.status_code == 404 - data = response.json() - - if obs.observed_property == "groundwater level:groundwater level": - actual_observation_class = "groundwater level" - else: - actual_observation_class = "water chemistry" - - assert ( - data["detail"][0]["msg"] - == f"Observation with ID {obs.id} is not a geothermal observation. It is a {actual_observation_class} observation." - ) - assert data["detail"][0]["type"] == "value_error" - assert data["detail"][0]["input"] == {"observation_id": obs.id} - assert data["detail"][0]["loc"] == ["path", "observation_id"] + assert ( + data["detail"][0]["msg"] + == f"Observation with ID {groundwater_level_observation.id} is not a water chemistry observation. It is a {actual_activity_type} observation." + ) + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == { + "observation_id": groundwater_level_observation.id + } + assert data["detail"][0]["loc"] == ["path", "observation_id"] + + +# def test_get_geothermal_observations(geothermal_observation): +# response = client.get("/observation/geothermal") +# assert response.status_code == 200 +# data = response.json() +# assert data["total"] == 1 +# assert data["items"][0]["id"] == geothermal_observation.id +# assert data["items"][0][ +# "created_at" +# ] == geothermal_observation.created_at.isoformat().replace("+00:00", "Z") +# assert data["items"][0]["release_status"] == geothermal_observation.release_status +# assert data["items"][0]["sample_id"] == geothermal_observation.sample_id +# assert data["items"][0]["sensor_id"] == geothermal_observation.sensor_id +# assert ( +# data["items"][0]["observation_datetime"] +# == geothermal_observation.observation_datetime +# ) +# colon_index = geothermal_observation.observed_property.find(":") +# assert ( +# data["items"][0]["observed_property"] +# == geothermal_observation.observed_property[colon_index + 1 :] +# ) +# assert data["items"][0]["value"] == geothermal_observation.value +# assert data["items"][0]["unit"] == geothermal_observation.unit +# assert ( +# data["items"][0]["observation_depth"] +# == geothermal_observation.observation_depth +# ) + + +# def test_get_geothermal_observation_by_id(geothermal_observation): +# response = client.get(f"/observation/geothermal/{geothermal_observation.id}") +# assert response.status_code == 200 +# data = response.json() +# assert data["id"] == geothermal_observation.id +# assert data["created_at"] == geothermal_observation.created_at.isoformat().replace( +# "+00:00", "Z" +# ) +# assert data["release_status"] == geothermal_observation.release_status +# assert data["sample_id"] == geothermal_observation.sample_id +# assert data["sensor_id"] == geothermal_observation.sensor_id +# assert data["observation_datetime"] == geothermal_observation.observation_datetime +# colon_index = geothermal_observation.observed_property.find(":") +# assert ( +# data["observed_property"] +# == geothermal_observation.observed_property[colon_index + 1 :] +# ) +# assert data["value"] == geothermal_observation.value +# assert data["unit"] == geothermal_observation.unit +# assert data["observation_depth"] == geothermal_observation.observation_depth + + +# def test_get_geothermal_observation_by_id_404_not_found(geothermal_observation): +# bad_id = 99999 +# response = client.get(f"/observation/geothermal/{bad_id}") +# assert response.status_code == 404 +# data = response.json() +# assert data["detail"] == f"Observation with ID {bad_id} not found." + + +# def test_get_geothermal_observation_by_id_404_wrong_activity_type( +# water_chemistry_observation, groundwater_level_observation +# ): +# for obs in water_chemistry_observation, groundwater_level_observation: +# response = client.get(f"/observation/geothermal/{obs.id}") +# assert response.status_code == 404 +# data = response.json() + +# if obs.observed_property == "groundwater level": +# actual_activity_type = "groundwater level" +# else: +# actual_activity_type = "water chemistry" + +# assert ( +# data["detail"][0]["msg"] +# == f"Observation with ID {obs.id} is not a geothermal observation. It is a {actual_activity_type} observation." +# ) +# assert data["detail"][0]["type"] == "value_error" +# assert data["detail"][0]["input"] == {"observation_id": obs.id} +# assert data["detail"][0]["loc"] == ["path", "observation_id"] # JB's comment: I don't think that geographic filters are necessary for diff --git a/tests/test_sample.py b/tests/test_sample.py index ba452d24c..b1b495afe 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -43,79 +43,83 @@ def override_dependencies_fixture(): def test_validate_sample_top_and_bottom(): for i in range(2): - sample_top = 10.0 if i == 0 else None - sample_bottom = 5.0 if i == 1 else None + depth_top = 10.0 if i == 0 else None + depth_bottom = 5.0 if i == 1 else None with pytest.raises( ValidationError, - match="Sample top and bottom must both be defined or both must be None.", + match="Depth top and bottom must both be defined or both must be None.", ): - ValidateSample(sample_top=sample_top, sample_bottom=sample_bottom) + ValidateSample(depth_top=depth_top, depth_bottom=depth_bottom) # ============= Post tests for samples ============================================= -def test_add_sample(spring_thing, sensor): +def test_add_sample( + groundwater_level_field_activity, water_well_thing, field_event_contact +): """ Test adding a sample. """ payload = { - "thing_id": spring_thing.id, - "sample_type": "groundwater", - "field_sample_id": "FS-1234567", - "sample_date": "2025-01-01T00:00:00Z", - "release_status": "draft", - "sampler_name": "Test Sampler", - "qc_sample": "Duplicate", - "sensor_id": sensor.id, + "field_activity_id": groundwater_level_field_activity.id, + "field_event_contact_id": field_event_contact.id, + "sample_date": "2025-01-01T14:00:00Z", + "sample_name": "second groundwater level field activity name", "sample_matrix": "water", - "sample_method": "manual", - "duplicate_sample_number": 3, - "sample_top": 2, - "sample_bottom": 3, + "sample_method": "grab sample", + "notes": "posted notes", + "qc_type": "Normal", + "depth_top": None, + "depth_bottom": None, + "release_status": "draft", } response = client.post( "/sample", json=payload, ) data = response.json() + assert response.status_code == 201 assert "id" in data assert "created_at" in data - assert data["thing"]["id"] == spring_thing.id - assert data["sample_type"] == payload["sample_type"] - assert data["field_sample_id"] == payload["field_sample_id"] + assert data["thing"]["id"] == water_well_thing.id + assert data["field_event"]["id"] == groundwater_level_field_activity.field_event_id + assert data["field_activity"]["id"] == groundwater_level_field_activity.id + assert data["field_activity_id"] == payload["field_activity_id"] + assert data["contact"]["id"] == field_event_contact.contact_id + assert data["field_event_contact_id"] == payload["field_event_contact_id"] assert data["sample_date"] == payload["sample_date"] - assert data["release_status"] == payload["release_status"] - assert data["sampler_name"] == payload["sampler_name"] - assert data["qc_sample"] == payload["qc_sample"] - assert data["sensor_id"] == payload["sensor_id"] + assert data["sample_name"] == payload["sample_name"] assert data["sample_matrix"] == payload["sample_matrix"] assert data["sample_method"] == payload["sample_method"] - assert data["duplicate_sample_number"] == payload["duplicate_sample_number"] - assert data["sample_top"] == payload["sample_top"] - assert data["sample_bottom"] == payload["sample_bottom"] + assert data["notes"] == payload["notes"] + assert data["qc_type"] == payload["qc_type"] + assert data["depth_top"] == payload["depth_top"] + assert data["depth_bottom"] == payload["depth_bottom"] + assert data["release_status"] == payload["release_status"] # cleanup after adding the sample cleanup_post_test(Sample, data["id"]) -def test_409_add_sample_invalid_field_sample_id(sample, spring_thing): +def test_409_add_sample_invalid_sample_name( + groundwater_level_field_activity, + groundwater_level_sample, + field_event_contact, +): """ - Test adding a sample with an invalid field_sample_id. + Test that a 409 error is raised if a duplicate sample_name is in the payload """ payload = { - "thing_id": spring_thing.id, - "sample_type": "groundwater", - "field_sample_id": sample.field_sample_id, # This should already exist - "sample_date": "2025-01-01T00:00:00Z", - "release_status": "draft", - "sampler_name": "Test Sampler", - "qc_sample": "Duplicate", - "sensor_id": None, + "field_activity_id": groundwater_level_field_activity.id, + "field_event_contact_id": field_event_contact.id, + "sample_date": "2025-01-01T14:00:00Z", + "sample_name": groundwater_level_sample.sample_name, "sample_matrix": "water", - "sample_method": "manual", - "duplicate_sample_number": 3, - "sample_top": 2, - "sample_bottom": 3, + "sample_method": "grab sample", + "qc_type": "Normal", + "depth_top": None, + "depth_bottom": None, + "release_status": "draft", } response = client.post( "/sample", @@ -123,75 +127,85 @@ def test_409_add_sample_invalid_field_sample_id(sample, spring_thing): ) data = response.json() assert response.status_code == 409 - assert data["detail"][0]["loc"] == ["body", "field_sample_id"] + assert data["detail"][0]["loc"] == ["body", "sample_name"] assert ( data["detail"][0]["msg"] - == f"Sample with field_sample_id {sample.field_sample_id} already exists." + == f"Sample with sample_name {groundwater_level_sample.sample_name} already exists." ) assert data["detail"][0]["type"] == "value_error" - assert data["detail"][0]["input"] == {"field_sample_id": sample.field_sample_id} + assert data["detail"][0]["input"] == { + "sample_name": groundwater_level_sample.sample_name + } -def test_409_add_sample_invalid_thing_id(): +def test_409_add_sample_invalid_field_activity_id( + groundwater_level_field_activity, + groundwater_level_sample, + field_event_contact, +): """ - Test adding a sample with an invalid thing_id. + Test adding a sample with an invalid field_activity_id. """ payload = { - "thing_id": 9999999, - "sample_type": "groundwater", - "field_sample_id": "FS-9999999", - "sample_date": "2025-01-01T00:00:00Z", - "release_status": "draft", - "sampler_name": "Test Sampler", - "qc_sample": "Duplicate", - "sensor_id": None, + "field_activity_id": 999999, + "field_event_contact_id": field_event_contact.id, + "sample_date": "2025-01-01T14:00:00Z", + "sample_name": "yet another sample name", "sample_matrix": "water", - "sample_method": "manual", - "duplicate_sample_number": 3, - "sample_top": 2, - "sample_bottom": 3, + "sample_method": "grab sample", + "qc_type": "Normal", + "depth_top": None, + "depth_bottom": None, + "release_status": "draft", } response = client.post( "/sample", json=payload, ) data = response.json() + print(data) assert response.status_code == 409 - assert data["detail"][0]["loc"] == ["body", "thing_id"] + assert data["detail"][0]["loc"] == ["body", "field_activity_id"] assert ( data["detail"][0]["msg"] - == f"Thing with ID {payload['thing_id']} does not exist." + == f"FieldActivity with ID {payload['field_activity_id']} does not exist." ) assert data["detail"][0]["type"] == "value_error" - assert data["detail"][0]["input"] == {"thing_id": payload["thing_id"]} + assert data["detail"][0]["input"] == { + "field_activity_id": payload["field_activity_id"] + } # ============= Patch tests for samples ============================================= -def test_patch_sample(sample): +def test_patch_sample(water_chemistry_sample, groundwater_level_field_activity): """ Test updating a sample. """ payload = { - "sampler_name": "test sample b", - "sample_method": "continuous", + "field_activity_id": groundwater_level_field_activity.id, + # "field_event_contact_id": third_contact.id, "sample_date": "2025-01-02T00:00:00Z", + "sample_name": "patched sample name", + "sample_matrix": "soil", + "sample_method": "bailer", "release_status": "private", + "qc_type": "Split", + "notes": "patched notes", + "depth_top": 10.0, + "depth_bottom": 20.0, } - response = client.patch(f"/sample/{sample.id}", json=payload) + response = client.patch(f"/sample/{water_chemistry_sample.id}", json=payload) assert response.status_code == 200 data = response.json() - assert data["id"] == sample.id - assert data["sampler_name"] == payload["sampler_name"] - assert data["sample_date"] == payload["sample_date"] - assert data["sample_method"] == payload["sample_method"] - assert data["release_status"] == payload["release_status"] + for key, value in payload.items(): + assert data[key] == value # rollback after updating the sample - cleanup_patch_test(Sample, payload, sample) + cleanup_patch_test(Sample, payload, water_chemistry_sample) -def test_patch_sample_404_not_found(sample): +def test_patch_sample_404_not_found(water_chemistry_sample): """ Test updating a sample that does not exist """ @@ -207,103 +221,121 @@ def test_patch_sample_404_not_found(sample): assert data["detail"] == "Sample with ID 999 not found." -def test_409_patch_sample_invalid_field_sample_id(sample, second_sample): +def test_409_patch_sample_invalid_sample_name( + water_chemistry_sample, groundwater_level_sample +): """ - Test updating a sample with an invalid field_sample_id. + Test updating a sample with an invalid sample_name. """ payload = { - "field_sample_id": sample.field_sample_id, # This should already exist + "sample_name": groundwater_level_sample.sample_name, # This should already exist } response = client.patch( - f"/sample/{second_sample.id}", + f"/sample/{water_chemistry_sample.id}", json=payload, ) data = response.json() assert response.status_code == 409 - assert data["detail"][0]["loc"] == ["body", "field_sample_id"] + assert data["detail"][0]["loc"] == ["body", "sample_name"] assert ( data["detail"][0]["msg"] - == f"Sample with field_sample_id {payload['field_sample_id']} already exists." + == f"Sample with sample_name {payload['sample_name']} already exists." ) assert data["detail"][0]["type"] == "value_error" - assert data["detail"][0]["input"] == {"field_sample_id": sample.field_sample_id} + assert data["detail"][0]["input"] == { + "sample_name": groundwater_level_sample.sample_name + } -def test_409_patch_sample_invalid_thing_id(sample): +def test_409_patch_sample_invalid_field_activity_id(water_chemistry_sample): """ - Test updating a sample with an invalid thing_id. + Test updating a sample with an invalid field_activity_id. """ payload = { - "thing_id": 9999999, + "field_activity_id": 9999999, } response = client.patch( - f"/sample/{sample.id}", + f"/sample/{water_chemistry_sample.id}", json=payload, ) data = response.json() assert response.status_code == 409 - assert data["detail"][0]["loc"] == ["body", "thing_id"] + assert data["detail"][0]["loc"] == ["body", "field_activity_id"] assert ( data["detail"][0]["msg"] - == f"Thing with ID {payload['thing_id']} does not exist." + == f"FieldActivity with ID {payload['field_activity_id']} does not exist." ) assert data["detail"][0]["type"] == "value_error" - assert data["detail"][0]["input"] == {"thing_id": payload["thing_id"]} + assert data["detail"][0]["input"] == { + "field_activity_id": payload["field_activity_id"] + } # ============= Get tests for samples ============================================= -def test_get_samples(sample, water_well_thing): +def test_get_samples(water_chemistry_sample, groundwater_level_sample): """ Test retrieving samples """ response = client.get("/sample") assert response.status_code == 200 data = response.json() - assert len(data["items"]) == 1 - assert data["items"][0]["id"] == sample.id - assert data["items"][0]["created_at"] == sample.created_at.isoformat().replace( - "+00:00", "Z" - ) - assert data["items"][0]["thing"]["id"] == water_well_thing.id - assert data["items"][0]["sample_type"] == sample.sample_type - assert data["items"][0]["field_sample_id"] == sample.field_sample_id - assert data["items"][0]["sample_date"] == sample.sample_date - assert data["items"][0]["release_status"] == sample.release_status - assert data["items"][0]["sampler_name"] == sample.sampler_name - assert data["items"][0]["qc_sample"] == sample.qc_sample - assert data["items"][0]["sensor_id"] == sample.sensor_id - assert data["items"][0]["sample_matrix"] == sample.sample_matrix - assert data["items"][0]["sample_method"] == sample.sample_method - assert data["items"][0]["duplicate_sample_number"] == sample.duplicate_sample_number - assert data["items"][0]["sample_top"] == sample.sample_top - assert data["items"][0]["sample_bottom"] == sample.sample_bottom - - -def test_get_sample_by_id(sample, water_well_thing): + assert len(data["items"]) == 2 + + for item in data["items"]: + assert "id" in item + assert "created_at" in item + assert "release_status" in item + assert "thing" in item + assert "field_event" in item + assert "field_activity" in item + assert "field_activity_id" in item + assert "contact" in item + assert "field_event_contact_id" in item + assert "sample_date" in item + assert "sample_name" in item + assert "sample_matrix" in item + assert "sample_method" in item + assert "qc_type" in item + assert "depth_top" in item + assert "depth_bottom" in item + assert "notes" in item + assert "release_status" in item + + +def test_get_sample_by_id( + water_chemistry_sample, + water_chemistry_field_activity, + field_event, + water_well_thing, + field_event_contact, +): """ Test retrieving a sample by its ID. """ - response = client.get(f"/sample/{sample.id}") + response = client.get(f"/sample/{water_chemistry_sample.id}") assert response.status_code == 200 data = response.json() - assert data["id"] == sample.id - assert data["created_at"] == sample.created_at.isoformat().replace("+00:00", "Z") + assert data["id"] == water_chemistry_sample.id + assert data["created_at"] == water_chemistry_sample.created_at.isoformat().replace( + "+00:00", "Z" + ) assert data["thing"]["id"] == water_well_thing.id - assert data["sample_type"] == sample.sample_type - assert data["field_sample_id"] == sample.field_sample_id - assert data["sample_date"] == sample.sample_date - assert data["release_status"] == sample.release_status - assert data["sampler_name"] == sample.sampler_name - assert data["qc_sample"] == sample.qc_sample - assert data["sensor_id"] == sample.sensor_id - assert data["sample_matrix"] == sample.sample_matrix - assert data["sample_method"] == sample.sample_method - assert data["duplicate_sample_number"] == sample.duplicate_sample_number - assert data["sample_top"] == sample.sample_top - assert data["sample_bottom"] == sample.sample_bottom - - -def test_get_sample_by_id_404_not_found(sample): + assert data["field_event"]["id"] == field_event.id + assert data["field_activity"]["id"] == water_chemistry_field_activity.id + assert data["field_activity_id"] == water_chemistry_field_activity.id + assert data["field_event_contact_id"] == field_event_contact.id + assert data["sample_date"] == water_chemistry_sample.sample_date + assert data["sample_name"] == water_chemistry_sample.sample_name + assert data["sample_matrix"] == water_chemistry_sample.sample_matrix + assert data["sample_method"] == water_chemistry_sample.sample_method + assert data["qc_type"] == water_chemistry_sample.qc_type + assert data["notes"] == water_chemistry_sample.notes + assert data["depth_top"] == water_chemistry_sample.depth_top + assert data["depth_bottom"] == water_chemistry_sample.depth_bottom + assert data["release_status"] == water_chemistry_sample.release_status + + +def test_get_sample_by_id_404_not_found(water_chemistry_sample): """ Test retrieving a sample that does not exist. """ @@ -316,18 +348,18 @@ def test_get_sample_by_id_404_not_found(sample): # DELETE tests ================================================================= -def test_delete_sample(second_sample): - response = client.delete(f"/sample/{second_sample.id}") +def test_delete_sample(sample_to_delete): + response = client.delete(f"/sample/{sample_to_delete.id}") assert response.status_code == 204 # verify the sample is deleted - response = client.get(f"/sample/{second_sample.id}") + response = client.get(f"/sample/{sample_to_delete.id}") assert response.status_code == 404 data = response.json() - assert data["detail"] == f"Sample with ID {second_sample.id} not found." + assert data["detail"] == f"Sample with ID {sample_to_delete.id} not found." -def test_delete_sample_404_not_found(second_sample): +def test_delete_sample_404_not_found(sample_to_delete): bad_sample_id = 999999 response = client.delete(f"/sample/{bad_sample_id}") assert response.status_code == 404 diff --git a/transfers/transfer.py b/transfers/transfer.py index 5d5be3cbc..d71c1c869 100644 --- a/transfers/transfer.py +++ b/transfers/transfer.py @@ -131,6 +131,17 @@ def main_transfer(): message("TRANSFERRING WATER LEVELS") transfer_water_levels(sess) + """ + Developer's notes + + When transfering water chemistry data use the qc_type field to indicate + normal/blanks/duplicates instead of what comes from LU_SampleType. Use + those values, however, to map to the standard qc_type fields if applicable + (i.e. not applicable when sample type is "Soil or rock sample" or + "Precipitation," but is applicable when sample type is "Equipment blank" + or "Field duplicate") + """ + if init or transfer_link_ids_flag: message("TRANSFERRING LINK IDS") transfer_link_ids(sess) diff --git a/transfers/waterlevels_transfer.py b/transfers/waterlevels_transfer.py index 53eff8d0c..2edf71df6 100644 --- a/transfers/waterlevels_transfer.py +++ b/transfers/waterlevels_transfer.py @@ -19,12 +19,13 @@ import pandas as pd -from db import Thing, Sample, Observation +from db import Thing, Sample, Observation, FieldEvent, FieldActivity from transfers.util import ( filter_to_valid_point_ids, logger, read_csv, convert_mt_to_utc, + lu_to_lexicon_map, ) @@ -64,31 +65,85 @@ def transfer_water_levels(session): ) continue - sample = Sample() - sample.sampler_name = "unknown" - sample.sample_type = "groundwater level" + release_status = "public" if row.PublicRelease else "private" - sample.field_sample_id = str(uuid.uuid4()) - sample.sample_date = dt_utc - sample.thing = thing - session.add(sample) + """ + Developer's notes - obs = Observation() + Assumes for manual water levels that the date/time of the water level + measurement is the same as the date/time of the field event. + """ - # TODO: this needs to be resolved - obs.sensor_id = 1 + if pd.isna(row.MeasuringAgency): + collecting_organization = "Unknown" + else: + collecting_organization = row.MeasuringAgency - # TODO: this needs to be implemented - # obs.nma_pk_observation = row.GlobalID + if pd.isna(row.MeasuredBy): + sampler_name = "Unknown" + else: + sampler_name = row.MeasuredBy + + field_event = FieldEvent( + thing=thing, + event_date=dt_utc, + collecting_organization=collecting_organization, + release_status=release_status, + ) + + session.add(field_event) + + field_activity = FieldActivity( + field_event=field_event, + activity_type="groundwater level", + release_status=release_status, + ) + session.add(field_activity) + + if not pd.isna(row.MeasurementMethod): + sample_method = lu_to_lexicon_map[ + f"LU_MeasurementMethod:{row.MeasurementMethod}" + ] + else: + sample_method = "null placeholder" + + sample = Sample( + field_activity=field_activity, + sampler_name=sampler_name, + sample_date=dt_utc, + sample_matrix="water", + sample_name=str( + uuid.uuid4() + ), # TODO: should this stay as-is for water levels? since there are no lab-assigned names + sample_method=sample_method, + qc_type="Normal", + depth_top=None, + depth_bottom=None, + ) + session.add(sample) - obs.sample = sample - obs.observation_datetime = dt - obs.value = row.DepthToWater - obs.measuring_point_height = row.MPHeight - obs.observed_property = "groundwater level:groundwater level" - obs.unit = "ft" + # TODO: update for auto-collectors in the Sensor table, like e-probes + # update the deployment table here + sensor_id = None - session.add(obs) + if not pd.isna(row.LevelStatus): + level_status = lu_to_lexicon_map[f"LU_LevelStatus:{row.LevelStatus}"] + else: + level_status = None + + observation = Observation( + sensor_id=sensor_id, + sample=sample, + nma_pk_waterlevels=row.GlobalID, + value=row.DepthToWater, + measuring_point_height=row.MPHeight, + observed_property="groundwater level", + unit="ft", + level_status=level_status, + observation_datetime=dt_utc, + ) + + session.add(observation) session.commit()