diff --git a/alembic/versions/986e0eb85ab3_expand_actively_monitored_wells_to_all_.py b/alembic/versions/986e0eb85ab3_expand_actively_monitored_wells_to_all_.py new file mode 100644 index 00000000..0b71d362 --- /dev/null +++ b/alembic/versions/986e0eb85ab3_expand_actively_monitored_wells_to_all_.py @@ -0,0 +1,288 @@ +"""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. A well in +multiple groups is aggregated into one row (group_ids/group_names/group_types +as arrays) rather than one row per group, so `id` stays unique -- pygeoapi's +id_field: id assumes exactly one row per id for /items/{id} lookups. + +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: + if all_groups: + # Aggregated: one row per well, group_ids/group_names/group_types as + # arrays, so `id` stays unique even when a well belongs to several + # groups. release_status = 'public' is checked on the group row + # itself (mirrors _create_project_areas_view's public_only handling) + # since any group can appear here now, not just one hardcoded one. + # group_thing_association has no unique constraint on + # (group_id, thing_id), so distinct_memberships de-dupes before + # aggregating; all three arrays are ordered by the same group_id key + # so they stay index-aligned with each other (ordering each array by + # its own column, e.g. names alphabetically, would desync them). + return """ + 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 + ), + distinct_memberships AS ( + SELECT DISTINCT + gta.thing_id, + g.id AS group_id, + g.name AS group_name, + g.group_type + FROM group_thing_association AS gta + JOIN "group" AS g ON g.id = gta.group_id + WHERE g.release_status = 'public' + ) + 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, + array_agg(dm.group_id ORDER BY dm.group_id) AS group_ids, + array_agg(dm.group_name ORDER BY dm.group_id) AS group_names, + array_agg(dm.group_type ORDER BY dm.group_id) AS group_types, + wws.point + FROM ogc_water_well_summary AS wws + JOIN latest_monitoring_status AS lms ON lms.thing_id = wws.id + JOIN distinct_memberships AS dm ON dm.thing_id = wws.id + WHERE lms.status_value = 'Currently monitored' + GROUP BY + wws.id, wws.name, 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, + wws.point + """ + # Historical (downgrade target): byte-for-byte the pre-fix view, single + # group_id/group_name/group_type columns, scoped to one hardcoded group. + return """ + 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, + '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 lower(trim(g.name)) = 'water level network' + AND lms.status_value = 'Currently monitored' + """ + + +def _create_internal_actively_monitored_wells_view(all_groups: bool) -> str: + if all_groups: + # Aggregated, same shape as the public view's all_groups branch, but + # no release_status filter -- the internal mount is unfiltered by + # design, same as its sibling views. See the public branch's comment + # for why distinct_memberships + a shared ORDER BY key is needed. + return """ + 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 + ), + distinct_memberships AS ( + SELECT DISTINCT + gta.thing_id, + g.id AS group_id, + g.name AS group_name, + g.group_type + FROM group_thing_association AS gta + JOIN "group" AS g ON g.id = gta.group_id + ) + 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, + array_agg(dm.group_id ORDER BY dm.group_id) AS group_ids, + array_agg(dm.group_name ORDER BY dm.group_id) AS group_names, + array_agg(dm.group_type ORDER BY dm.group_id) AS group_types, + wws.point + FROM ogc_internal_water_well_summary AS wws + JOIN latest_monitoring_status AS lms ON lms.thing_id = wws.id + JOIN distinct_memberships AS dm ON dm.thing_id = wws.id + WHERE lms.status_value = 'Currently monitored' + GROUP BY + wws.id, wws.name, 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, + wws.point + """ + # Historical (downgrade target): byte-for-byte the pre-fix view. + return """ + 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 lower(trim(g.name)) = 'water level network' + AND lms.status_value = 'Currently monitored' + """ + + +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.'" + ) + ) diff --git a/core/ogc-field-descriptions.yml b/core/ogc-field-descriptions.yml index 2c83681f..dd01ac95 100644 --- a/core/ogc-field-descriptions.yml +++ b/core/ogc-field-descriptions.yml @@ -315,17 +315,19 @@ actively_monitored_wells: is falling. x-ogc-unit: https://qudt.org/vocab/unit/FT-PER-YR x-ogc-unitLang: QUDT - group_id: - title: Network ID - description: Identifier of the monitoring network the well belongs to. - group_name: - title: Network name - description: >- - Name of the monitoring network the well belongs to. Always the Water - Level Network in this collection. - group_type: - title: Network type - description: Kind of grouping the network record represents. + group_ids: + title: Network IDs + description: Identifiers of every monitoring network the well belongs to. + group_names: + title: Network names + description: >- + Names of every monitoring network the well belongs to, in the same + order as group_ids. + group_types: + title: Network types + description: >- + Kind of grouping each network record represents, in the same order as + group_ids. depth_to_water_trend_wells: record_count: diff --git a/tests/features/ogc-cleanup-sprint1.feature b/tests/features/ogc-cleanup-sprint1.feature index d747c8be..bfab8bc9 100644 --- a/tests/features/ogc-cleanup-sprint1.feature +++ b/tests/features/ogc-cleanup-sprint1.feature @@ -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/A6 — actively_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 diff --git a/tests/test_ogc.py b/tests/test_ogc.py index 8b49ac0a..35ed69ac 100644 --- a/tests/test_ogc.py +++ b/tests/test_ogc.py @@ -491,15 +491,15 @@ def test_ogc_actively_monitored_wells_exposes_water_level_network_group_wells( row = session.execute( text( - "SELECT group_id, group_name, group_type " + "SELECT group_ids, group_names, group_types " "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 == "Water Level Network" - assert row.group_type == "Monitoring Plan" + assert row.group_ids == [group.id] + assert row.group_names == ["Water Level Network"] + assert row.group_types == ["Monitoring Plan"] session.delete(status_history) session.delete(group_assoc) @@ -559,6 +559,139 @@ def test_ogc_actively_monitored_wells_excludes_latest_not_currently_monitored( session.commit() +def test_ogc_actively_monitored_wells_aggregates_multiple_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_a = Group( + name="Test Other Group A", + group_type="Monitoring Plan", + release_status="public", + ) + group_b = Group( + name="Test Other Group B", + group_type="Monitoring Plan", + release_status="public", + ) + session.add_all([group_a, group_b]) + session.flush() + + group_assoc_a = GroupThingAssociation( + group_id=group_a.id, + thing_id=water_well_thing.id, + ) + group_assoc_b = GroupThingAssociation( + group_id=group_b.id, + thing_id=water_well_thing.id, + ) + session.add_all([group_assoc_a, group_assoc_b]) + 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_ids, group_names, group_types " + "FROM ogc_actively_monitored_wells WHERE id = :thing_id" + ), + {"thing_id": water_well_thing.id}, + ).one() + + assert set(row.group_ids) == {group_a.id, group_b.id} + assert set(row.group_names) == {"Test Other Group A", "Test Other Group B"} + assert row.group_types == ["Monitoring Plan", "Monitoring Plan"] + + internal_row = session.execute( + text( + "SELECT group_ids, group_names, group_types " + "FROM ogc_internal_actively_monitored_wells WHERE id = :thing_id" + ), + {"thing_id": water_well_thing.id}, + ).one() + + assert set(internal_row.group_ids) == {group_a.id, group_b.id} + assert set(internal_row.group_names) == { + "Test Other Group A", + "Test Other Group B", + } + assert internal_row.group_types == ["Monitoring Plan", "Monitoring Plan"] + + session.delete(status_history) + session.delete(group_assoc_a) + session.delete(group_assoc_b) + session.delete(group_a) + session.delete(group_b) + 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_ids FROM ogc_internal_actively_monitored_wells " + "WHERE id = :thing_id" + ), + {"thing_id": water_well_thing.id}, + ).one() + assert internal_row.group_ids == [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