diff --git a/api/asset.py b/api/asset.py index 49e6cee1a..db93a5d2b 100644 --- a/api/asset.py +++ b/api/asset.py @@ -14,10 +14,11 @@ # limitations under the License. # =============================================================================== -from fastapi import APIRouter, Depends, UploadFile, File, HTTPException +from fastapi import APIRouter, Depends, UploadFile, File from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select -from starlette.status import HTTP_201_CREATED +from sqlalchemy.exc import ProgrammingError +from starlette.status import HTTP_201_CREATED, HTTP_409_CONFLICT, HTTP_204_NO_CONTENT from api.pagination import CustomPage from core.dependencies import ( @@ -31,20 +32,51 @@ from db.asset import Asset, AssetThingAssociation from schemas.asset import AssetResponse, CreateAsset, UpdateAsset from services.audit_helper import audit_add -from services.crud_helper import model_patcher +from services.crud_helper import model_patcher, model_deleter +from services.query_helper import simple_get_by_id from services.gcs_helper import ( get_storage_bucket, gcs_upload, + gcs_remove, check_asset_exists, add_signed_url, ) +from services.exceptions_helper import PydanticStyleException router = APIRouter( prefix="/asset", tags=["asset"], dependencies=[Depends(viewer_function)] ) -# ======= Create ========= +def database_error_handler(payload: CreateAsset, error: ProgrammingError) -> None: + """ + Handle errors raised by the database when adding or updating a asset. + """ + + error_message = error.orig.args[0]["M"] + + if ( + error_message + == 'null value in column "thing_id" of relation "asset_thing_association" violates not-null constraint' + ): + """ + Developer's notes + + this error occurs because the thing_id is set by the Thing record that + is retrieved, so if there is no Thing with thing_id it tries to set + thing_id to None in the AssetThingAssociation table + """ + detail = { + "loc": ["body", "thing_id"], + "msg": f"Thing with ID {payload.thing_id} not found.", + "type": "value_error", + "input": {"thing_id": payload.thing_id}, + } + + raise PydanticStyleException(status_code=HTTP_409_CONFLICT, detail=[detail]) + + +# POST ========================================================================= @router.post( "/upload", status_code=HTTP_201_CREATED, dependencies=[Depends(admin_function)] ) @@ -60,42 +92,63 @@ async def upload_asset( @router.post("", status_code=HTTP_201_CREATED) async def add_asset( - user: admin_dependency, session: session_dependency, asset_data: CreateAsset + user: admin_dependency, + session: session_dependency, + asset_data: CreateAsset, + bucket=Depends(get_storage_bucket), ) -> AssetResponse: - data = asset_data.model_dump() - thing_id = data.pop("thing_id", None) - storage_path = data["storage_path"] + try: + data = asset_data.model_dump() + thing_id = data.pop("thing_id", None) - # check to see if an asset entry already exists for - # this storage path and thing_id - existing_asset = check_asset_exists(session, storage_path, thing_id=thing_id) - if existing_asset: - # If an asset already exists, return it - return existing_asset + storage_path = data["storage_path"] - data["storage_service"] = "gcs" - asset = Asset(**data) - audit_add(user, asset) + # check to see if an asset entry already exists for + # this storage path and thing_id + existing_asset = check_asset_exists(session, storage_path, thing_id=thing_id) + if existing_asset: + # If an asset already exists, return it + return existing_asset + + data["storage_service"] = "gcs" + asset = Asset(**data) + audit_add(user, asset) + + if thing_id: + assoc = AssetThingAssociation() + audit_add(user, assoc) + thing = session.get(Thing, thing_id) + assoc.thing = thing + assoc.asset = asset + session.add(assoc) + + session.add(asset) + session.commit() + session.refresh(asset) + + return asset + except ProgrammingError as e: + database_error_handler(asset_data, e) - if thing_id: - assoc = AssetThingAssociation() - audit_add(user, assoc) - thing = session.get(Thing, thing_id) - assoc.thing = thing - assoc.asset = asset - session.add(assoc) - - session.add(asset) - session.commit() - session.refresh(asset) - return asset + +# GET ========================================================================== + +""" +Developer's notes + +Do not generate signed urls when listing ALL assets. There is a reason to +generate signed urls when listing assets for a given `thing_id` because this +is used by the front end to display a gallery of images all at once. This is +the only case in which signed urls should be generated for a list of assets. A +signed url is always generated when retrieving assets individually +""" -# ======= Read ========= @router.get("") async def list_assets( - session: session_dependency, thing_id: int = None + session: session_dependency, + thing_id: int = None, ) -> CustomPage[AssetResponse]: """ List all assets or assets associated with a specific thing. @@ -110,7 +163,6 @@ def transformer(records: list[Asset]): if thing_id is not None: bucket = get_storage_bucket() records = [add_signed_url(ai, bucket) for ai in records] - return records return paginate(query=sql, conn=session, transformer=transformer) @@ -120,29 +172,18 @@ def transformer(records: list[Asset]): async def get_asset( asset_id: int, session: session_dependency, - thing_id: int = None, bucket=Depends(get_storage_bucket), ) -> AssetResponse: """ Retrieve an asset by its ID. """ - sql = select(Asset) - if thing_id: - sql = sql.join(AssetThingAssociation).where( - AssetThingAssociation.thing_id == thing_id - ) - else: - sql = sql.where(Asset.id == asset_id) - - asset = session.scalars(sql).one_or_none() - if not asset: - raise HTTPException(status_code=404, detail="Asset not found") + asset = simple_get_by_id(session, Asset, asset_id) add_signed_url(asset, bucket) return asset -# ======= Update ========= +# PATCH ======================================================================== @router.patch("/{asset_id}") async def update_asset( asset_id: int, @@ -156,4 +197,29 @@ async def update_asset( return model_patcher(session, Asset, asset_id, asset_data, user=user) +# DELETE ======================================================================= + + +@router.delete("/{asset_id}", status_code=HTTP_204_NO_CONTENT) +async def delete_asset( + asset_id: int, session: session_dependency, user: admin_dependency +): + return model_deleter(session, Asset, asset_id) + + +@router.delete( + "/{asset_id}/remove", + status_code=HTTP_204_NO_CONTENT, + dependencies=[Depends(admin_function)], +) +async def remove_asset( + asset_id: int, + session: session_dependency, + user: admin_dependency, + bucket=Depends(get_storage_bucket), +): + asset = simple_get_by_id(session, Asset, asset_id) + gcs_remove(asset.uri, bucket) + + # ============= EOF ============================================= diff --git a/api/contact.py b/api/contact.py index 0525e4126..00dec32c7 100644 --- a/api/contact.py +++ b/api/contact.py @@ -33,7 +33,6 @@ CreateAddress, CreateEmail, CreatePhone, - CreateThingAssociation, PhoneResponse, EmailResponse, AddressResponse, @@ -60,7 +59,7 @@ def database_error_handler( - payload: CreateThingAssociation, error: ProgrammingError + payload: CreateEmail | CreateContact | CreatePhone, error: ProgrammingError ) -> None: """ Handle errors raised by the database when adding or updating a sample. @@ -78,17 +77,6 @@ def database_error_handler( "type": "value_error", "input": {"thing_id": payload.thing_id}, } - - elif ( - error_message - == 'insert or update on table "thing_contact_association" violates foreign key constraint "thing_contact_association_contact_id_fkey"' - ): - detail = { - "loc": ["body", "contact_id"], - "msg": f"Contact with ID {payload.contact_id} not found.", - "type": "value_error", - "input": {"contact_id": payload.contact_id}, - } elif ( error_message == 'insert or update on table "email" violates foreign key constraint "email_contact_id_fkey"' diff --git a/api/observation.py b/api/observation.py index 177433053..888783836 100644 --- a/api/observation.py +++ b/api/observation.py @@ -14,23 +14,37 @@ # limitations under the License. # =============================================================================== from datetime import datetime - -from fastapi import APIRouter, Query -from fastapi_pagination.ext.sqlalchemy import paginate -from sqlalchemy import select -from starlette.status import HTTP_201_CREATED +from fastapi import APIRouter, Query, Request +from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT from api.pagination import CustomPage -from core.dependencies import session_dependency -from db import Sample -from db.observation import Observation +from core.dependencies import ( + session_dependency, + amp_admin_dependency, + admin_dependency, + amp_viewer_dependency, + viewer_dependency, +) +from db import Observation, adder from schemas.observation import ( CreateGroundwaterLevelObservation, GroundwaterLevelObservationResponse, CreateWaterChemistryObservation, + WaterChemistryObservationResponse, + CreateGeothermalObservation, + GeothermalObservationResponse, + ObservationResponse, + UpdateGroundwaterLevelObservation, + UpdateWaterChemistryObservation, + UpdateGeothermalObservation, +) +from services.crud_helper import model_deleter +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, ) -from services.observation_helper import add_observation -from services.query_helper import order_sort_filter router = APIRouter(prefix="/observation", tags=["observation"]) @@ -40,51 +54,96 @@ def add_groundwater_level_observation( obs_data: CreateGroundwaterLevelObservation, session: session_dependency, -): + user: amp_admin_dependency, +) -> GroundwaterLevelObservationResponse: """ Add a new groundwater observation to the database. """ - return add_observation(session, obs_data) + return adder(session, Observation, obs_data, user=user) @router.post("/water-chemistry", status_code=HTTP_201_CREATED) def add_water_chemistry_observation( obs_data: CreateWaterChemistryObservation, session: session_dependency, -): + user: amp_admin_dependency, +) -> WaterChemistryObservationResponse: """ Add a new water chemistry observation to the database. This endpoint is currently a placeholder and does not implement any functionality. """ - return add_observation(session, obs_data) + return adder(session, Observation, obs_data, user=user) -# -# @router.post("/geothermal", status_code=HTTP_201_CREATED) -# def add_geothermal_observation( -# obs_data: CreateGeothermalObservation | CreateGeothermalObservationDirect, -# session: session_dependency, -# ): -# """ -# Add a new geothermal observation to the database. -# This endpoint is currently a placeholder and does not implement any functionality. -# """ -# if isinstance(obs_data, CreateGeothermalObservationDirect): -# return direct_adder(session, GeothermalObservation, obs_data) -# else: -# return adder(session, GeothermalObservation, obs_data) +@router.post("/geothermal", status_code=HTTP_201_CREATED) +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 adder(session, Observation, obs_data, user=user) + + +# PATCH ======================================================================== + + +@router.patch("/groundwater-level/{observation_id}", status_code=HTTP_200_OK) +def update_groundwater_level_observation( + observation_id: int, + obs_data: UpdateGroundwaterLevelObservation, + session: session_dependency, + user: amp_admin_dependency, + request: Request, +) -> GroundwaterLevelObservationResponse: + """ + Update an existing groundwater level observation in the database. + """ + return observation_model_patcher(session, request, observation_id, obs_data, user) + + +@router.patch("/water-chemistry/{observation_id}", status_code=HTTP_200_OK) +def update_water_chemistry_observation( + observation_id: int, + obs_data: UpdateWaterChemistryObservation, + session: session_dependency, + user: amp_admin_dependency, + request: Request, +) -> WaterChemistryObservationResponse: + """ + Update an existing water chemistry 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) +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 ============================================== -@router.get( - "/groundwater-level", -) + + +@router.get("/groundwater-level", summary="Get groundwater level observations") def get_groundwater_level_observations( + request: Request, session: session_dependency, + user: amp_viewer_dependency, thing_id: int | None = None, sensor_id: int | None = None, sample_id: int | None = None, - polygon: str | None = None, start_time: datetime | None = None, end_time: datetime | None = None, sort: str | None = None, @@ -94,27 +153,174 @@ def get_groundwater_level_observations( """ Retrieve all groundwater level observations from the database. """ - sql = select(Observation) - sql = sql.where(Observation.observed_property == "groundwater level") - if thing_id is not None: - sql = sql.join(Sample) - sql = sql.where(Sample.thing_id == thing_id) - if sample_id is not None: - sql = sql.where(Observation.sample_id == sample_id) - if sensor_id is not None: - sql = sql.where(Observation.sensor_id == sensor_id) + 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( + "/groundwater-level/{observation_id}", + summary="Get groundwater level observation by ID", +) +def get_groundwater_level_observation_by_id( + session: session_dependency, + request: Request, + user: amp_viewer_dependency, + observation_id: int, +) -> GroundwaterLevelObservationResponse: + return get_observation_of_an_observation_class_by_id( + session=session, + request=request, + observation_id=observation_id, + ) + + +@router.get("/water-chemistry", summary="Get water chemistry observations") +def get_water_chemistry_observations( + request: Request, + session: session_dependency, + user: amp_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[WaterChemistryObservationResponse]: + """ + Retrieve all water chemistry 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_, + ) - if start_time: - sql = sql.where(Observation.observation_datetime >= start_time) - if end_time: - sql = sql.where(Observation.observation_datetime <= end_time) - sql = order_sort_filter(sql, Observation, sort, order, filter_) +@router.get( + "/water-chemistry/{observation_id}", summary="Get water chemistry observation by ID" +) +def get_water_chemistry_observation_by_id( + session: session_dependency, + request: Request, + user: amp_viewer_dependency, + observation_id: int, +) -> WaterChemistryObservationResponse: + return get_observation_of_an_observation_class_by_id( + session=session, + request=request, + observation_id=observation_id, + ) - if not order: - sql = sql.order_by(Observation.observation_datetime.desc()) - return paginate(query=sql, conn=session) +@router.get("/geothermal", summary="Get geothermal observations") +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") +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("", summary="Get all observations") +def get_all_observations( + request: Request, + session: session_dependency, + user: amp_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[ObservationResponse]: + 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("/{observation_id}", summary="Get an observation by its ID") +def get_observation_by_id( + session: session_dependency, user: amp_viewer_dependency, observation_id: int +) -> ObservationResponse: + return simple_get_by_id(session, Observation, observation_id) + + +# DELETE ======================================================================= + + +@router.delete( + "/{observation_id}", + summary="Delete an observation", + status_code=HTTP_204_NO_CONTENT, +) +def delete_observation( + session: session_dependency, user: amp_admin_dependency, observation_id: int +) -> None: + return model_deleter(session, Observation, observation_id) # ============= EOF ============================================= diff --git a/core/lexicon.json b/core/lexicon.json index 96aedb7c9..6113a7f72 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -11,10 +11,16 @@ {"category": "unit", "term": "W/m²", "definition": "watts per square meter"}, {"category": "unit", "term": "W/m·K", "definition": "watts per meter Kelvin"}, {"category": "unit", "term": "m²/s", "definition": "square meters per second"}, + {"category": "unit", "term": "deg C", "definition": "degree Celsius"}, + + {"category": "observed_property", "term": "groundwater level:groundwater level", "definition": "groundwater level measurement" }, + + {"category": "observed_property", "term": "geothermal:temperature", "definition": "Temperature measurement"}, + + {"category": "observed_property", "term": "water chemistry:pH", "definition": "pH"}, + {"category": "observed_property", "term": "water chemistry:Alkalinity as CaCO3", "definition": "Alkalinity as CaCO3"}, + - {"category": "observed_property", "term": "groundwater level", "definition": "groundwater level measurement" }, - {"category": "observed_property", "term": "pH", "definition": "pH"}, - {"category": "observed_property", "term": "Alkalinity as CaCO3", "definition": "Alkalinity as CaCO3"}, {"category": "release_status", "term": "draft", "definition": "draft version"}, {"category": "release_status", "term": "provisional", "definition": "provisional version"}, diff --git a/db/observation.py b/db/observation.py index a37c31101..1b49196ef 100644 --- a/db/observation.py +++ b/db/observation.py @@ -16,34 +16,17 @@ from sqlalchemy import ( ForeignKey, Integer, - TIMESTAMP, - PrimaryKeyConstraint, Float, DateTime, ) from sqlalchemy.orm import mapped_column, relationship -from db.base import Base, AuditMixin, ReleaseMixin, lexicon_term +from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term -class Observation(Base, AuditMixin, ReleaseMixin): - __tablename__ = "observation" - +class Observation(Base, AutoBaseMixin, ReleaseMixin): __versioned__ = {} - __table_args__ = ( - PrimaryKeyConstraint( - "id", - "observation_datetime", - ), - {}, - ) - - id = mapped_column( - Integer, - autoincrement=True, - ) - sample_id = mapped_column( Integer, ForeignKey("sample.id", ondelete="CASCADE"), @@ -59,15 +42,13 @@ class Observation(Base, AuditMixin, ReleaseMixin): DateTime(timezone=True), nullable=False, doc="Timestamp of the observation" ) observed_property = lexicon_term() - - # groundwater - depth_to_water = mapped_column( + value = mapped_column( Float, nullable=True, - doc="Depth to water level in ft below measuring point", - info={"unit": "ft"}, ) + unit = lexicon_term() + # groundwater measuring_point_height = mapped_column( Float, nullable=True, @@ -78,25 +59,12 @@ class Observation(Base, AuditMixin, ReleaseMixin): level_status = lexicon_term() # geothermal - depth = mapped_column( + observation_depth = mapped_column( Float, nullable=True, info={"unit": "feet"}, doc="Depth of the geothermal observation in feet", ) - temperature = mapped_column( - Float, - nullable=True, - info={"unit": "degC"}, - doc="Temperature of the geothermal observation in degrees Celsius", - ) - - # general observations - value = mapped_column( - Float, - nullable=True, - ) - unit = lexicon_term() sensor = relationship("Sensor") sample = relationship("Sample") diff --git a/schemas/asset.py b/schemas/asset.py index d30038a94..de8b37c10 100644 --- a/schemas/asset.py +++ b/schemas/asset.py @@ -13,7 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from pydantic import BaseModel, AwareDatetime +from pydantic import BaseModel + +from schemas import ORMBaseModel class BaseAsset(BaseModel): @@ -23,32 +25,23 @@ class BaseAsset(BaseModel): mime_type: str size: int uri: str - thing_id: int | None = None # -------- CREATE ---------- class CreateAsset(BaseAsset): - pass + thing_id: int | None = None # -------- RESPONSE -------- -class AssetResponse(BaseAsset): - id: int - # name: str - # label: str - # storage_service: str - # storage_path: str - # mime_type: str - # size: int - created_at: AwareDatetime +class AssetResponse(ORMBaseModel, BaseAsset): storage_service: str - uri: str signed_url: str | None = None # -------- UPDATE ---------- -class UpdateAsset(BaseAsset): - pass +class UpdateAsset(BaseModel): + name: str | None = None + label: str | None = None # ============= EOF ============================================= diff --git a/schemas/observation.py b/schemas/observation.py index fe6bb065b..4fb0474f3 100644 --- a/schemas/observation.py +++ b/schemas/observation.py @@ -14,8 +14,17 @@ # limitations under the License. # =============================================================================== from datetime import timezone -from pydantic import BaseModel, AwareDatetime, PastDatetime, field_validator +from pydantic import ( + BaseModel, + AwareDatetime, + PastDatetime, + field_validator, + model_validator, +) from typing import Annotated +from typing_extensions import Self + +from schemas import ORMBaseModel # class GeothermalMixin: @@ -27,6 +36,9 @@ class ValidateObservation(BaseModel): + _observation_class: str + observed_property: str + observation_datetime: AwareDatetime @field_validator("observation_datetime", check_fields=False) def convert_observation_datetime_to_utc( @@ -43,66 +55,109 @@ 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(ValidateObservation): observation_datetime: Annotated[AwareDatetime, PastDatetime()] sample_id: int | None = None - field_sample_id: str | None = None sensor_id: int observed_property: str release_status: str + value: float | None + unit: str | None class CreateGroundwaterLevelObservation(CreateBaseObservation): - depth_to_water: float + _observation_class: str = "groundwater level" measuring_point_height: float level_status: str class CreateWaterChemistryObservation(CreateBaseObservation): - value: float - unit: str + _observation_class: str = "water chemistry" -# -# -# class CreateGroundwaterLevelObservation(ChildObservationModel, GroundwaterLevelMixin): -# pass -# -# -# class CreateGeothermalObservation(ChildObservationModel, GeothermalMixin): -# pass -# -# -# class CreateGroundwaterLevelObservationDirect(CreateObservation, GroundwaterLevelMixin): -# pass -# -# -# class CreateGeothermalObservationDirect(CreateObservation, GeothermalMixin): -# pass +class CreateGeothermalObservation(CreateBaseObservation): + _observation_class: str = "geothermal" + observation_depth: float + + +# -------- UPDATE ------------ + + +class UpdateBaseObservation(ValidateObservation): + observation_datetime: Annotated[AwareDatetime, PastDatetime()] | None = None + sample_id: int | None = None + sensor_id: int | None = None + observed_property: str | None = None + release_status: str | None = None + value: float | None | None = None + unit: str | None = None + + +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" + + +class UpdateGeothermalObservation(UpdateBaseObservation): + _observation_class: str = "geothermal" + observation_depth: float | None = None # -------- RESPONSE ---------- -class BaseObservationResponse(BaseModel): - id: int +class BaseObservationResponse(ORMBaseModel): sample_id: int sensor_id: int observation_datetime: AwareDatetime observed_property: str - created_at: AwareDatetime release_status: str + 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: float + depth_to_water_bgs: float | None + measuring_point_height: float | None level_status: str | None + @model_validator(mode="before") + def calculate_depth_to_water_bgs(self: Self) -> Self: + depth_to_water = self.value + measuring_point_height = self.measuring_point_height + if depth_to_water is not None and measuring_point_height is not None: + self.depth_to_water_bgs = depth_to_water - measuring_point_height + else: + self.depth_to_water_bgs = None + return self -class GeothermalObservationResponse(BaseObservationResponse): - temperature: float - depth: float +class WaterChemistryObservationResponse(BaseObservationResponse): + pass + + +class GeothermalObservationResponse(BaseObservationResponse): + observation_depth: float | None class ObservationResponse( @@ -114,5 +169,4 @@ class ObservationResponse( """ -# -------- UPDATE ---------- # ============= EOF ============================================= diff --git a/services/gcs_helper.py b/services/gcs_helper.py index 184e41267..1ef8661c6 100644 --- a/services/gcs_helper.py +++ b/services/gcs_helper.py @@ -76,6 +76,11 @@ def gcs_upload(file: UploadFile, bucket: storage.Bucket = None): return url, blob_name +def gcs_remove(uri: str, bucket: storage.Bucket): + blob = bucket.blob(uri) + blob.delete() + + def add_signed_url(asset: Asset, bucket: storage.Bucket): asset.signed_url = bucket.blob(asset.storage_path).generate_signed_url( version="v4", diff --git a/services/observation_helper.py b/services/observation_helper.py index 43b46abd4..8f4561c32 100644 --- a/services/observation_helper.py +++ b/services/observation_helper.py @@ -1,58 +1,144 @@ -# =============================================================================== -# Copyright 2025 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== from pydantic import BaseModel -from sqlalchemy import select from sqlalchemy.orm import Session +from starlette.status import HTTP_404_NOT_FOUND +from fastapi_pagination.ext.sqlalchemy import paginate +from sqlalchemy import select +from typing import List +from fastapi import Request, Query +from datetime import datetime -from db import Base, Observation, Sample - - -def add_observation(session: Session, data: BaseModel) -> Base: - - if isinstance(data, BaseModel): - data = data.model_dump(exclude_unset=True) - - # if 'thing_id' in data: - # thing_id = data.pop('thing_id') - # if 'sample_id' not in data: - # sample = Sample(thing_id=thing_id, - # collection_method=data.get('collection_method', 'manual'), - # collection_timestamp=data.get('observation_datetime')) - # session.add(sample) - # data['sample'] = sample - # else: - # raise ValueError('Cannot specify both thing_id and sample_id') - if "field_sample_id" in data: - field_sample_id = data.pop("field_sample_id") - data.pop( - "sample_id", None - ) # Ensure sample_id is not set if field_sample_id is used - - sql = select(Sample).where(Sample.field_sample_id == field_sample_id) - sample = session.scalar(sql) - if not sample: - raise ValueError(f"Sample with id {field_sample_id} does not exist") - data["sample"] = sample - obj = Observation(**data) - - session.add(obj) - session.commit() - session.refresh(obj) +from core.dependencies import session_dependency +from db import Observation, Sample +from schemas.observation import ( + ObservationResponse, + WaterChemistryObservationResponse, + 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: + 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] + if len(path_components) >= 3: + # observation class specified in path + observation_class_in_path = path_components[2] + + observation_class = observation_class_in_path.replace("-", " ") + return observation_class + + +def get_observations( + request: Request, + session: session_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), +) -> ( + List[ObservationResponse] + | List[WaterChemistryObservationResponse] + | List[GeothermalObservationResponse] + | List[GroundwaterLevelObservationResponse] +): + """ + Retrieve all observations + """ + observation_class = get_observation_class_from_request(request) + + sql = select(Observation) + if thing_id is not None: + sql = sql.join(Sample) + sql = sql.where(Sample.thing_id == thing_id) + if sample_id is not None: + sql = sql.where(Observation.sample_id == sample_id) + if sensor_id is not None: + sql = sql.where(Observation.sensor_id == sensor_id) + + if start_time: + sql = sql.where(Observation.observation_datetime >= start_time) + if end_time: + 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}:%")) + + sql = order_sort_filter(sql, Observation, sort, order, filter_) + + if not order: + sql = sql.order_by(Observation.observation_datetime.desc()) - return obj + return paginate(query=sql, conn=session) -# ============= EOF ============================================= +def verify_observed_property_corresponds_with_observation_class( + observation: Observation, request: Request +): + observation_class = get_observation_class_from_request(request) + + observed_property = observation.observed_property + colon_index = observed_property.find(":") + actual_observation_class = observed_property[:colon_index] + + if actual_observation_class != observation_class: + raise PydanticStyleException( + status_code=HTTP_404_NOT_FOUND, + detail=[ + { + "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.", + } + ], + ) + + +def get_observation_of_an_observation_class_by_id( + session: Session, request: Request, observation_id: int +) -> Observation: + """ + Retrieve an observation by its ID. + """ + observation = simple_get_by_id(session, Observation, observation_id) + + verify_observed_property_corresponds_with_observation_class(observation, request) + + return observation + + +def observation_model_patcher( + session: Session, + request: Request, + observation_id: int, + payload: BaseModel, + user: dict, +) -> Observation: + """ + Patch an observation model with the provided payload. + """ + # 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) + + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(observation, key, value) + + if user: + observation.updated_by_id = user["sub"] + observation.updated_by_name = user["name"] + + session.commit() + session.refresh(observation) + return observation diff --git a/tests/conftest.py b/tests/conftest.py index 957540ea2..0fc57fb1f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -166,3 +166,111 @@ def asset(): yield asset session.close() + + +@pytest.fixture(scope="function") +def asset_with_associated_thing(thing): + with session_ctx() as session: + asset = Asset( + name="Test Asset with thing", + label="test label", + mime_type="application/pdf", + size=12345, + storage_service="mock_service", + storage_path="mock/path/to/asset", + uri="https://storage.googleapis.com/mock-bucket/mock-asset", + ) + session.add(asset) + session.commit() + session.refresh(asset) + + association = AssetThingAssociation(asset_id=asset.id, thing_id=thing.id) + session.add(association) + session.commit() + session.refresh(association) + + yield asset + session.delete(asset) + session.delete(association) + session.commit() + session.close() + + +@pytest.fixture(scope="function") +def second_asset(): + with session_ctx() as session: + asset = Asset( + name="Second test asset", + label="Second test label", + mime_type="application/pdf", + size=2468, + storage_service="mock_service", + storage_path="mock/path/to/asset", + uri="https://storage.googleapis.com/mock-bucket/second-mock-asset", + ) + session.add(asset) + session.commit() + session.refresh(asset) + yield asset + session.delete(asset) + session.close() + + +@pytest.fixture(scope="session") +def groundwater_level_observation(sensor, sample): + with session_ctx() as session: + observation = Observation( + observation_datetime="2025-01-01T00:04:00Z", + sample_id=sample.id, + sensor_id=sensor.id, + observed_property="groundwater level:groundwater level", + release_status="draft", + value=10.0, + unit="ft", + measuring_point_height=5.0, + level_status="normal", + ) + session.add(observation) + session.commit() + yield observation + + session.close() + + +@pytest.fixture(scope="session") +def water_chemistry_observation(sensor, sample): + with session_ctx() as session: + observation = Observation( + observation_datetime="2025-01-01T00:03:00Z", + sample_id=sample.id, + sensor_id=sensor.id, + observed_property="water chemistry:pH", + release_status="draft", + value=4.0, + unit="dimensionless", + ) + session.add(observation) + session.commit() + yield observation + + session.close() + + +@pytest.fixture(scope="session") +def geothermal_observation(sensor, sample): + with session_ctx() as session: + observation = Observation( + observation_datetime="2025-01-01T00:02:00Z", + sample_id=sample.id, + sensor_id=sensor.id, + observed_property="geothermal:temperature", + release_status="draft", + value=20.0, + unit="deg C", + observation_depth=200.0, + ) + session.add(observation) + session.commit() + yield observation + + session.close() diff --git a/tests/test_asset.py b/tests/test_asset.py index 8c146a17d..e56b463a4 100644 --- a/tests/test_asset.py +++ b/tests/test_asset.py @@ -17,9 +17,12 @@ from core.app import app from core.dependencies import viewer_function, admin_function, editor_function from db import Asset -from tests import client, cleanup_post_test, override_authentication +from tests import client, cleanup_post_test, override_authentication, cleanup_patch_test import pytest +from unittest.mock import patch + +# CLASSES, FIXTURES, AND FUNCTIONS ============================================= class MockBlob: @@ -29,6 +32,9 @@ def upload_from_file(self, *args, **kwargs): def generate_signed_url(self, *args, **kwargs): return "https://storage.googleapis.com/mock-bucket/mock-asset" + def delete(self, *args, **kwargs): + pass + class MockStorageBucket: name = "mock-bucket" @@ -63,6 +69,9 @@ def override_dependency_fixture(): app.dependency_overrides = {} +# POST & UPLOAD tests ========================================================== + + def test_upload_asset(): path = "tests/data/riochama.png" @@ -78,61 +87,166 @@ def test_upload_asset(): def test_add_asset(thing): - resp = client.post( - "/asset", - json={ - "thing_id": thing.id, - "name": "riochama.png", - "storage_service": "mock_service", - "storage_path": "mock/path/to/asset", - "uri": "https://storage.googleapis.com/mock-bucket/mock-asset", - "mime_type": "image/png", - "size": 12345, - }, - ) - + payload = { + "thing_id": thing.id, + "name": "test_asset.png", + "label": "Test Asset", + "uri": "https://storage.googleapis.com/mock-bucket/mock-asset", + "storage_service": "mock_service", + "storage_path": "mock/path/to/asset/test_asset.png", + "mime_type": "image/png", + "size": 12345, + } + resp = client.post("/asset", json=payload) assert resp.status_code == 201 data = resp.json() - assert data["name"] == "riochama.png" + assert "id" in data + assert "created_at" in data + assert data["name"] == payload["name"] + assert data["label"] == payload["label"] + assert data["uri"] == payload["uri"] + assert data["storage_service"] == "gcs" + assert data["storage_path"] == payload["storage_path"] + assert data["mime_type"] == payload["mime_type"] + assert data["size"] == payload["size"] + assert data["signed_url"] == None cleanup_post_test(Asset, data["id"]) -def test_add_asset_with_label(thing): - resp = client.post( - "/asset", - json={ - "thing_id": thing.id, - "name": "test_asset.png", - "label": "Test Asset", - "uri": "https://storage.googleapis.com/mock-bucket/mock-asset", - "storage_service": "mock_service", - "storage_path": "mock/path/to/asset/test_asset.png", - "mime_type": "image/png", - "size": 12345, - }, - ) - assert resp.status_code == 201 +def test_add_asset_409_bad_thing_id(thing): + bad_thing_id = 99999 + payload = { + "thing_id": bad_thing_id, + "name": "test_asset.png", + "label": "Test Asset", + "uri": "https://storage.googleapis.com/mock-bucket/mock-asset", + "storage_service": "mock_service", + "storage_path": "mock/path/to/asset/test_asset.png", + "mime_type": "image/png", + "size": 12345, + } + resp = client.post("/asset", json=payload) + assert resp.status_code == 409 data = resp.json() - assert data["name"] == "test_asset.png" - assert data["label"] == "Test Asset" + assert data["detail"][0]["loc"] == ["body", "thing_id"] + assert data["detail"][0]["msg"] == f"Thing with ID {bad_thing_id} not found." + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"thing_id": bad_thing_id} - cleanup_post_test(Asset, data["id"]) + +# GET tests ==================================================================== -def test_get_asset(asset): +def test_get_assets(asset, asset_with_associated_thing): + response = client.get("/asset") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 2 + assert data["items"][0]["id"] == asset.id + assert data["items"][0]["created_at"] == asset.created_at.isoformat().replace( + "+00:00", "Z" + ) + assert data["items"][0]["name"] == asset.name + assert data["items"][0]["label"] == asset.label + assert data["items"][0]["storage_path"] == asset.storage_path + assert data["items"][0]["mime_type"] == asset.mime_type + assert data["items"][0]["size"] == asset.size + assert data["items"][0]["uri"] == asset.uri + assert data["items"][0]["storage_service"] == asset.storage_service + assert data["items"][0]["signed_url"] == None + + assert data["items"][1]["id"] == asset_with_associated_thing.id + assert data["items"][1]["signed_url"] == None + + +def test_get_assets_thing_id(asset_with_associated_thing, thing): + with patch("api.asset.get_storage_bucket", return_value=MockStorageBucket()): + query_parameters = {"thing_id": thing.id} + response = client.get("/asset", params=query_parameters) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["id"] == asset_with_associated_thing.id + assert ( + data["items"][0]["signed_url"] + == mock_storage_bucket().blob().generate_signed_url() + ) + + +def test_get_asset_by_id(asset): response = client.get(f"/asset/{asset.id}") assert response.status_code == 200 data = response.json() assert data["id"] == asset.id + assert data["created_at"] == asset.created_at.isoformat().replace("+00:00", "Z") assert data["name"] == asset.name - assert data["uri"] == MockBlob().generate_signed_url() + assert data["label"] == asset.label + assert data["storage_path"] == asset.storage_path + assert data["mime_type"] == asset.mime_type + assert data["size"] == asset.size + assert data["uri"] == asset.uri + assert data["storage_service"] == asset.storage_service + assert data["signed_url"] == MockBlob().generate_signed_url() + + +def test_get_asset_by_id_404_not_found(asset): + bad_id = 99999 + response = client.get(f"/asset/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Asset with ID {bad_id} not found." -def test_get_asset_not_found(): - response = client.get("/asset/9999") +# PATCH tests ================================================================== + + +def test_patch_asset(asset): + payload = {"name": "patched name", "label": "patched label"} + response = client.patch(f"/asset/{asset.id}", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["id"] == asset.id + assert data["name"] == payload["name"] + assert data["label"] == payload["label"] + + cleanup_patch_test(Asset, payload, asset) + + +def test_patch_asset_404_not_found(asset): + bad_id = 99999 + payload = {"name": "patched name", "label": "patched label"} + response = client.patch(f"/asset/{bad_id}", json=payload) + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Asset with ID {bad_id} not found." + + +# DELETE tests ================================================================= + + +def test_delete_asset(second_asset): + response = client.delete(f"/asset/{second_asset.id}") + assert response.status_code == 204 + + # verify deletion + response = client.get(f"/asset/{second_asset.id}") assert response.status_code == 404 - assert response.json() == {"detail": "Asset not found"} + data = response.json() + assert data["detail"] == f"Asset with ID {second_asset.id} not found." + + +def test_delete_asset_404_not_found(second_asset): + bad_id = 99999 + response = client.delete(f"/asset/{bad_id}/remove") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Asset with ID {bad_id} not found." + + +def test_remove_asset(second_asset): + response = client.delete(f"/asset/{second_asset.id}/remove") + assert response.status_code == 204 # ============= EOF ============================================= diff --git a/tests/test_observation.py b/tests/test_observation.py index 5a8172e65..f1c94483c 100644 --- a/tests/test_observation.py +++ b/tests/test_observation.py @@ -13,107 +13,426 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== - -from tests import client +from db import Observation +from db.engine import session_ctx +from core.dependencies import ( + amp_admin_function, + admin_function, + amp_viewer_function, + viewer_function, +) +from main import app +from tests import client, cleanup_post_test, override_authentication, cleanup_patch_test import pytest +@pytest.fixture(scope="module", autouse=True) +def override_authentication_dependency_fixture(): + app.dependency_overrides[amp_admin_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[admin_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[amp_viewer_function] = override_authentication() + app.dependency_overrides[viewer_function] = override_authentication() + + yield + + app.dependency_overrides = {} + + +@pytest.fixture(scope="function") +def observation_to_delete(sample, sensor): + with session_ctx() as session: + observation = Observation( + observation_datetime="2019-01-01T00:03:00Z", + sample_id=sample.id, + sensor_id=sensor.id, + observed_property="water chemistry:pH", + release_status="draft", + value=4.0, + unit="dimensionless", + ) + session.add(observation) + session.commit() + yield observation + + # ============= Post tests ================= -def test_add_water_chemistry_observation(location, thing, sample, sensor): - response = client.post( - "/observation/water-chemistry", - json={ - "observation_datetime": "2025-01-01T00:00:00Z", - "release_status": "draft", - "value": 7.5, - "unit": "dimensionless", - "sample_id": sample.id, - "sensor_id": sensor.id, - "observed_property": "pH", - }, +def test_add_water_chemistry_observation(sample, sensor): + payload = { + "observation_datetime": "2025-01-01T00:00:00Z", + "release_status": "draft", + "value": 7.5, + "unit": "dimensionless", + "sample_id": sample.id, + "sensor_id": sensor.id, + "observed_property": "pH", + } + response = client.post("/observation/water-chemistry", json=payload) + data = response.json() + assert response.status_code == 201 + + assert data["observation_datetime"] == payload["observation_datetime"] + assert data["release_status"] == payload["release_status"] + assert data["value"] == payload["value"] + assert data["unit"] == payload["unit"] + assert data["sample_id"] == payload["sample_id"] + assert data["sensor_id"] == payload["sensor_id"] + assert data["observed_property"] == payload["observed_property"] + + cleanup_post_test(Observation, data["id"]) + + +def test_add_groundwater_level_observation(sample, sensor): + payload = { + "observation_datetime": "2025-01-01T00:00:00Z", + "release_status": "draft", + "value": 101, + "measuring_point_height": 53, + "sample_id": sample.id, + "sensor_id": sensor.id, + "level_status": "normal", + "observed_property": "groundwater level", + "unit": "ft", + } + response = client.post("/observation/groundwater-level", json=payload) + data = response.json() + assert response.status_code == 201 + + assert data["observation_datetime"] == payload["observation_datetime"] + assert data["release_status"] == payload["release_status"] + assert data["value"] == payload["value"] + assert data["measuring_point_height"] == payload["measuring_point_height"] + assert data["sensor_id"] == payload["sensor_id"] + assert data["level_status"] == payload["level_status"] + assert data["observed_property"] == payload["observed_property"] + assert ( + data["depth_to_water_bgs"] + == payload["value"] - payload["measuring_point_height"] ) + + 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 data["value"] == 7.5 - assert data["unit"] == "dimensionless" + 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_groundwater_observation(location, thing, sample, sensor): - response = client.post( - "/observation/groundwater-level", - json={ - "observation_datetime": "2025-01-01T00:00:00Z", - "release_status": "draft", - "depth_to_water": 101, - "measuring_point_height": 53, - "sample_id": sample.id, - "sensor_id": sensor.id, - "level_status": "normal", - "observed_property": "groundwater level", - }, + +# PATCH tests ================================================================== + + +def test_patch_groundwater_level_observation(groundwater_level_observation): + payload = {"measuring_point_height": 3} + response = client.patch( + f"/observation/groundwater-level/{groundwater_level_observation.id}", + json=payload, ) data = response.json() - assert response.status_code == 201 + assert response.status_code == 200 - assert data["depth_to_water"] == 101 - assert data["measuring_point_height"] == 53 + assert data["measuring_point_height"] == payload["measuring_point_height"] + cleanup_patch_test(Observation, payload, groundwater_level_observation) -# def test_add_geothermal_observation(): -# response = client.post( -# "/observation/geothermal", -# json={ -# "observation_id": 1, -# "observation_datetime": "2025-01-01T00:00:00Z", -# "depth": 100, -# "temperature": 25.5, -# }, -# ) -# assert response.status_code == 201 -# data = response.json() -# assert data["observation_id"] == 1 +def test_patch_groundwater_level_observation_404_not_found( + groundwater_level_observation, +): + bad_id = 99999 + payload = {"measuring_point_height": 3} + response = client.patch(f"/observation/groundwater-level/{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_groundwater_level_observation_404_wrong_observation_class( + water_chemistry_observation, geothermal_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() + + if obs.observed_property == "geothermal:temperature": + observation_class = "geothermal" + else: + observation_class = "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." + ) + + +def test_patch_water_chemistry_observation(water_chemistry_observation): + payload = {"value": 8} + response = client.patch( + f"/observation/water-chemistry/{water_chemistry_observation.id}", + json=payload, + ) + data = response.json() + assert response.status_code == 200 -@pytest.mark.skip(reason="not implemented yet") -def test_add_geochemical_observation(): - response = client.post("/observation/geochemical", json={"observation_id": 1}) - assert response.status_code == 201 + assert data["value"] == payload["value"] + + cleanup_patch_test(Observation, payload, water_chemistry_observation) + + +def test_patch_water_chemistry_observation_404_not_found(water_chemistry_observation): + bad_id = 999999 + payload = {"value": 8} + response = client.patch(f"/observation/water-chemistry/{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_water_chemistry_observation_404_wrong_observation_class( + groundwater_level_observation, geothermal_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() + + if obs.observed_property == "geothermal:temperature": + observation_class = "geothermal" + else: + observation_class = "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." + ) + + +def test_patch_geothermal_observation(geothermal_observation): + payload = {"observation_depth": 4} + 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"] + + 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["observation_id"] == 1 + 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() + + if obs.observed_property == "groundwater level:groundwater level": + observation_class = "groundwater level" + else: + observation_class = "water chemistry" + + assert ( + data["detail"][0]["msg"] + == f"Observation with ID {obs.id} is not a geothermal observation. It is a {observation_class} observation." + ) # ============= Get tests ================= -# def test_get_observation_by_series_id(): -# response = client.get("/observation", params={"series_id": 1}) -# assert response.status_code == 200 -# data = response.json() -# assert "items" in data, "Expected 'items' in response" -# items = data["items"] -# assert len(items) > 0, "Expected at least one observation for the series" -# # assert isinstance(data, list), "Expected a list of observations" -# # assert len(data) == 1, "Expected at least one observation for the series" - - -# def test_get_groundwater_observation_by_thing_id(): -# response = client.get("/observation/groundwater-level", params={"thing_id": 1, -# "observed_property": "groundwater level"}) -# assert response.status_code == 200 -# data = response.json() -# assert "items" in data, "Expected 'items' in response" -# items = data["items"] -# assert ( -# len(items) > 0 -# ), "Expected at least one groundwater observation for the series" - - -# def test_get_geothermal_observation_by_series_id(): -# response = client.get("/observation/geothermal", params={"series_id": 1}) -# assert response.status_code == 200 -# data = response.json() -# assert "items" in data, "Expected 'items' in response" -# items = data["items"] -# assert len(items) > 0, "Expected at least one geothermal observation for the series" + + +def test_get_all_observations( + groundwater_level_observation, water_chemistry_observation, geothermal_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 + + +def test_get_observation_by_id( + groundwater_level_observation, water_chemistry_observation, geothermal_observation +): + for obs in ( + groundwater_level_observation, + water_chemistry_observation, + geothermal_observation, + ): + response = client.get(f"/observation/{obs.id}") + assert response.status_code == 200 + data = response.json() + + assert data["id"] == obs.id + if obs.observed_property == "groundwater level: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 +): + bad_id = 999999 + response = client.get(f"/observation/{bad_id}") + assert response.status_code == 404 + data = response.json() + 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 +): + response = client.get("/observation/groundwater-level") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["id"] == groundwater_level_observation.id + assert data["items"][0]["sample_id"] == groundwater_level_observation.sample_id + assert data["items"][0]["sensor_id"] == groundwater_level_observation.sensor_id + assert ( + data["items"][0]["observation_datetime"] + == groundwater_level_observation.observation_datetime + ) + colon_index = groundwater_level_observation.observed_property.find(":") + assert ( + data["items"][0]["observed_property"] + == groundwater_level_observation.observed_property[colon_index + 1 :] + ) + assert ( + data["items"][0]["release_status"] + == groundwater_level_observation.release_status + ) + assert ( + data["items"][0]["level_status"] == groundwater_level_observation.level_status + ) + assert data["items"][0]["value"] == groundwater_level_observation.value + assert data["items"][0]["unit"] == groundwater_level_observation.unit + assert ( + data["items"][0]["depth_to_water_bgs"] + == groundwater_level_observation.value + - groundwater_level_observation.measuring_point_height + ) + assert ( + data["items"][0]["measuring_point_height"] + == groundwater_level_observation.measuring_point_height + ) + assert ( + data["items"][0]["level_status"] == groundwater_level_observation.level_status + ) + + +def test_get_groundwater_level_observation_by_id(groundwater_level_observation): + response = client.get( + f"/observation/groundwater-level/{groundwater_level_observation.id}" + ) + assert response.status_code == 200 + data = response.json() + assert data["id"] == groundwater_level_observation.id + assert data["sample_id"] == groundwater_level_observation.sample_id + assert data["sensor_id"] == groundwater_level_observation.sensor_id + assert ( + data["observation_datetime"] + == groundwater_level_observation.observation_datetime + ) + colon_index = groundwater_level_observation.observed_property.find(":") + assert ( + data["observed_property"] + == groundwater_level_observation.observed_property[colon_index + 1 :] + ) + assert data["release_status"] == groundwater_level_observation.release_status + assert data["level_status"] == groundwater_level_observation.level_status + assert data["value"] == groundwater_level_observation.value + assert data["unit"] == groundwater_level_observation.unit + assert ( + data["depth_to_water_bgs"] + == groundwater_level_observation.value + - groundwater_level_observation.measuring_point_height + ) + assert ( + data["measuring_point_height"] + == groundwater_level_observation.measuring_point_height + ) + assert data["level_status"] == groundwater_level_observation.level_status + + +def test_get_groundwater_level_observation_by_id_404_not_found( + groundwater_level_observation, +): + bad_id = 99999 + response = client.get(f"/observation/groundwater-level/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert "detail" in data, "Expected 'detail' in response" + 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 +): + 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() + + if obs.observed_property == "geothermal:temperature": + actual_observation_class = "geothermal" + else: + actual_observation_class = "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"] def test_get_groundwater_observation_by_sample(sample): @@ -128,10 +447,10 @@ def test_get_groundwater_observation_by_sample(sample): assert len(items) > 0, "Expected at least one groundwater observation for the thing" -def test_get_groundwater_observation_by_thing(sample): +def test_get_groundwater_observation_by_thing(thing): response = client.get( "/observation/groundwater-level", - params={"thing_id": sample.thing_id, "observed_property": "groundwater level"}, + params={"thing_id": thing.id, "observed_property": "groundwater level"}, ) assert response.status_code == 200 data = response.json() @@ -151,12 +470,12 @@ def test_get_groundwater_observation_by_thing_nonexistent(): ), "Expected no groundwater observations for a non-existent thing" -@pytest.mark.skip(reason="unclear why not working. is it necessary functionality?") -def test_get_groundwater_observation_by_polygon(): +def test_get_groundwater_observation_by_time_range(): response = client.get( "/observation/groundwater-level", params={ - "polygon": "POLYGON((-10.0 -10.0, 20.0 10.0, 20.0 20.0, 10.0 20.0, -10.0 -10.0))", + "start_time": "2025-01-01T00:00:00Z", + "end_time": "2025-01-02T00:00:00Z", }, ) assert response.status_code == 200 @@ -165,30 +484,189 @@ def test_get_groundwater_observation_by_polygon(): items = data["items"] assert ( len(items) > 0 - ), "Expected at least one groundwater observation within the polygon" + ), "Expected at least one groundwater observation in the time range" -@pytest.mark.skip(reason="unclear why not working. is it necessary functionality?") -def test_get_groundwater_observation_by_polygon_nonexistent(): +def test_get_groundwater_observation_by_time_range_nonexistent(): response = client.get( "/observation/groundwater-level", params={ - "polygon": "POLYGON((-100.0 -100.0, -90.0 -90.0, -90.0 -80.0, -100.0 -80.0, -100.0 -100.0))", + "start_time": "2020-01-01T00:00:00Z", + "end_time": "2020-01-02T00:00:00Z", }, ) assert response.status_code == 200 data = response.json() assert "items" in data, "Expected 'items' in response" items = data["items"] - assert len(items) == 0, "Expected no groundwater observations within the polygon" + assert len(items) == 0, "Expected no groundwater observations in the time range" -def test_get_groundwater_observation_by_time_range(): +def test_get_water_chemistry_observations(water_chemistry_observation): + response = client.get("/observation/water-chemistry") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["id"] == water_chemistry_observation.id + assert data["items"][0][ + "created_at" + ] == water_chemistry_observation.created_at.isoformat().replace("+00:00", "Z") + assert data["items"][0]["sample_id"] == water_chemistry_observation.sample_id + assert data["items"][0]["sensor_id"] == water_chemistry_observation.sensor_id + assert ( + data["items"][0]["observation_datetime"] + == water_chemistry_observation.observation_datetime + ) + colon_index = water_chemistry_observation.observed_property.find(":") + assert ( + data["items"][0]["observed_property"] + == water_chemistry_observation.observed_property[colon_index + 1 :] + ) + assert data["items"][0]["value"] == water_chemistry_observation.value + assert data["items"][0]["unit"] == water_chemistry_observation.unit + + +def test_get_water_chemistry_observation_by_id(water_chemistry_observation): + response = client.get( + f"/observation/water-chemistry/{water_chemistry_observation.id}" + ) + assert response.status_code == 200 + data = response.json() + assert data["id"] == water_chemistry_observation.id + assert data[ + "created_at" + ] == water_chemistry_observation.created_at.isoformat().replace("+00:00", "Z") + assert data["sample_id"] == water_chemistry_observation.sample_id + assert data["sensor_id"] == water_chemistry_observation.sensor_id + assert ( + data["observation_datetime"] == water_chemistry_observation.observation_datetime + ) + colon_index = water_chemistry_observation.observed_property.find(":") + assert ( + data["observed_property"] + == water_chemistry_observation.observed_property[colon_index + 1 :] + ) + assert data["value"] == water_chemistry_observation.value + assert data["unit"] == water_chemistry_observation.unit + + +def test_get_water_chemistry_observation_by_id_404_not_found( + water_chemistry_observation, +): + bad_id = 99999 + response = client.get(f"/observation/water-chemistry/{bad_id}") + assert response.status_code == 404 + data = response.json() + 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 +): + 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]["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["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_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"] + + +# JB's comment: I don't think that geographic filters are necessary for +# observations. I think that they should only be applicable to finding Things +# and locations. Then the user can proceed from there to find observations. +@pytest.mark.skip(reason="unclear why not working. is it necessary functionality?") +def test_get_groundwater_observation_by_polygon(): response = client.get( "/observation/groundwater-level", params={ - "start_time": "2025-01-01T00:00:00Z", - "end_time": "2025-01-02T00:00:00Z", + "polygon": "POLYGON((-10.0 -10.0, 20.0 10.0, 20.0 20.0, 10.0 20.0, -10.0 -10.0))", }, ) assert response.status_code == 200 @@ -197,22 +675,49 @@ def test_get_groundwater_observation_by_time_range(): items = data["items"] assert ( len(items) > 0 - ), "Expected at least one groundwater observation in the time range" + ), "Expected at least one groundwater observation within the polygon" -def test_get_groundwater_observation_by_time_range_nonexistent(): +# JB's comment: I don't think that geographic filters are necessary for +# observations. I think that they should only be applicable to finding Things +# and locations. Then the user can proceed from there to find observations +@pytest.mark.skip(reason="unclear why not working. is it necessary functionality?") +def test_get_groundwater_observation_by_polygon_nonexistent(): response = client.get( "/observation/groundwater-level", params={ - "start_time": "2020-01-01T00:00:00Z", - "end_time": "2020-01-02T00:00:00Z", + "polygon": "POLYGON((-100.0 -100.0, -90.0 -90.0, -90.0 -80.0, -100.0 -80.0, -100.0 -100.0))", }, ) assert response.status_code == 200 data = response.json() assert "items" in data, "Expected 'items' in response" items = data["items"] - assert len(items) == 0, "Expected no groundwater observations in the time range" + assert len(items) == 0, "Expected no groundwater observations within the polygon" + + +# DELETE tests ================================================================= + + +def test_delete_observation_by_id(observation_to_delete): + response = client.delete(f"/observation/{observation_to_delete.id}") + assert response.status_code == 204 + + # Verify that the observation was deleted + get_response = client.get(f"/observation/{observation_to_delete.id}") + assert get_response.status_code == 404 + data = get_response.json() + assert ( + data["detail"] == f"Observation with ID {observation_to_delete.id} not found." + ) + + +def test_delete_observation_by_id_404_not_found(observation_to_delete): + bad_id = 99999 + response = client.delete(f"/observation/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Observation with ID {bad_id} not found." # ============= EOF =============================================