Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""expand actively_monitored_wells to all groups

Drops the "WHERE group name = 'water level network'" restriction so the view
covers currently-monitored wells in any group, not just one. Public view
adds a group release_status = 'public' check instead, so draft/private
groups don't leak through now that any group can show up. Inner join to
group/group_thing_association is kept as-is (prod has no currently-monitored
well with zero group memberships); wells in multiple groups intentionally
appear once per group, no aggregation.

Revision ID: 986e0eb85ab3
Revises: c3d4e5f6a7b8
Create Date: 2026-08-20 10:55:25.697907

"""

from typing import Sequence, Union

from alembic import op
from sqlalchemy import text

# revision identifiers, used by Alembic.
revision: str = "986e0eb85ab3"
down_revision: Union[str, Sequence[str], None] = "c3d4e5f6a7b8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def _drop_view_or_materialized_view(view_name: str) -> None:
# DROP VIEW IF EXISTS / DROP MATERIALIZED VIEW IF EXISTS only suppress
# "relation does not exist" -- Postgres still raises WrongObjectType if
# the relation exists as the other kind, so the relation's actual kind
# must be checked first rather than trying both blindly.
bind = op.get_bind()
relkind = bind.execute(
text("SELECT relkind FROM pg_class WHERE oid = to_regclass(:name)"),
{"name": view_name},
).scalar()
if relkind == "m":
op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {view_name}"))
elif relkind == "v":
op.execute(text(f"DROP VIEW IF EXISTS {view_name}"))


def _create_actively_monitored_wells_view(all_groups: bool) -> str:
# The all_groups branch drops the group-name predicate but still needs
# to keep draft/private groups off the public mount -- unlike the old
# single-group filter, any group can appear here now, so the group's own
# release_status has to be checked directly (mirrors
# _create_project_areas_view's public_only handling).
group_filter = (
"g.release_status = 'public'\n AND "
if all_groups
else "lower(trim(g.name)) = 'water level network'\n AND "
)
Comment thread
Copilot marked this conversation as resolved.
return f"""
CREATE VIEW ogc_actively_monitored_wells AS
WITH latest_monitoring_status AS (
SELECT DISTINCT ON (sh.target_id)
sh.target_id AS thing_id,
sh.status_value
FROM status_history AS sh
WHERE
sh.target_table = 'thing'
AND sh.status_type = 'Monitoring Status'
ORDER BY sh.target_id, sh.start_date DESC, sh.id DESC
)
SELECT
wws.id,
wws.name,
Comment on lines +69 to +70

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jirhiker and @ksmuczynski
Copilot flagged that a well in multiple groups now produces duplicate rows sharing the same id, breaking /items/{id} lookups (verified live — it silently returns just one group, no error, not even deterministic). Need a decision on the fix:

  • Aggregate: one row per well, group info becomes arrays (group_names: [...]) instead of single values. Keeps id meaning "the well" everywhere, consistent with every other collection in this API.
  • Composite ID: keep one row per (well, group), but make composite id of well and group. Keeps per-group rows, but id would no longer match the well's real id the way it does in water_wells/water_well_summary today.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@likithabommasani21 which do you prefer and why?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm leaning towards aggregation — it keeps id meaning "the well" everywhere and gives one API call the full picture of a well's group memberships instead of splitting it across duplicate rows.

The one tradeoff: it changes the response shape - group_id become arrays instead of single values. If that's not a concern, I'd go with aggregation.

If it is something we need to avoid, the alternative is a composite ID. But, worth noting that means id on this collection would no longer map back to the well's real id.

'water well'::text AS thing_type,
wws.well_depth,
wws.elevation,
wws.elevation_method,
wws.formation_zone,
wws.total_water_levels,
wws.last_water_level,
wws.last_water_level_datetime,
wws.min_water_level,
wws.max_water_level,
wws.water_level_trend_ft_per_year,
g.id AS group_id,
g.name AS group_name,
g.group_type,
wws.point
FROM "group" AS g
JOIN group_thing_association AS gta ON gta.group_id = g.id
JOIN ogc_water_well_summary AS wws ON wws.id = gta.thing_id
JOIN latest_monitoring_status AS lms ON lms.thing_id = wws.id
WHERE {group_filter}lms.status_value = 'Currently monitored'
"""


def _create_internal_actively_monitored_wells_view(all_groups: bool) -> str:
group_filter = (
""
if all_groups
else "lower(trim(g.name)) = 'water level network'\n AND "
)
return f"""
CREATE VIEW ogc_internal_actively_monitored_wells AS
WITH latest_monitoring_status AS (
SELECT DISTINCT ON (sh.target_id)
sh.target_id AS thing_id,
sh.status_value
FROM status_history AS sh
WHERE
sh.target_table = 'thing'
AND sh.status_type = 'Monitoring Status'
ORDER BY sh.target_id, sh.start_date DESC, sh.id DESC
)
SELECT
wws.id,
wws.name,
'water well'::text AS thing_type,
wws.well_depth,
wws.elevation,
wws.elevation_method,
wws.formation_zone,
wws.total_water_levels,
wws.last_water_level,
wws.last_water_level_datetime,
wws.min_water_level,
wws.max_water_level,
wws.water_level_trend_ft_per_year,
g.id AS group_id,
g.name AS group_name,
g.group_type,
wws.point
FROM "group" AS g
JOIN group_thing_association AS gta ON gta.group_id = g.id
JOIN ogc_internal_water_well_summary AS wws ON wws.id = gta.thing_id
JOIN latest_monitoring_status AS lms ON lms.thing_id = wws.id
WHERE {group_filter}lms.status_value = 'Currently monitored'
Comment on lines +130 to +134

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

"""


def upgrade() -> None:
"""Upgrade schema."""
_drop_view_or_materialized_view("ogc_actively_monitored_wells")
op.execute(text(_create_actively_monitored_wells_view(all_groups=True)))
op.execute(
text(
"COMMENT ON VIEW ogc_actively_monitored_wells IS "
"'Actively (currently) monitored wells across all groups for pygeoapi.'"
)
)

_drop_view_or_materialized_view("ogc_internal_actively_monitored_wells")
op.execute(text(_create_internal_actively_monitored_wells_view(all_groups=True)))
op.execute(
text(
"COMMENT ON VIEW ogc_internal_actively_monitored_wells IS "
"'Actively (currently) monitored wells across all groups, "
"for the internal pygeoapi mount.'"
)
)


def downgrade() -> None:
"""Downgrade schema."""
_drop_view_or_materialized_view("ogc_actively_monitored_wells")
op.execute(text(_create_actively_monitored_wells_view(all_groups=False)))
op.execute(
text(
"COMMENT ON VIEW ogc_actively_monitored_wells IS "
"'Wells in the Water Level Network group for pygeoapi.'"
)
)

_drop_view_or_materialized_view("ogc_internal_actively_monitored_wells")
op.execute(text(_create_internal_actively_monitored_wells_view(all_groups=False)))
op.execute(
text(
"COMMENT ON VIEW ogc_internal_actively_monitored_wells IS "
"'Unfiltered wells in the Water Level Network group, "
"for the internal pygeoapi mount.'"
)
)
40 changes: 11 additions & 29 deletions tests/features/ogc-cleanup-sprint1.feature
Original file line number Diff line number Diff line change
Expand Up @@ -135,47 +135,29 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
| /ogcapi/collections/water_wells/items?datetime=2020-01-01/2024-01-01 |

# ---------------------------------------------------------------------------
# A4 — Fix brittle SQL filter in actively_monitored_wells
# A4/A6actively_monitored_wells covers all groups; name unchanged
# ---------------------------------------------------------------------------
# Note: these scenarios use actively_monitored_wells (the pre-A6 name). After A6
# is applied, requests to this ID redirect via the 90-day deprecation route.
# A6's original rename to water_level_network_wells was withdrawn: the name
# was fine, the filter was too narrow. A4 (brittle group-name filter) and A6
# (naming) are resolved together by the same SQL change.

@backend @ogc-data-currency @sprint-1 @high-priority @A4
Scenario: Layer result set is unchanged after replacing the string filter
Given the "Water Level Network" group exists in the database
Scenario: Layer includes a well from a group other than Water Level Network
Given a well is currently monitored under the "Test Other Group" group
When a client requests features from the actively_monitored_wells layer
Then the feature count is 322
Then the response includes that well

@backend @ogc-data-currency @sprint-1 @high-priority @A4
Scenario: Layer is resilient to group display name changes
Given the "Water Level Network" group display name is changed to "Water Level Monitoring Network"
When a client requests features from the actively_monitored_wells layer
Then the feature count is 322

# ---------------------------------------------------------------------------
# A6 — Rename actively_monitored_wells to water_level_network_wells
# ---------------------------------------------------------------------------

@backend @ogc-naming @sprint-1 @high-priority @A6
Scenario: Layer is accessible under the new ID water_level_network_wells
When a client requests /ogcapi/collections/water_level_network_wells/items
Then the response HTTP status is 200
And the response Content-Type is "application/geo+json"
Then wells in that group still appear in the response

@backend @ogc-naming @sprint-1 @high-priority @A6
Scenario: The renamed layer is discoverable in the collections catalog
Given the rename to water_level_network_wells has been applied across service configuration
Scenario: Layer keeps its existing ID and is discoverable in the collections catalog
When a client requests /ogcapi/collections
Then the water_level_network_wells collection appears in the response
And the actively_monitored_wells collection does not appear in the response

@backend @ogc-naming @sprint-1 @high-priority @A6
Scenario: Old layer ID returns deprecation headers during the 90-day grace period
When a client requests /ogcapi/collections/actively_monitored_wells/items
Then the response HTTP status is 200
And the response includes a Deprecation header
And the response includes a Sunset header containing a valid RFC 7231 date
And the response includes a Link header pointing to the water_level_network_wells collection
Then the actively_monitored_wells collection appears in the response
And the water_level_network_wells collection does not appear in the response

# ---------------------------------------------------------------------------
# A11 — Stand up authenticated internal OGC mount at /ogcapi-internal
Expand Down
119 changes: 119 additions & 0 deletions tests/test_ogc.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,125 @@ def test_ogc_actively_monitored_wells_excludes_latest_not_currently_monitored(
session.commit()


def test_ogc_actively_monitored_wells_includes_wells_from_other_groups(
water_well_thing,
groundwater_level_observation,
):
with session_ctx() as session:
session.execute(text("REFRESH MATERIALIZED VIEW ogc_water_well_summary"))
session.execute(
text("REFRESH MATERIALIZED VIEW ogc_internal_water_well_summary")
)
session.commit()

group = Group(
name="Test Other Group",
group_type="Monitoring Plan",
release_status="public",
)
session.add(group)
session.flush()

group_assoc = GroupThingAssociation(
group_id=group.id,
thing_id=water_well_thing.id,
)
session.add(group_assoc)
status_history = StatusHistory(
status_type="Monitoring Status",
status_value="Currently monitored",
start_date=date(2024, 1, 1),
target_id=water_well_thing.id,
target_table="thing",
)
session.add(status_history)
session.commit()

row = session.execute(
text(
"SELECT group_id, group_name, group_type "
"FROM ogc_actively_monitored_wells WHERE id = :thing_id"
),
{"thing_id": water_well_thing.id},
).one()

assert row.group_id == group.id
assert row.group_name == "Test Other Group"
assert row.group_type == "Monitoring Plan"

internal_row = session.execute(
text(
"SELECT group_id, group_name, group_type "
"FROM ogc_internal_actively_monitored_wells WHERE id = :thing_id"
),
{"thing_id": water_well_thing.id},
).one()

assert internal_row.group_id == group.id
assert internal_row.group_name == "Test Other Group"
assert internal_row.group_type == "Monitoring Plan"

session.delete(status_history)
session.delete(group_assoc)
session.delete(group)
session.commit()


def test_ogc_actively_monitored_wells_hides_draft_group_on_public_view(
water_well_thing,
groundwater_level_observation,
):
with session_ctx() as session:
session.execute(text("REFRESH MATERIALIZED VIEW ogc_water_well_summary"))
session.execute(
text("REFRESH MATERIALIZED VIEW ogc_internal_water_well_summary")
)
session.commit()

group = Group(
name="Test Draft Group",
group_type="Monitoring Plan",
release_status="draft",
)
session.add(group)
session.flush()

group_assoc = GroupThingAssociation(
group_id=group.id,
thing_id=water_well_thing.id,
)
session.add(group_assoc)
status_history = StatusHistory(
status_type="Monitoring Status",
status_value="Currently monitored",
start_date=date(2024, 1, 1),
target_id=water_well_thing.id,
target_table="thing",
)
session.add(status_history)
session.commit()

public_row = session.execute(
text("SELECT id FROM ogc_actively_monitored_wells WHERE id = :thing_id"),
{"thing_id": water_well_thing.id},
).one_or_none()
assert public_row is None

internal_row = session.execute(
text(
"SELECT group_id FROM ogc_internal_actively_monitored_wells "
"WHERE id = :thing_id"
),
{"thing_id": water_well_thing.id},
).one()
assert internal_row.group_id == group.id

session.delete(status_history)
session.delete(group_assoc)
session.delete(group)
session.commit()


def test_ogc_collections(ogc_client):
response = ogc_client.get("/ogcapi/collections")
assert response.status_code == 200
Expand Down
Loading