diff --git a/api/sensor.py b/api/sensor.py index a7f6b6ccb..bf908c080 100644 --- a/api/sensor.py +++ b/api/sensor.py @@ -14,34 +14,102 @@ # limitations under the License. # =============================================================================== -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Query, Response from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select, and_ -from sqlalchemy.orm import Session from starlette import status from api.pagination import CustomPage from core.dependencies import session_dependency from db import adder, Observation -from db.engine import get_db_session from db.sensor import Sensor -from schemas.sensor import SensorResponse, CreateSensor -from services.query_helper import order_sort_filter +from schemas.sensor import SensorResponse, CreateSensor, UpdateSensor +from services.crud_helper import model_patcher, model_deleter +from services.exceptions_helper import PydanticStyleException +from services.query_helper import order_sort_filter, simple_get_by_id router = APIRouter(prefix="/sensor", tags=["sensor"]) +# ====== POST ================================================================== + @router.post("", status_code=status.HTTP_201_CREATED) def add_sensor( - sensor_data: CreateSensor, session: Session = Depends(get_db_session) + sensor_data: CreateSensor, session: session_dependency ) -> SensorResponse: """ Add a sensor to the system. - This endpoint is a placeholder and should be implemented with actual logic. """ return adder(session, Sensor, sensor_data) +# ====== PATCH ================================================================= + + +@router.patch("/{sensor_id}", status_code=status.HTTP_200_OK) +def update_sensor( + sensor_id: int, sensor_data: UpdateSensor, session: session_dependency +) -> SensorResponse: + """ + Update a sensor in the system. + """ + if ( + sensor_data.datetime_installed is not None + and sensor_data.datetime_removed is None + ): + sensor = simple_get_by_id(session, Sensor, sensor_id) + existing_datetime_removed = sensor.datetime_removed + if ( + existing_datetime_removed is not None + and sensor_data.datetime_installed >= existing_datetime_removed + ): + raise PydanticStyleException( + status_code=status.HTTP_409_CONFLICT, + loc=["body", "datetime_installed"], + msg=f"new datetime installed must be before existing datetime removed of {existing_datetime_removed.isoformat().replace('+00:00', 'Z')}", + type="value_error", + input={ + "datetime_installed": sensor_data.datetime_installed.isoformat().replace( + "+00:00", "Z" + ) + }, + ) + elif ( + sensor_data.datetime_installed is None + and sensor_data.datetime_removed is not None + ): + sensor = simple_get_by_id(session, Sensor, sensor_id) + existing_datetime_installed = sensor.datetime_installed + if sensor_data.datetime_removed <= existing_datetime_installed: + raise PydanticStyleException( + status_code=status.HTTP_409_CONFLICT, + loc=["body", "datetime_removed"], + msg=f"new datetime removed must be after existing datetime installed of {existing_datetime_installed.isoformat().replace('+00:00', 'Z')}", + type="value_error", + input={ + "datetime_removed": sensor_data.datetime_removed.isoformat().replace( + "+00:00", "Z" + ) + }, + ) + + return model_patcher(session, Sensor, sensor_id, sensor_data) + + +# ====== DELETE ================================================================ + + +@router.delete("/{sensor_id}") +def delete_sensor(sensor_id: int, session: session_dependency) -> Response: + """ + Delete a sensor in the system + """ + return model_deleter(session, Sensor, sensor_id) + + +# ====== GET =================================================================== + + @router.get("", status_code=status.HTTP_200_OK) def get_sensors( session: session_dependency, @@ -56,6 +124,7 @@ def get_sensors( This endpoint is a placeholder and should be implemented with actual logic. """ sql = select(Sensor) + # TODO: a sensor is not yet related to observation, so this won't work at the moment if thing_id is not None or observed_property is not None: conditions = [] if observed_property is not None: @@ -71,12 +140,11 @@ def get_sensors( @router.get("/{sensor_id}", status_code=status.HTTP_200_OK) -def get_sensor( - sensor_id: int, session: Session = Depends(get_db_session) -) -> SensorResponse: - - sensor = session.get(Sensor, sensor_id) - return sensor +def get_sensor(sensor_id: int, session: session_dependency) -> SensorResponse: + """ + Retrieve a sensor by its ID. + """ + return simple_get_by_id(session, Sensor, sensor_id) # ============= EOF ============================================= diff --git a/db/__init__.py b/db/__init__.py index 40af68323..31c87f67b 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -30,8 +30,7 @@ from db.observation import * from db.publication import * from db.sample import * -from db.sensor.groundwaterlevel import * -from db.sensor.sensor import * +from db.sensor import * from db.thing import * from sqlalchemy import ( diff --git a/db/sensor/sensor.py b/db/sensor.py similarity index 76% rename from db/sensor/sensor.py rename to db/sensor.py index a0e52b2cc..61b023625 100644 --- a/db/sensor/sensor.py +++ b/db/sensor.py @@ -13,23 +13,12 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from sqlalchemy import Column, String, Integer, ForeignKey, DateTime -from sqlalchemy.orm import declared_attr, relationship +from sqlalchemy import Column, String, Integer, DateTime +from sqlalchemy.orm import relationship from db.base import Base, AutoBaseMixin -class SensorMixin: - @declared_attr - def sensor_id(self): - return Column( - Integer, - ForeignKey("sensor.id", ondelete="CASCADE"), - nullable=False, - unique=True, - ) - - class Sensor(Base, AutoBaseMixin): """ Base class for all sensor types. @@ -40,8 +29,8 @@ class Sensor(Base, AutoBaseMixin): name = Column(String(255), nullable=False) model = Column(String(50)) serial_no = Column(String(50)) - date_installed = Column(DateTime) - date_removed = Column(DateTime) + datetime_installed = Column(DateTime(timezone=True), nullable=False) + datetime_removed = Column(DateTime(timezone=True)) recording_interval = Column(Integer) notes = Column(String(50)) diff --git a/db/sensor/__init__.py b/db/sensor/__init__.py deleted file mode 100644 index 84c847937..000000000 --- a/db/sensor/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# =============================================================================== -# 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 .sensor import Sensor -from .groundwaterlevel import GroundwaterLevelSensor - -# ============= EOF ============================================= diff --git a/db/sensor/groundwaterlevel.py b/db/sensor/groundwaterlevel.py deleted file mode 100644 index 6c040abfb..000000000 --- a/db/sensor/groundwaterlevel.py +++ /dev/null @@ -1,28 +0,0 @@ -# =============================================================================== -# 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 sqlalchemy import Column, String, Integer, ForeignKey -from sqlalchemy.orm import relationship - -from db.base import Base, AutoBaseMixin - -from db.sensor.sensor import SensorMixin - - -class GroundwaterLevelSensor(Base, AutoBaseMixin, SensorMixin): - pass - - -# ============= EOF ============================================= diff --git a/schemas/sample.py b/schemas/sample.py index 35df8872d..ffda36b23 100644 --- a/schemas/sample.py +++ b/schemas/sample.py @@ -51,8 +51,8 @@ class ValidateSample(BaseModel): # ) # return sample_bottom - # REFACTOR TODO: fields are evaluated in the order in which they are defined. - # are sample top/bottom really working as expected? + sample_top: float | None = None + sample_bottom: float | None = None @model_validator(mode="after") def validate_top_and_bottom(self) -> Self: diff --git a/schemas/sensor.py b/schemas/sensor.py index 2fca51e9f..e412895a3 100644 --- a/schemas/sensor.py +++ b/schemas/sensor.py @@ -13,13 +13,44 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from datetime import datetime +from typing_extensions import Annotated, Self +from datetime import timezone -from pydantic import BaseModel +from pydantic import ( + BaseModel, + AwareDatetime, + PastDatetime, + model_validator, + field_validator, +) + +# ------- VALIDATION ------ + + +class ValidateSensor(BaseModel): + + datetime_installed: AwareDatetime | None = None + datetime_removed: AwareDatetime | None = None + + @field_validator("datetime_installed", "datetime_removed") + def convert_datetime_fields_to_utc(cls, field: AwareDatetime) -> AwareDatetime: + if field is not None and field.tzinfo != timezone.utc: + field = field.astimezone(timezone.utc) + return field + + @model_validator(mode="after") + def check_datetime_values(self) -> Self: + if ( + getattr(self, "datetime_removed", None) is not None + and getattr(self, "datetime_installed", None) is not None + ): + if self.datetime_removed <= self.datetime_installed: + raise ValueError("datetime removed must be after datetime installed") + return self # -------- CREATE ---------- -class CreateSensor(BaseModel): +class CreateSensor(ValidateSensor): """ Schema for creating a new sensor. """ @@ -28,8 +59,19 @@ class CreateSensor(BaseModel): # equipment_type: str | None = None model: str | None = None serial_no: str | None = None - date_installed: str | None = None # ISO format date string - date_removed: str | None = None # ISO format date string + datetime_installed: Annotated[AwareDatetime, PastDatetime()] + datetime_removed: AwareDatetime | None = None # ISO format date string + recording_interval: int | None = None + notes: str | None = None + + +# -------- UPDATE ---------- +class UpdateSensor(ValidateSensor): + name: str | None = None + model: str | None = None + serial_no: str | None = None + datetime_installed: AwareDatetime | None = None + datetime_removed: AwareDatetime | None = None recording_interval: int | None = None notes: str | None = None @@ -40,12 +82,10 @@ class SensorResponse(BaseModel): name: str model: str | None # = Column(String(50)) serial_no: str | None # = Column(String(50)) - date_installed: datetime | None # = Column(DateTime) - date_removed: datetime | None # = Column(DateTime) + datetime_installed: AwareDatetime + datetime_removed: AwareDatetime | None # = Column(DateTime) recording_interval: int | None # = Column(Integer) notes: str | None # = Column(String(50)) -# -------- UPDATE ---------- - # ============= EOF ============================================= diff --git a/services/exceptions_helper.py b/services/exceptions_helper.py new file mode 100644 index 000000000..742f5e2fc --- /dev/null +++ b/services/exceptions_helper.py @@ -0,0 +1,21 @@ +from fastapi import HTTPException + + +class PydanticStyleException(HTTPException): + """ + Exception to be raised for errors not handled by Pydantic to maintain + the same style. + """ + + def __init__( + self, + status_code: int, + loc: list, + msg: str, + type: str, + input: dict, + ): + super().__init__( + status_code=status_code, + detail=[{"loc": loc, "msg": msg, "type": type, "input": input}], + ) diff --git a/tests/__init__.py b/tests/__init__.py index 87d334000..5d7220f57 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -17,7 +17,7 @@ from core.app import init_lexicon from db import Base -from db.engine import engine +from db.engine import engine, session_ctx from main import app @@ -29,4 +29,25 @@ client = TestClient(app) +def cleanup_post_test(model: Base, new_record_id: int) -> None: + """ + Function to cleanup POST tests + """ + with session_ctx() as session: + session.delete(session.get(model, new_record_id)) + session.commit() + + +def cleanup_patch_test(model: Base, payload: dict, original_data: Base) -> None: + """ + Function to cleanup PATCH tests + """ + with session_ctx() as session: + updated_record = session.get(model, original_data.id) + for field in payload.keys(): + original_value = getattr(original_data, field) + setattr(updated_record, field, original_value) + session.commit() + + # ============= EOF ============================================= diff --git a/tests/conftest.py b/tests/conftest.py index 62b1aebed..a0596c671 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,7 +38,15 @@ def thing(location): @pytest.fixture(scope="session") def sensor(): with session_ctx() as session: - sensor = Sensor(name=f"Test Sensor {uuid.uuid4()}") + sensor = Sensor( + name=f"Test Sensor {uuid.uuid4()}", + model="Model X", + serial_no="123456", + datetime_installed="2023-01-01T00:00:00Z", + datetime_removed="2023-01-02T00:00:00Z", + recording_interval=60, + notes="Test equipment", + ) session.add(sensor) session.commit() yield sensor diff --git a/tests/test_sample.py b/tests/test_sample.py index 3770cd000..6588ae89d 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -14,6 +14,7 @@ # limitations under the License. # =============================================================================== import pytest +from pydantic import ValidationError from db.engine import session_ctx from db.sample import Sample @@ -55,15 +56,11 @@ 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 - try: - invalid_sample = ValidateSample( - sample_top=sample_top, sample_bottom=sample_bottom - ) - except ValueError as e: - assert ( - str(e) - == "Sample top and bottom must both be defined or both must be None." - ) + with pytest.raises( + ValidationError, + match="Sample top and bottom must both be defined or both must be None.", + ): + ValidateSample(sample_top=sample_top, sample_bottom=sample_bottom) # ============= Post tests for samples ============================================= diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 9dce1d5ec..bfa47c6bc 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -13,50 +13,188 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from tests import client +from db import Sensor +from db.engine import session_ctx +from schemas.sensor import ValidateSensor +from tests import client, cleanup_post_test, cleanup_patch_test + +import pytest +from pydantic import ValidationError + +# ====== module functions and fixtures ========================================= + + +@pytest.fixture(scope="function") +def second_sensor(): + with session_ctx() as session: + sensor = Sensor( + name="Test Sensor 2", + model="Model X", + serial_no="123456", + datetime_installed="2023-01-01T00:00:00Z", + datetime_removed="2023-01-02T00:00:00Z", + recording_interval=60, + notes="Test equipment", + ) + session.add(sensor) + session.commit() + yield sensor + session.close() + + +# ====== VALIDATION tests ====================================================== + + +def test_validate_datetime_installed_datetime_removed(): + with pytest.raises( + ValidationError, match="datetime removed must be after datetime installed" + ): + ValidateSensor( + datetime_installed="2023-01-02T00:00:00Z", + datetime_removed="2023-01-01T00:00:00Z", + ) + + +# ====== POST tests ============================================================ def test_add_sensor(): - response = client.post( - "/sensor", - json={ - "name": "Test Sensor", - "model": "Model X", - "serial_no": "123456", - "date_installed": "2023-01-01T00:00:00", - # "date_removed": None, - "recording_interval": 60, - "notes": "Test equipment", - # "location_id": 2, - }, - ) + payload = { + "name": "Test Sensor 2", + "model": "Model X", + "serial_no": "12345678", + "datetime_installed": "2024-01-01T00:00:00Z", + "datetime_removed": None, + "recording_interval": 60, + "notes": "Test equipment", + } + response = client.post("/sensor", json=payload) assert response.status_code == 201 data = response.json() assert "id" in data - # assert data["location_id"] == 2 + assert data["name"] == payload["name"] + assert data["model"] == payload["model"] + assert data["serial_no"] == payload["serial_no"] + assert data["datetime_installed"] == payload["datetime_installed"] + assert data["datetime_removed"] == payload["datetime_removed"] + assert data["recording_interval"] == payload["recording_interval"] + assert data["notes"] == payload["notes"] + + # cleanup after post test + cleanup_post_test(Sensor, data["id"]) + + +# ====== PATCH tests =========================================================== + + +def test_patch_sensor(sensor): + payload = {"name": "patched name", "model": "patched model"} + response = client.patch(f"/sensor/{sensor.id}", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["id"] == sensor.id + assert data["name"] == payload["name"] + assert data["model"] == payload["model"] + + # cleanup after patch test + cleanup_patch_test(Sensor, payload, sensor) + + +def test_patch_sensor_404_not_found(sensor): + bad_sensor_id = 99999 + payload = {"name": "patched name", "model": "patched model"} + response = client.patch(f"/sensor/{bad_sensor_id}", json=payload) + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Sensor with ID {bad_sensor_id} not found." -def test_get_sensors(): +def test_patch_sensor_409_conflicting_datetime_installed(sensor): + payload = {"datetime_installed": "2025-01-01T00:00:00Z"} + response = client.patch(f"/sensor/{sensor.id}", json=payload) + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["loc"] == ["body", "datetime_installed"] + assert ( + data["detail"][0]["msg"] + == f"new datetime installed must be before existing datetime removed of {sensor.datetime_removed}" + ) + assert data["detail"][0]["type"] == "value_error" + + +def test_patch_sensor_409_conflicting_datetime_removed(sensor): + payload = {"datetime_removed": "2020-01-01T00:00:00Z"} + response = client.patch(f"/sensor/{sensor.id}", json=payload) + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["loc"] == ["body", "datetime_removed"] + assert ( + data["detail"][0]["msg"] + == f"new datetime removed must be after existing datetime installed of {sensor.datetime_installed}" + ) + assert data["detail"][0]["type"] == "value_error" + + +# ====== GET tests ============================================================= + + +def test_get_sensors(sensor): response = client.get("/sensor") assert response.status_code == 200 data = response.json() - # assert isinstance(items, list), "Expected a list of sensors" - assert "items" in data - items = data["items"] - assert "id" in items[0], "Expected 'id' in sensor items" - # assert "name" in items[0], "Expected 'name' in sensor items" - # assert "equipment_type" in items[0], "Expected 'equipment_type' in sensor items" + assert data["total"] == 1 + assert data["items"][0]["id"] == sensor.id + assert data["items"][0]["name"] == sensor.name + assert data["items"][0]["model"] == sensor.model + assert data["items"][0]["serial_no"] == sensor.serial_no + assert data["items"][0]["datetime_installed"] == sensor.datetime_installed + assert data["items"][0]["datetime_removed"] == sensor.datetime_removed + assert data["items"][0]["recording_interval"] == sensor.recording_interval + assert data["items"][0]["notes"] == sensor.notes -def test_get_sensor(): - # Assuming the first sensor has an ID of 1 - response = client.get("/sensor/1") +def test_get_sensor_by_id(sensor): + response = client.get(f"/sensor/{sensor.id}") assert response.status_code == 200 data = response.json() - assert "id" in data, "Expected 'id' in sensor data" - assert data["id"] == 1, "Expected sensor ID to be 1" - assert "name" in data, "Expected 'name' in sensor data" - # assert "equipment_type" in data, "Expected 'equipment_type' in sensor data" + assert data["id"] == sensor.id + assert data["name"] == sensor.name + assert data["model"] == sensor.model + assert data["serial_no"] == sensor.serial_no + assert data["datetime_installed"] == sensor.datetime_installed + assert data["datetime_removed"] == sensor.datetime_removed + assert data["recording_interval"] == sensor.recording_interval + assert data["notes"] == sensor.notes + + +def test_get_sensor_by_id_404_not_found(sensor): + bad_sensor_id = 999999 + response = client.get(f"/sensor/{bad_sensor_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Sensor with ID {bad_sensor_id} not found." + + +# ====== DELETE tests ========================================================== + + +def test_delete_sensor(second_sensor): + response = client.delete(f"/sensor/{second_sensor.id}") + assert response.status_code == 204 + + # verify sensor is gone + response = client.get(f"/sensor/{second_sensor.id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Sensor with ID {second_sensor.id} not found." + + +def test_delete_sensor_404_not_found(sensor): + bad_sensor_id = 999999 + response = client.delete(f"/sensor/{bad_sensor_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Sensor with ID {bad_sensor_id} not found." # ============= EOF =============================================