Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
87 changes: 87 additions & 0 deletions api/chemisty.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,17 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
from datetime import datetime

from fastapi import APIRouter
from fastapi_pagination.ext.sqlalchemy import paginate
from sqlalchemy import asc, desc, select

from api.pagination import CustomPage
from core.dependencies import amp_viewer_dependency, session_dependency
from db.chemistry_views import WaterChemistryResultsView
from schemas.chemistry import WaterChemistryResultResponse
from services.legacy_chemistry import canonical_parameter_name, result_kind

# from services.validation.chemistry import validate_analyte

Expand All @@ -25,6 +35,83 @@
)


# Only columns that mean something to a client of this endpoint. A whitelist
# rather than getattr on the view: the latter would expose every column,
# including the ones carrying release state, as a public sort key.
_RESULT_SORT_COLUMNS = {
"observation_datetime": WaterChemistryResultsView.observation_datetime,
"parameter_name": WaterChemistryResultsView.parameter_name,
"value": WaterChemistryResultsView.value,
"id": WaterChemistryResultsView.id,
}


@router.get("/results", summary="Get water chemistry results", tags=["chemistry"])
def get_water_chemistry_results(
session: session_dependency,
user: amp_viewer_dependency,
thing_id: int | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
sort: str | None = None,
order: str | None = None,
) -> CustomPage[WaterChemistryResultResponse]:
"""
Retrieve water chemistry results, one row per analyte.

Reads the legacy NMA chemistry tables, which is where the water chemistry
actually is -- the refactored `observation` table holds none of it. Rows
come from the public view, so an unreleased thing or a sample flagged
`PublicRelease = false` is not served here regardless of who is asking.

`start_time` is inclusive and `end_time` exclusive, so a calendar year is
`start_time=YYYY-01-01&end_time=YYYY+1-01-01` with no risk of picking up a
result recorded at midnight on New Year's Day of the following year.

`sort` accepts `observation_datetime`, `parameter_name`, `value`, or `id`;
`order` accepts `asc` or `desc`. The default is newest first, so a client
that wants a well's most recent analysis can ask for size 1.
"""
query = select(WaterChemistryResultsView)

if thing_id is not None:
query = query.where(WaterChemistryResultsView.thing_id == thing_id)

if start_time is not None:
query = query.where(
WaterChemistryResultsView.observation_datetime >= start_time
)

if end_time is not None:
query = query.where(WaterChemistryResultsView.observation_datetime < end_time)

sort_column = _RESULT_SORT_COLUMNS.get(
sort or "observation_datetime",
WaterChemistryResultsView.observation_datetime,
)
direction = asc if (order or "desc").lower() == "asc" else desc

# id is the tiebreaker so paging is stable: without it two analytes sharing
# a timestamp can swap pages between requests and be served twice or never.
query = query.order_by(direction(sort_column), WaterChemistryResultsView.id)

def transformer(rows):
# Analytes come out of the legacy tables as symbols; the response
# speaks the lexicon's names so a consumer can match a result to a
# drinking water standard without knowing the legacy vocabulary.
return [
WaterChemistryResultResponse.model_validate(row).model_copy(
update={
"parameter_name": canonical_parameter_name(row.parameter_name),
"result_kind": result_kind(row.id),
}
)
for row in rows
]

return paginate(query=query, conn=session, transformer=transformer)


# @router.get(
# "/analysis_set",
# response_model=CustomPage[WaterChemistryAnalysisSetResponse],
Expand Down
2 changes: 2 additions & 0 deletions core/initializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,10 @@ def register_api_routes(app):
from api.feedback import router as feedback_router
from api.disclaimer import router as disclaimer_router
from api.geothermal import router as geothermal_router
from api.chemisty import router as chemistry_router

app.include_router(asset_router)
app.include_router(chemistry_router)
app.include_router(author_router)
app.include_router(contact_router)
app.include_router(disclaimer_router)
Expand Down
77 changes: 77 additions & 0 deletions db/chemistry_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# ===============================================================================
# Copyright 2026 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.
# ===============================================================================
"""Read-only mappings over the legacy water-chemistry views.

`ogc_water_chemistry` and `ogc_internal_water_chemistry` are materialized views
built in d9e0f1a2b3c4 by unioning the four legacy NMA chemistry tables
(NMA_MajorChemistry, NMA_MinorTraceChemistry, NMA_Radionuclides,
NMA_FieldParameters) into one analyte-per-row shape. They were added for the OGC
EDR mount; these mappings let the REST API serve the same rows, which is where
the chemistry data actually lives -- the refactored `observation` table holds no
water chemistry.

Views only. Like db/ngwmn_views.py these use their own declarative base so
Alembic never tries to autogenerate a table for them, and the underlying
relations are refreshed by the migration that owns them, not from here.
"""

from datetime import datetime

from sqlalchemy import DateTime, Float, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class ChemistryViewBase(DeclarativeBase):
"""Declarative base for chemistry view mappings, excluded from Alembic."""


class _WaterChemistryResultColumns:
"""Columns shared by the public and internal chemistry views.

`id` is a text key (``maj-1``, ``min-2``, ``rad-3``, ``fld-4``) rather than
an integer: a row's identity is which legacy table it came from plus that
table's own id, and the four id sequences overlap.
"""

id: Mapped[str] = mapped_column("id", String, primary_key=True)
thing_id: Mapped[int] = mapped_column("thing_id", Integer)
station_name: Mapped[str | None] = mapped_column("station_name", String)
thing_type: Mapped[str | None] = mapped_column("thing_type", String)
sample_id: Mapped[int | None] = mapped_column("sample_id", Integer)
parameter_name: Mapped[str] = mapped_column("parameter_name", String)
value: Mapped[float | None] = mapped_column("value", Float)
unit: Mapped[str | None] = mapped_column("unit", String)
# Named `datetime` in the view; exposed under the name the observation
# endpoints already use so clients do not need a second field name.
observation_datetime: Mapped[datetime] = mapped_column("datetime", DateTime)
release_status: Mapped[str | None] = mapped_column("release_status", String)


class WaterChemistryResultsView(_WaterChemistryResultColumns, ChemistryViewBase):
"""Public chemistry analyses: released things, released samples."""

__tablename__ = "ogc_water_chemistry"


class InternalWaterChemistryResultsView(
_WaterChemistryResultColumns, ChemistryViewBase
):
"""Every chemistry analysis, including unreleased things and samples."""

__tablename__ = "ogc_internal_water_chemistry"


# ============= EOF =============================================
69 changes: 69 additions & 0 deletions schemas/chemistry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# ===============================================================================
# Copyright 2026 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 datetime import datetime, timezone
from typing import Literal

from pydantic import BaseModel, ConfigDict, field_serializer, field_validator


class WaterChemistryResultResponse(BaseModel):
"""One legacy chemistry analyte result.

Not a `BaseResponseModel`: the row comes from a view over the legacy NMA
tables, so it has a text id rather than an integer one and carries no
`created_at` of its own.
"""

id: str
thing_id: int
station_name: str | None = None
sample_id: int | None = None
parameter_name: str
value: float | None = None
unit: str | None = None
observation_datetime: datetime
# Which legacy table the result came from. A field measurement was read at
# the wellhead and a lab one was not, which is the distinction an
# owner-facing report has to draw -- and the legacy tables are the only
# place that distinction is recorded.
result_kind: Literal["major", "minor", "radionuclide", "field", "unknown"] = (
"unknown"
)

model_config = ConfigDict(from_attributes=True)

@field_validator("observation_datetime")
@classmethod
def assume_utc(cls, value: datetime) -> datetime:
"""Stamp naive legacy timestamps as UTC.

The legacy tables store collection and analysis dates without a zone --
they are calendar dates, not instants. Attaching UTC keeps them stable:
`astimezone` on a naive value would read it in the server's local zone,
which would move a sample collected Jan 01 into the previous year for
any server west of Greenwich, and a report for that year would then come
back empty.
"""
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)

@field_serializer("observation_datetime")
def serialize_observation_datetime(self, value: datetime) -> str:
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


# ============= EOF =============================================
8 changes: 8 additions & 0 deletions schemas/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ class LocationGeoJSONResponse(BaseModel):
@model_validator(mode="before")
@classmethod
def populate_fields(cls, data: Any) -> Any:
# A thing can have no current location -- it is associated with one
# over an effective period, and that period can be closed or never
# opened. Hand None straight back so the optional annotation resolves
# it, rather than reaching for __table__ on it and turning a well with
# no location into a 500 for the whole page it appears on.
if data is None:
return None

# convert row to dictionary
if not isinstance(data, dict):
data_dict = {c.name: getattr(data, c.name) for c in data.__table__.columns}
Expand Down
2 changes: 1 addition & 1 deletion schemas/thing.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ class BaseThingResponse(BaseResponseModel):
name: str
site_name: str | None = None
thing_type: str
current_location: LocationGeoJSONResponse
current_location: LocationGeoJSONResponse | None = None
first_visit_date: PastOrTodayDate | None
groups: list[GroupResponse] = []
monitoring_status: str | None
Expand Down
Loading
Loading