Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
69c0a9e
fix: import all models in db package for Alembic
jacob-a-brown Jul 24, 2025
6bece1b
fix: drop idx_location_point index
jacob-a-brown Jul 24, 2025
a406054
Formatting changes
jacob-a-brown Jul 24, 2025
c348dc6
feat: Refactor imports and update Alembic migration for all models
jacob-a-brown Jul 24, 2025
56e5ade
fix: version work in docker database
jacob-a-brown Jul 24, 2025
4920e36
Merge branch 'jab-poc' of https://github.com/DataIntegrationGroup/NMS…
jacob-a-brown Jul 24, 2025
6b3e83b
Formatting changes
jacob-a-brown Jul 24, 2025
0f19295
Merge remote-tracking branch 'origin/pre-production' into jab-poc
jacob-a-brown Jul 25, 2025
70c211c
fix: create migration script with sa continuum tables
jacob-a-brown Jul 28, 2025
f4c1b53
fix: drop indexes before creation
jacob-a-brown Jul 28, 2025
af59537
Merge branch 'jab-poc' of https://github.com/DataIntegrationGroup/NMS…
jacob-a-brown Jul 28, 2025
2e6b162
Formatting changes
jacob-a-brown Jul 28, 2025
d471487
fix: drop index before creating if exists
jacob-a-brown Jul 28, 2025
5c111df
fix: remove material created by tests
jacob-a-brown Jul 28, 2025
7055f2f
Merge branch 'jab-poc' of https://github.com/DataIntegrationGroup/NMS…
jacob-a-brown Jul 28, 2025
12d993f
fix: resolve merge conflicts
jacob-a-brown Jul 29, 2025
3ce1bee
docs: document work needed to be done by alembic migrations
jacob-a-brown Jul 29, 2025
5b223bc
refactor: call configure_mappers() in db/__init__.py
jacob-a-brown Jul 29, 2025
d43365d
feat: add sample fixture for sample tests
jacob-a-brown Jul 29, 2025
56a6229
fix: delete thing after creation in fixture
jacob-a-brown Jul 29, 2025
5fc4016
feat: create general ResourceNotFoundResponse model for 404 errors
jacob-a-brown Jul 29, 2025
ded3c54
feat: create sample patch and get by id endpoints
jacob-a-brown Jul 29, 2025
7ef271f
feat: add update schema for sample and update sample response schema
jacob-a-brown Jul 29, 2025
2d40298
WIP: work on sample endpoint tests
jacob-a-brown Jul 29, 2025
706006f
fix: implement successful and 404 sample patch endpoints
jacob-a-brown Jul 30, 2025
e22f7e8
WIP: test sample custom field validators
jacob-a-brown Jul 30, 2025
9f58da3
docs: note where code can be refactored
jacob-a-brown Jul 30, 2025
315f407
docs: note fixture management
jacob-a-brown Jul 30, 2025
f892b3c
fix: update add sample test to use thing fixture
jacob-a-brown Jul 30, 2025
e3b127d
fix: use sample fixture's in get get sample test
jacob-a-brown Jul 30, 2025
d54d5ce
fix: fix use of session to validate Thing existence for UpdateSample
jacob-a-brown Jul 30, 2025
b301103
fix: fix errors with 422 custom validation update sample tests
jacob-a-brown Jul 30, 2025
7e73e47
fix: cleanup sample after add test
jacob-a-brown Jul 30, 2025
fed04a7
refactor: remove outdated note about fixtures
jacob-a-brown Jul 30, 2025
a35b9d1
refactor: put sample fixture in tests/__init__.py for organization
jacob-a-brown Jul 30, 2025
acb07dd
Merge remote-tracking branch 'origin/pre-production' into dev-jab
jacob-a-brown Jul 31, 2025
d484f2c
fix: fix artifact from merge conflict
jacob-a-brown Jul 31, 2025
b14a536
refactor: move fixtures to conftest.py
jacob-a-brown Jul 31, 2025
1fe76a1
fix: update and clean samples tests for session fixtures
jacob-a-brown Jul 31, 2025
20b4a12
fix: remove debugging print statement
jacob-a-brown Jul 31, 2025
3ba7dfa
fix: remove fixture imports now they are in conftest
jacob-a-brown Jul 31, 2025
2d08c74
fix: fix search test cases
jacob-a-brown Jul 31, 2025
8406c8c
fix: delete shapefile files created by geospatial test
jacob-a-brown Jul 31, 2025
21e6c9a
fix: use session fixtures for contact tests | cleanup post tests
jacob-a-brown Jul 31, 2025
eb0d1fa
fix: run search test on relevant fixtures
jacob-a-brown Jul 31, 2025
6aa3e8f
fix: temporary fix until all fixtures are used
jacob-a-brown Jul 31, 2025
717dde8
refactor: drop and create all tables for API testing suite
jacob-a-brown Jul 31, 2025
57b937a
refactor: address PR 49 feedback and use session_dependency
jacob-a-brown Jul 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ repos:
'--show-source',
'--statistics'
]
exclude: ^db/__init__.py$ # all models need to be imported for Alembic, but are not used directly

# - repo: https://github.com/pre-commit/mirrors-mypy
# rev: v1.10.0 # Use the latest stable version or pin to your preference
Expand Down
2 changes: 1 addition & 1 deletion alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@

def include_object(object, name, type_, reflected, compare_to):
# only include tables in sql alchemy model, not auto-generated tables from PostGIS or TIGER
if type_ == "table":
if type_ == "table" or name.endswith("_version") or name == "transaction":
return name in model_tables
return True

Expand Down
22 changes: 18 additions & 4 deletions alembic/versions/66ac1af4ba69_initial_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@

from typing import Sequence, Union

from alembic import op
import geoalchemy2
import sqlalchemy as sa
import sqlalchemy_utils
# from alembic import op
from sqlalchemy.orm import configure_mappers

# revision identifiers, used by Alembic.
Expand Down Expand Up @@ -39,6 +36,23 @@ def upgrade() -> None:
# It is here as a record of the initial database state.
# Actual initial database creation should be done through the Base.metadata.create_all(engine) call above.

"""
TODO
The following code will need to be regenerated by Alembic since configure_mappers() is now called
in db/__init__.py to ensure all models are loaded before creating the database schema. This is
require for SQL Alchemy continuum.

The following code will also need to be added:

- op.drop_index("idx_location_version_point", table_name="location_version", if_exists=True)
- before calling op.create_index("idx_location_version_point", "location_version", ["point"], unique=False, postgresql_using="gist",)
- op.drop_index("idx_location_point", table_name="location", if_exists=True)
- before calling op.create_index("idx_location_point", "location", ["point"], unique=False, postgresql_using="gist",)

We will also need to figure out how to handle the SQL Alchemy searchable columns in the models, as they are not currently handled by Alembic.
There is some documentation about sync_triggers, but that has not yet been tested.
"""

# ### commands auto generated by Alembic - please adjust! ###
# op.create_table('asset',
# sa.Column('name', sa.String(), nullable=False),
Expand Down
53 changes: 44 additions & 9 deletions api/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,19 @@
# limitations under the License.
# ===============================================================================

from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from starlette.status import HTTP_201_CREATED
from starlette.status import HTTP_201_CREATED, HTTP_404_NOT_FOUND

from api.pagination import CustomPage
from core.dependencies import session_dependency
from db import adder
from db.engine import get_db_session
from db.sample import Sample
from schemas_v2.sample import (
SampleResponse,
CreateSample,
)
from schemas_v2 import ResourceNotFoundResponse
from schemas_v2.sample import SampleResponse, CreateSample, UpdateSample
from services.query_helper import paginated_all_getter
from services.crud_helper import model_patcher

router = APIRouter(
prefix="/sample",
Expand All @@ -36,7 +35,7 @@

# ============= Post =============================================
@router.post("", status_code=HTTP_201_CREATED)
def add_sample(sample_data: CreateSample, session: Session = Depends(get_db_session)):
def add_sample(sample_data: CreateSample, session: session_dependency):
"""
Endpoint to add a sample.
"""
Expand Down Expand Up @@ -69,6 +68,33 @@ def add_sample(sample_data: CreateSample, session: Session = Depends(get_db_sess
# return adder(session, GeothermalSample, sample_data)


# ============= Update =============================================
@router.patch("/{sample_id}", summary="Update Sample")
def update_sample(
sample_id: int,
sample_data: UpdateSample,
session: Session = Depends(get_db_session),
Comment thread
jirhiker marked this conversation as resolved.
) -> SampleResponse | ResourceNotFoundResponse:
"""
Endpoint to update a sample.
"""

"""
Development notes:

What do we do if the field is nullable and the schema defaults to None?
If that occurs, then we update the field to None, which may not have
been the intension of the user. We could set some string to indicate
DO NOT UPDATE. Perhaps coordination between the front and backends?
"""
if session.get(Sample, sample_id) is None:
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"Sample with ID {sample_id} not found.",
)
return model_patcher(session, Sample, sample_id, sample_data)


# ============= Get =============================================
@router.get("", summary="Get Samples")
def get_samples(session: session_dependency) -> CustomPage[SampleResponse]:
Expand Down Expand Up @@ -102,11 +128,20 @@ def get_samples(session: session_dependency) -> CustomPage[SampleResponse]:

# ============= Get by ID =============================================
@router.get("/{sample_id}", summary="Get Sample by ID")
def get_sample_by_id(sample_id: int, session: session_dependency) -> SampleResponse:
def get_sample_by_id(
sample_id: int, session: session_dependency
) -> SampleResponse | ResourceNotFoundResponse:
"""
Endpoint to retrieve a sample by its ID.
"""
return session.get(Sample, sample_id)
sample = session.get(Sample, sample_id)
if sample is None:
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"Sample with ID {sample_id} not found.",
)
else:
return sample


# @router.get("/{sample_id}", summary="Get Geochemical Sample by ID")
Expand Down
18 changes: 13 additions & 5 deletions db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,25 @@
# limitations under the License.
# ===============================================================================

from db.asset import *
# import all models from db package so that Alembic can discover them

from db.base import *
from db.base import Base

from db.asset import *
from db.collabnet import *
from db.contact import *
from db.geochronology import *
from db.geothermal import *
from db.group import *
from db.lexicon import *
from db.location import *
from db.observation import *
from db.publication import *
from db.sample import *
from db.sensor import *
from db.sensor.groundwaterlevel import *
from db.sensor.sensor import *
from db.thing import *
from db.contact import *
from db.group import *


from sqlalchemy import (
func,
Expand All @@ -40,6 +45,9 @@
inspect_search_vectors,
search_manager,
)
from sqlalchemy.orm import configure_mappers

configure_mappers()


def adder(session, table, model, **kwargs):
Expand Down
6 changes: 6 additions & 0 deletions schemas_v2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
from pydantic import BaseModel


class ResourceNotFoundResponse(BaseModel):
detail: str


# ============= EOF =============================================
37 changes: 35 additions & 2 deletions schemas_v2/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
from datetime import datetime
from datetime import datetime, timezone
from pydantic import BaseModel, field_validator

from pydantic import BaseModel
from db.engine import get_db_session
from db import Thing


# -------- CREATE ----------
Expand Down Expand Up @@ -47,8 +49,39 @@ class CreateGeothermalSample(BaseModel):
# -------- RESPONSE ----------
class SampleResponse(BaseModel):
id: int
collection_timestamp: datetime
collection_method: str
thing_id: int


# -------- UPDATE ----------
class UpdateSample(BaseModel):
collection_timestamp: datetime | None = None
collection_method: str | None = None
thing_id: int | None = None

@field_validator("thing_id")
def validate_thing_id_exists(cls, thing_id: int) -> int:
"""
Validate that the thing_id exists in the database.
"""
with next(get_db_session()) as session:
thing = session.get(Thing, thing_id)
if not thing:
raise ValueError(f"Thing with ID {thing_id} does not exist.")
return thing_id

@field_validator("collection_timestamp")
def validate_collection_timestamp(cls, collection_timestamp: datetime) -> datetime:
"""
Validate that the collection_timestamp is not in the future.
"""
if collection_timestamp:
if collection_timestamp > datetime.now(tz=timezone.utc):
raise ValueError(
f"Collection timestamp {collection_timestamp} cannot be in the future."
)
return collection_timestamp


# ============= EOF =============================================
8 changes: 7 additions & 1 deletion services/query_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from fastapi import HTTPException
from fastapi_pagination.ext.sqlalchemy import paginate
from sqlalchemy import select, Float, Integer, Column, Select, func
from sqlalchemy import select, Float, Integer, Column, Select
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.sql.elements import OperatorExpression

Expand Down Expand Up @@ -100,6 +100,12 @@ def simple_get_by_id(session, table, item_id) -> object | None:
"""
Helper function to get a record by ID from the database.
"""
"""
REFACTOR NOTE/TODO: this function replicates the functionality of
session.get(table, item_id), which is a SQL Alchemy method to retrieve
a record by its primary key. This function can be replaced with
session.get(table, item_id).
"""
sql = select(table).where(table.id == item_id)
result = session.execute(sql)
return result.scalar_one_or_none()
Expand Down
77 changes: 3 additions & 74 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,13 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
import uuid

import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import configure_mappers

from core.app import init_lexicon, init_hypertables
from db.location import Location
from db.base import Base
from db.sample import Sample
from db.sensor import Sensor
from core.app import init_lexicon
from db import Base
from db.engine import engine
from main import app
from db.engine import engine, session_ctx
from services.thing_helper import add_thing

configure_mappers()

Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)
Expand All @@ -38,66 +29,4 @@
client = TestClient(app)


@pytest.fixture(scope="session")
def location():
with session_ctx() as session:
loc = Location(point="SRID=4326;POINT(0 0)")
session.add(loc)
session.commit()
session.refresh(loc)
yield loc

session.close()


@pytest.fixture(scope="session")
def thing(location):
with session_ctx() as session:
# loc = Location(point='SRID=4326;POINT(0 0)')
# session.add(loc)
# session.commit()
# session.refresh(loc)

wt = add_thing(
session,
{
"location_id": location.id,
"name": "Test Well",
},
"water well",
)

yield wt

session.close()


@pytest.fixture(scope="session")
def sample(thing):
with session_ctx() as session:
sample = Sample(
collection_timestamp="2025-01-01T00:00:00Z",
collection_method="manual",
thing_id=thing.id,
sample_type="groundwater",
sampler="Test Sampler",
)
session.add(sample)
session.commit()
yield sample

session.close()


@pytest.fixture(scope="session")
def sensor():
with session_ctx() as session:
sensor = Sensor(name=f"Test Sensor {uuid.uuid4()}")
session.add(sensor)
session.commit()
yield sensor

session.close()


# ============= EOF =============================================
Loading
Loading