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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ Authentik groups granted.
**Role families are orthogonal**: general `Admin` confers nothing in the AMP or
Lexicon families. Only tiers *within* a family nest.

**`AMP.Staging`** is a standalone group, not a fourth AMP tier — `AMPAdmin`
does not satisfy it. It gates the hydrograph corrector's publish and range-delete
routes while the workbench is being validated against real logger files, so they
ship dark. Read **`docs/hydrograph-correction-publish.md`** before changing
them.

**Authorization is opt-in per endpoint** — a `user: <role>_dependency` parameter
in the signature, not a router-level `dependencies=[...]`. Omitting it produces a
fully public endpoint with no error. `tests/test_authorization.py` holds the
Expand Down
130 changes: 130 additions & 0 deletions alembic/versions/c3d4e5f6a7b8_hydrograph_correction_publish.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""publish provenance for corrected transducer blocks

Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-08-19

The hydrograph corrector publishes a *derived* series: water head converted to
depth below ground surface against manual anchors, then shifted, snapped, and
drift-corrected. None of those numbers are what the instrument recorded, so the
database has to carry enough to tell a reviewer what happened to them.

Three columns on the block cover the batch: the file it came from, whether that
file held water head or depth to water, and the ordered list of corrections
applied. `comment` already exists and takes the publisher's free-text note.

One column on the observation covers the row: `note`, set only on readings a
correction actually moved. NULL therefore means "as measured", which is the
distinction review needs. The legacy `nma_waterlevelscontinuous_*_notes`
columns cannot serve -- each is scoped to one legacy source table.

The block time-order check is relaxed from `>` to `>=`. A block spanning a
single instant is legitimate: a published file with one reading, or a block
narrowed by a range delete until one observation survives. The block reader
matches observations inclusively on both bounds, so a zero-width block still
covers its reading. Loosening a check constraint cannot invalidate existing
rows.

That check also gets its name spelled right on the way through. It was created
as `check_transuder_block_time_order` -- no `c` -- and since Postgres cannot
alter a check in place, the drop-and-recreate this migration already performs
is the free moment to fix it. The old name is dropped and the new one created;
no separate RENAME is needed.
"""

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

revision = "c3d4e5f6a7b8"
down_revision = "b2c3d4e5f6a7"
branch_labels = None
depends_on = None

# The name as created by the initial migration, misspelled. Drops and
# downgrades have to use it verbatim: `op.drop_constraint` matches on the name
# in the live database, so correcting the spelling anywhere it is used to
# *find* the constraint would make the statement a no-op target and fail.
LEGACY_TIME_ORDER_CONSTRAINT = "check_transuder_block_time_order"

# What it is called from this migration forward.
TIME_ORDER_CONSTRAINT = "check_transducer_block_time_order"


def upgrade() -> None:
op.add_column(
"transducer_observation_block",
sa.Column(
"source_file",
sa.String(length=255),
nullable=True,
comment="Name of the logger file the corrected series was derived from",
),
)
op.add_column(
"transducer_observation_block",
sa.Column(
"source_kind",
sa.String(length=50),
nullable=True,
comment="What the source file measured: water_head or depth_to_water",
),
)
op.add_column(
"transducer_observation_block",
sa.Column(
"corrections",
postgresql.JSONB(astext_type=sa.Text()),
nullable=True,
comment="Corrections applied to the source series, in applied order",
),
)
op.add_column(
"transducer_observation",
sa.Column(
"note",
sa.Text(),
nullable=True,
comment=(
"Per-reading correction annotation; NULL means the value is as "
"measured"
),
),
)

# Dropped under the old name, recreated under the new one: the rename and
# the relaxation are the same statement pair, so there is no window where
# the table is unconstrained beyond the one this already needs.
op.drop_constraint(
LEGACY_TIME_ORDER_CONSTRAINT, "transducer_observation_block", type_="check"
)
op.create_check_constraint(
TIME_ORDER_CONSTRAINT,
"transducer_observation_block",
"end_datetime >= start_datetime",
)


def downgrade() -> None:
# Zero-width blocks may have been created while the loosened constraint was
# in force, so widen them by a second rather than let the stricter
# constraint fail to validate. A one-second span on a block that covered an
# instant is a smaller lie than a failed downgrade.
op.execute(
"UPDATE transducer_observation_block "
"SET end_datetime = start_datetime + interval '1 second' "
"WHERE end_datetime = start_datetime"
)
op.drop_constraint(
TIME_ORDER_CONSTRAINT, "transducer_observation_block", type_="check"
)
op.create_check_constraint(
LEGACY_TIME_ORDER_CONSTRAINT,
"transducer_observation_block",
"end_datetime > start_datetime",
)

op.drop_column("transducer_observation", "note")
op.drop_column("transducer_observation_block", "corrections")
op.drop_column("transducer_observation_block", "source_kind")
op.drop_column("transducer_observation_block", "source_file")
122 changes: 113 additions & 9 deletions api/observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
session_dependency,
amp_admin_dependency,
amp_editor_dependency,
amp_staging_dependency,
amp_viewer_dependency,
)
from db import Observation, Parameter
Expand All @@ -40,7 +41,12 @@
UpdateGroundwaterLevelObservation,
UpdateWaterChemistryObservation,
)
from schemas.transducer import TransducerObservationWithBlockResponse
from schemas.transducer import (
DeletedTransducerObservationsResponse,
PublishedTransducerBlockResponse,
PublishTransducerBlock,
TransducerObservationWithBlockResponse,
)
from schemas.water_level_csv import WaterLevelBulkUploadResponse
from services.crud_helper import model_deleter, model_adder
from services.observation_helper import (
Expand All @@ -50,10 +56,30 @@
get_transducer_observations,
)
from services.query_helper import simple_get_by_id
from services.transducer_helper import (
delete_transducer_observations,
publish_transducer_block,
)
from services.water_level_csv import bulk_upload_water_levels

router = APIRouter(prefix="/observation", tags=["observation"])


def _groundwater_level_parameter_id(session) -> int:
"""
The lexicon id the transducer routes work in.

Looked up rather than configured so the publish, read, and delete routes
cannot drift onto different parameters.
"""
return (
session.query(Parameter)
.filter(Parameter.parameter_name == "groundwater level")
.one()
.id
)


"""
TODO

Expand Down Expand Up @@ -88,6 +114,37 @@ def add_water_chemistry_observation(
return model_adder(session, Observation, obs_data, user=user)


@router.post(
"/transducer-groundwater-level/block",
status_code=HTTP_201_CREATED,
summary="Publish a corrected transducer series as one block",
)
def publish_transducer_groundwater_level_block(
payload: PublishTransducerBlock,
session: session_dependency,
user: amp_staging_dependency,
replace_overlapping: bool = False,
) -> PublishedTransducerBlockResponse:
"""
Publish one corrected logger file as a single observation block.

The block's time span is derived from the measurements; the client does not
send it. Overlapping an existing block is a 409 listing the collisions --
pass `replace_overlapping=true` to supersede them, which deletes those
blocks and their readings in the same transaction.

Written by the hydrograph corrector in OcotilloUI. See
`docs/hydrograph-correction-publish.md`.
"""
return publish_transducer_block(
session,
payload,
parameter_id=_groundwater_level_parameter_id(session),
user=user,
replace_overlapping=replace_overlapping,
)


@router.post(
"/groundwater-level/bulk-upload",
response_model=WaterLevelBulkUploadResponse,
Expand Down Expand Up @@ -155,17 +212,30 @@ def get_transducer_groundwater_level_observations(
thing_id: int | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
sort: str | None = None,
order: str | None = None,
) -> CustomPage[TransducerObservationWithBlockResponse]:
"""
Retrieve transducer groundwater level observations paired with the block
that covers them.

groundwater_parameter_id = (
session.query(Parameter)
.filter(Parameter.parameter_name == "groundwater level")
.one()
.id
)

`sort` accepts `observation_datetime`, `value`, or `id`; `order` accepts
`asc` or `desc`. The default is newest first, so a client that wants the
latest stored reading for a well can ask for size 1.
"""
# Keyword arguments deliberately: the helper's fourth positional parameter
# is `sensor_id`, so the previous positional call passed `start_time` as a
# sensor id (unused, silently dropped), `end_time` as `start_time`, and
# nothing as `end_time` -- an upper bound the caller asked for was ignored
# and the lower bound came from the wrong argument.
return get_transducer_observations(
session, thing_id, groundwater_parameter_id, start_time, end_time
session,
thing_id=thing_id,
parameter_id=_groundwater_level_parameter_id(session),
start_time=start_time,
end_time=end_time,
sort=sort,
order=order,
)


Expand Down Expand Up @@ -302,6 +372,40 @@ def get_observation_by_id(
# DELETE =======================================================================


@router.delete(
"/transducer-groundwater-level",
status_code=HTTP_200_OK,
summary="Delete transducer groundwater level observations in a time range",
)
def delete_transducer_groundwater_level_observations(
session: session_dependency,
user: amp_staging_dependency,
thing_id: int,
start_time: datetime,
end_time: datetime,
) -> DeletedTransducerObservationsResponse:
"""
Delete every transducer groundwater level reading for a well inside a
closed time range, and reconcile the blocks that covered them: a block left
with no readings is deleted, one left with some has its span narrowed to
the survivors.

All three parameters are required -- there is deliberately no form of this
request that deletes everything for a well. Scoped exactly like the `GET`
on this path, so the set previewed there is the set removed here.

Irreversible, and it leaves the `transducer_daily_data` materialized view
stale until its next refresh.
"""
return delete_transducer_observations(
session,
thing_id=thing_id,
parameter_id=_groundwater_level_parameter_id(session),
start_time=start_time,
end_time=end_time,
)


@router.delete(
"/{observation_id}",
summary="Delete an observation",
Expand Down
17 changes: 17 additions & 0 deletions core/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@
amp_viewer_function = authenticated(any_of=["AMPAdmin", "AMPEditor", "AMPViewer"])


# Hydrograph-Corrector Staging Permissions -------------------------------------
# The hydrograph corrector's publish and range-delete routes write and destroy
# transducer records, and the workbench driving them is still being validated
# against real logger files. `AMP.Staging` is its own group with no tier below
# it and no AMP tier above it -- an AMPAdmin does not satisfy it. Nobody holds
# it until it is granted in Authentik, so the routes ship dark and reachable
# only by whoever is testing them.
#
# This is deliberately not a fourth rung on the AMP ladder. When the workbench
# is trusted, these routes move to `amp_admin_dependency` and the group goes
# away; leaving it as a tier would make that a schema change instead of a
# one-line edit.
amp_staging_function = authenticated(any_of=["AMP.Staging"])


# Lexicon-Specific Authentication/Permissions ----------------------------------

lexicon_admin_function = authenticated(any_of=["LexiconAdmin"])
Expand Down Expand Up @@ -89,5 +104,7 @@
amp_editor_dependency: TypeAlias = Annotated[dict, Depends(amp_editor_function)]
amp_viewer_dependency: TypeAlias = Annotated[dict, Depends(amp_viewer_function)]

amp_staging_dependency: TypeAlias = Annotated[dict, Depends(amp_staging_function)]

no_permission_dependency: TypeAlias = Annotated[dict, Depends(no_permission_function)]
# ============= EOF =============================================
Loading
Loading