From 506f0a29cfcf4c1856e369bc5719fcf6bb948a2d Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 12 Aug 2025 15:09:23 -0600 Subject: [PATCH 01/23] refactor: make sensor db model own module --- db/{sensor => }/sensor.py | 19 ++++--------------- db/sensor/__init__.py | 19 ------------------- db/sensor/groundwaterlevel.py | 28 ---------------------------- 3 files changed, 4 insertions(+), 62 deletions(-) rename db/{sensor => }/sensor.py (76%) delete mode 100644 db/sensor/__init__.py delete mode 100644 db/sensor/groundwaterlevel.py 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 ============================================= From f0060c0eb9c599c6d929be6f0729df725d98abb4 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 12 Aug 2025 15:10:04 -0600 Subject: [PATCH 02/23] refactor: make dt fields tz aware --- schemas/sensor.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/schemas/sensor.py b/schemas/sensor.py index 2fca51e9f..b15e3b05f 100644 --- a/schemas/sensor.py +++ b/schemas/sensor.py @@ -13,9 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from datetime import datetime +from typing_extensions import Annotated -from pydantic import BaseModel +from pydantic import BaseModel, AwareDatetime, PastDatetime # -------- CREATE ---------- @@ -28,8 +28,8 @@ 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 @@ -40,8 +40,8 @@ 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) + date_installed: AwareDatetime + date_removed: AwareDatetime | None # = Column(DateTime) recording_interval: int | None # = Column(Integer) notes: str | None # = Column(String(50)) From dc281faa14180e939364a0934d6473ab792042b4 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 12 Aug 2025 15:11:52 -0600 Subject: [PATCH 03/23] feat: functions to cleanup POST/PATCH tests --- tests/__init__.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/__init__.py b/tests/__init__.py index 87d334000..1416fa3cd 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,27 @@ 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, updated_record_id: int, original_data: Base +) -> None: + """ + Function to cleanup PATCH tests + """ + with session_ctx() as session: + updated_record = session.get(model, updated_record_id) + for field in payload.keys(): + original_value = getattr(original_data, field) + setattr(updated_record, field, original_value) + session.commit() + + # ============= EOF ============================================= From b2b76af387a2ce565174890bc1bf9522769e82d5 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 12 Aug 2025 15:13:14 -0600 Subject: [PATCH 04/23] feat: update sensor fixture --- tests/conftest.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 62b1aebed..de2edb241 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", + date_installed="2023-01-01T00:00:00Z", + date_removed="2023-01-02T00:00:00Z", + recording_interval=60, + notes="Test equipment", + ) session.add(sensor) session.commit() yield sensor From b423233ae42e91ebfb452f80c398fe306128d8f0 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 12 Aug 2025 15:16:14 -0600 Subject: [PATCH 05/23] fix: fix field name typo --- tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index de2edb241..a0596c671 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,8 +42,8 @@ def sensor(): name=f"Test Sensor {uuid.uuid4()}", model="Model X", serial_no="123456", - date_installed="2023-01-01T00:00:00Z", - date_removed="2023-01-02T00:00:00Z", + datetime_installed="2023-01-01T00:00:00Z", + datetime_removed="2023-01-02T00:00:00Z", recording_interval=60, notes="Test equipment", ) From 05a01e835145d322172191476ebf72c7127dcb5c Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 12 Aug 2025 15:17:55 -0600 Subject: [PATCH 06/23] fix: update sensor db import --- db/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 ( From 3888a0d38358080590fb08d0d295d4a5ffcf9a1e Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 12 Aug 2025 15:36:52 -0600 Subject: [PATCH 07/23] fix: update sensor response --- schemas/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/schemas/sensor.py b/schemas/sensor.py index b15e3b05f..47850b3f1 100644 --- a/schemas/sensor.py +++ b/schemas/sensor.py @@ -40,8 +40,8 @@ class SensorResponse(BaseModel): name: str model: str | None # = Column(String(50)) serial_no: str | None # = Column(String(50)) - date_installed: AwareDatetime - date_removed: AwareDatetime | None # = Column(DateTime) + datetime_installed: AwareDatetime + datetime_removed: AwareDatetime | None # = Column(DateTime) recording_interval: int | None # = Column(Integer) notes: str | None # = Column(String(50)) From 0c3c167d5533f8b746e2b3e1000d294d26ae8281 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 12 Aug 2025 15:39:12 -0600 Subject: [PATCH 08/23] refactor: use fixture in existing tests --- tests/test_sensor.py | 82 ++++++++++++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 9dce1d5ec..769525f84 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -13,50 +13,74 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from tests import client +from db import Sensor +from tests import client, cleanup_post_test + +# ====== 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"]) + + +# ====== GET tests ============================================================= -def test_get_sensors(): +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, "Expected sensor ID to match" + assert data["name"] == sensor.name, "Expected sensor name to match" + assert data["model"] == sensor.model, "Expected sensor model to match" + assert data["serial_no"] == sensor.serial_no, "Expected sensor serial_no to match" + assert ( + data["datetime_installed"] == sensor.datetime_installed + ), "Expected sensor datetime_installed to match" + assert ( + data["datetime_removed"] == sensor.datetime_removed + ), "Expected sensor datetime_removed to match" + assert ( + data["recording_interval"] == sensor.recording_interval + ), "Expected sensor recording_interval to match" + assert data["notes"] == sensor.notes, "Expected sensor notes to match" # ============= EOF ============================================= From 53b7413a75fd3b00df399650bcbc4438a648871e Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 12 Aug 2025 15:39:56 -0600 Subject: [PATCH 09/23] refactor: remove notes --- tests/test_sensor.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 769525f84..0141a965d 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -67,20 +67,14 @@ def test_get_sensor_by_id(sensor): response = client.get(f"/sensor/{sensor.id}") assert response.status_code == 200 data = response.json() - assert data["id"] == sensor.id, "Expected sensor ID to match" - assert data["name"] == sensor.name, "Expected sensor name to match" - assert data["model"] == sensor.model, "Expected sensor model to match" - assert data["serial_no"] == sensor.serial_no, "Expected sensor serial_no to match" - assert ( - data["datetime_installed"] == sensor.datetime_installed - ), "Expected sensor datetime_installed to match" - assert ( - data["datetime_removed"] == sensor.datetime_removed - ), "Expected sensor datetime_removed to match" - assert ( - data["recording_interval"] == sensor.recording_interval - ), "Expected sensor recording_interval to match" - assert data["notes"] == sensor.notes, "Expected sensor notes to match" + 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 # ============= EOF ============================================= From c97596a422dcbe4dbc8430fb7f4db1d6ffe32e6c Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 12 Aug 2025 15:41:07 -0600 Subject: [PATCH 10/23] refactor: use simple_get_by_id for /sensor/{sensor_id} --- api/sensor.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/api/sensor.py b/api/sensor.py index a7f6b6ccb..5d4385aff 100644 --- a/api/sensor.py +++ b/api/sensor.py @@ -26,7 +26,7 @@ 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 services.query_helper import order_sort_filter, simple_get_by_id router = APIRouter(prefix="/sensor", tags=["sensor"]) @@ -75,8 +75,7 @@ def get_sensor( sensor_id: int, session: Session = Depends(get_db_session) ) -> SensorResponse: - sensor = session.get(Sensor, sensor_id) - return sensor + return simple_get_by_id(session, Sensor, sensor_id) # ============= EOF ============================================= From b657f34d337de92e97448608c871c50a7261f41c Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 12 Aug 2025 15:49:29 -0600 Subject: [PATCH 11/23] refactor: some cleanup --- api/sensor.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/api/sensor.py b/api/sensor.py index 5d4385aff..7aa612b0d 100644 --- a/api/sensor.py +++ b/api/sensor.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Query from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select, and_ from sqlalchemy.orm import Session @@ -23,7 +23,6 @@ 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, simple_get_by_id @@ -33,11 +32,10 @@ @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 = 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) @@ -56,6 +54,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,10 +70,10 @@ 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: - +def get_sensor(sensor_id: int, session: Session = session_dependency) -> SensorResponse: + """ + Retrieve a sensor by its ID. + """ return simple_get_by_id(session, Sensor, sensor_id) From c0f3e426d16aa1e8b71facd30674a88235ed6d72 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 12 Aug 2025 16:06:12 -0600 Subject: [PATCH 12/23] fix: fix error with session dependency --- api/sensor.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/api/sensor.py b/api/sensor.py index 7aa612b0d..dd8974c5d 100644 --- a/api/sensor.py +++ b/api/sensor.py @@ -17,7 +17,6 @@ from fastapi import APIRouter, Query 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 @@ -25,14 +24,17 @@ from db import adder, Observation from db.sensor import Sensor from schemas.sensor import SensorResponse, CreateSensor +from services.crud_helper import model_patcher 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 = session_dependency + sensor_data: CreateSensor, session: session_dependency ) -> SensorResponse: """ Add a sensor to the system. @@ -40,6 +42,22 @@ def add_sensor( 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: CreateSensor, session: session_dependency +) -> SensorResponse: + """ + Update a sensor in the system. + """ + return model_patcher(session, Sensor, sensor_id, sensor_data) + + +# ====== GET =================================================================== + + @router.get("", status_code=status.HTTP_200_OK) def get_sensors( session: session_dependency, @@ -70,7 +88,7 @@ def get_sensors( @router.get("/{sensor_id}", status_code=status.HTTP_200_OK) -def get_sensor(sensor_id: int, session: Session = session_dependency) -> SensorResponse: +def get_sensor(sensor_id: int, session: session_dependency) -> SensorResponse: """ Retrieve a sensor by its ID. """ From 3f6e0b4a10698f7a1cb75daefd2b53dff18b832b Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 12 Aug 2025 16:06:38 -0600 Subject: [PATCH 13/23] feat: implement 404 test for /sensor/{sensor_id} --- tests/test_sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 0141a965d..408da926d 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -77,4 +77,12 @@ def test_get_sensor_by_id(sensor): 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." + + # ============= EOF ============================================= From 83722d72432211a0e1edeb4243a6537fa71b34c9 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 12 Aug 2025 16:15:29 -0600 Subject: [PATCH 14/23] feat: implement PATCH /sensor/{sensor_id} --- api/sensor.py | 4 ++-- schemas/sensor.py | 9 +++++++++ tests/__init__.py | 6 ++---- tests/test_sensor.py | 27 ++++++++++++++++++++++++++- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/api/sensor.py b/api/sensor.py index dd8974c5d..5aef890d5 100644 --- a/api/sensor.py +++ b/api/sensor.py @@ -23,7 +23,7 @@ from core.dependencies import session_dependency from db import adder, Observation from db.sensor import Sensor -from schemas.sensor import SensorResponse, CreateSensor +from schemas.sensor import SensorResponse, CreateSensor, UpdateSensor from services.crud_helper import model_patcher from services.query_helper import order_sort_filter, simple_get_by_id @@ -47,7 +47,7 @@ def add_sensor( @router.patch("/{sensor_id}", status_code=status.HTTP_200_OK) def update_sensor( - sensor_id: int, sensor_data: CreateSensor, session: session_dependency + sensor_id: int, sensor_data: UpdateSensor, session: session_dependency ) -> SensorResponse: """ Update a sensor in the system. diff --git a/schemas/sensor.py b/schemas/sensor.py index 47850b3f1..0b108dca5 100644 --- a/schemas/sensor.py +++ b/schemas/sensor.py @@ -47,5 +47,14 @@ class SensorResponse(BaseModel): # -------- UPDATE ---------- +class UpdateSensor(BaseModel): + 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 + # ============= EOF ============================================= diff --git a/tests/__init__.py b/tests/__init__.py index 1416fa3cd..5d7220f57 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -38,14 +38,12 @@ def cleanup_post_test(model: Base, new_record_id: int) -> None: session.commit() -def cleanup_patch_test( - model: Base, payload: dict, updated_record_id: int, original_data: Base -) -> None: +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, updated_record_id) + 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) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 408da926d..dfe05f552 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== from db import Sensor -from tests import client, cleanup_post_test +from tests import client, cleanup_post_test, cleanup_patch_test # ====== POST tests ============================================================ @@ -45,6 +45,31 @@ def test_add_sensor(): 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." + + # ====== GET tests ============================================================= From d4d185bc7d4ab50d51af1947ce2a018d75be78d9 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 12 Aug 2025 17:07:45 -0600 Subject: [PATCH 15/23] feat: implement delete sensor endpoint --- api/sensor.py | 15 +++++++++++++-- tests/test_sensor.py | 46 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/api/sensor.py b/api/sensor.py index 5aef890d5..f4407cc15 100644 --- a/api/sensor.py +++ b/api/sensor.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, Response from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select, and_ from starlette import status @@ -24,7 +24,7 @@ from db import adder, Observation from db.sensor import Sensor from schemas.sensor import SensorResponse, CreateSensor, UpdateSensor -from services.crud_helper import model_patcher +from services.crud_helper import model_patcher, model_deleter from services.query_helper import order_sort_filter, simple_get_by_id router = APIRouter(prefix="/sensor", tags=["sensor"]) @@ -55,6 +55,17 @@ def update_sensor( 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 =================================================================== diff --git a/tests/test_sensor.py b/tests/test_sensor.py index dfe05f552..9cd147c2a 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -14,8 +14,32 @@ # limitations under the License. # =============================================================================== from db import Sensor +from db.engine import session_ctx from tests import client, cleanup_post_test, cleanup_patch_test +import pytest + +# ====== 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() + + # ====== POST tests ============================================================ @@ -110,4 +134,26 @@ def test_get_sensor_by_id_404_not_found(sensor): 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 ============================================= From 6a3f3a55f6aa56af5715eef1690089f25ad5d779 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 13 Aug 2025 09:58:18 -0600 Subject: [PATCH 16/23] feat: validate datetime installed and removed --- schemas/sensor.py | 46 ++++++++++++++++++++++++++++++-------------- tests/test_sensor.py | 17 ++++++++++++++++ 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/schemas/sensor.py b/schemas/sensor.py index 0b108dca5..7bcba1350 100644 --- a/schemas/sensor.py +++ b/schemas/sensor.py @@ -13,13 +13,31 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from typing_extensions import Annotated +from typing_extensions import Annotated, Self -from pydantic import BaseModel, AwareDatetime, PastDatetime +from pydantic import BaseModel, AwareDatetime, PastDatetime, model_validator + +# ------- VALIDATION ------ + + +class ValidateSensor(BaseModel): + + datetime_installed: AwareDatetime + datetime_removed: AwareDatetime + + @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. """ @@ -34,6 +52,17 @@ class CreateSensor(BaseModel): 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 + + # -------- RESPONSE ---------- class SensorResponse(BaseModel): id: int @@ -46,15 +75,4 @@ class SensorResponse(BaseModel): notes: str | None # = Column(String(50)) -# -------- UPDATE ---------- -class UpdateSensor(BaseModel): - 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 - - # ============= EOF ============================================= diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 9cd147c2a..4691c00aa 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -15,6 +15,7 @@ # =============================================================================== 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 @@ -40,6 +41,22 @@ def second_sensor(): session.close() +# ====== VALIDATION tests ====================================================== + + +def test_validate_datetime_installed_datetime_removed(): + try: + sensor = ValidateSensor( + datetime_installed="2023-01-02T00:00:00Z", + datetime_removed="2023-01-01T00:00:00Z", + ) + except ValueError as e: + assert ( + e.errors()[0]["msg"] + == "Value error, datetime removed must be after datetime installed" + ) + + # ====== POST tests ============================================================ From 1fe984beeaf04953c5401edf6b08ba93a54da42d Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 13 Aug 2025 09:59:37 -0600 Subject: [PATCH 17/23] fix: fix custom validation test for sample --- tests/test_sample.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_sample.py b/tests/test_sample.py index 3770cd000..d789ef9c2 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -61,8 +61,8 @@ def test_validate_sample_top_and_bottom(): ) except ValueError as e: assert ( - str(e) - == "Sample top and bottom must both be defined or both must be None." + e.errors()[0]["msg"] + == "Value error, Sample top and bottom must both be defined or both must be None." ) From 32c1624943b9b0bc1f0a55a380100e089a30baf5 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 13 Aug 2025 15:51:48 -0600 Subject: [PATCH 18/23] feat: create pydantic style exception to maintain style --- services/error_helper.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 services/error_helper.py diff --git a/services/error_helper.py b/services/error_helper.py new file mode 100644 index 000000000..742f5e2fc --- /dev/null +++ b/services/error_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}], + ) From 694c70140e02266d71b8e5aa0006491d304d74d2 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 13 Aug 2025 15:52:33 -0600 Subject: [PATCH 19/23] feat: handle 409 errors for PATCH /sensor/{sensor_id} --- api/sensor.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/api/sensor.py b/api/sensor.py index f4407cc15..f7935c8c6 100644 --- a/api/sensor.py +++ b/api/sensor.py @@ -25,6 +25,7 @@ from db.sensor import Sensor from schemas.sensor import SensorResponse, CreateSensor, UpdateSensor from services.crud_helper import model_patcher, model_deleter +from services.error_helper import PydanticStyleException from services.query_helper import order_sort_filter, simple_get_by_id router = APIRouter(prefix="/sensor", tags=["sensor"]) @@ -52,6 +53,46 @@ def update_sensor( """ 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) From 74ac596a7f7dae316db8420c8d8280f0c080d50f Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 13 Aug 2025 15:53:26 -0600 Subject: [PATCH 20/23] feat: test 409 PATCH errors --- tests/test_sensor.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 4691c00aa..fd9a01e05 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -111,6 +111,32 @@ def test_patch_sensor_404_not_found(sensor): assert data["detail"] == f"Sensor with ID {bad_sensor_id} not found." +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 ============================================================= From a6ca83665ed7b0bd708cc34d12054dc53846aa05 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 13 Aug 2025 15:56:23 -0600 Subject: [PATCH 21/23] refactor: rename error_helper exception_helper --- api/sensor.py | 2 +- services/{error_helper.py => exceptions_helper.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename services/{error_helper.py => exceptions_helper.py} (100%) diff --git a/api/sensor.py b/api/sensor.py index f7935c8c6..bf908c080 100644 --- a/api/sensor.py +++ b/api/sensor.py @@ -25,7 +25,7 @@ from db.sensor import Sensor from schemas.sensor import SensorResponse, CreateSensor, UpdateSensor from services.crud_helper import model_patcher, model_deleter -from services.error_helper import PydanticStyleException +from services.exceptions_helper import PydanticStyleException from services.query_helper import order_sort_filter, simple_get_by_id router = APIRouter(prefix="/sensor", tags=["sensor"]) diff --git a/services/error_helper.py b/services/exceptions_helper.py similarity index 100% rename from services/error_helper.py rename to services/exceptions_helper.py From 5cceadd3d2cb9b7e8cc3ffe856cae164cd2fad18 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 10:55:52 -0600 Subject: [PATCH 22/23] feat: ensure sensor dt fields at UTC --- schemas/sensor.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/schemas/sensor.py b/schemas/sensor.py index 7bcba1350..fd2ab7e7c 100644 --- a/schemas/sensor.py +++ b/schemas/sensor.py @@ -14,8 +14,15 @@ # limitations under the License. # =============================================================================== from typing_extensions import Annotated, Self +from datetime import timezone -from pydantic import BaseModel, AwareDatetime, PastDatetime, model_validator +from pydantic import ( + BaseModel, + AwareDatetime, + PastDatetime, + model_validator, + field_validator, +) # ------- VALIDATION ------ @@ -25,6 +32,12 @@ class ValidateSensor(BaseModel): datetime_installed: AwareDatetime datetime_removed: AwareDatetime + @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 ( From 605131c86d6524ef755b667961cd7e293bb8e42a Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 09:04:58 -0600 Subject: [PATCH 23/23] refactor: use pytest.raises to test exceptions --- schemas/sample.py | 4 ++-- schemas/sensor.py | 4 ++-- tests/test_sample.py | 15 ++++++--------- tests/test_sensor.py | 12 +++++------- 4 files changed, 15 insertions(+), 20 deletions(-) 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 fd2ab7e7c..e412895a3 100644 --- a/schemas/sensor.py +++ b/schemas/sensor.py @@ -29,8 +29,8 @@ class ValidateSensor(BaseModel): - datetime_installed: AwareDatetime - datetime_removed: AwareDatetime + 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: diff --git a/tests/test_sample.py b/tests/test_sample.py index d789ef9c2..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 ( - e.errors()[0]["msg"] - == "Value error, 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 fd9a01e05..bfa47c6bc 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -19,6 +19,7 @@ from tests import client, cleanup_post_test, cleanup_patch_test import pytest +from pydantic import ValidationError # ====== module functions and fixtures ========================================= @@ -45,16 +46,13 @@ def second_sensor(): def test_validate_datetime_installed_datetime_removed(): - try: - sensor = ValidateSensor( + 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", ) - except ValueError as e: - assert ( - e.errors()[0]["msg"] - == "Value error, datetime removed must be after datetime installed" - ) # ====== POST tests ============================================================