diff --git a/alembic/versions/c8d9e0f1a2b3_publish_imported_project_areas.py b/alembic/versions/c8d9e0f1a2b3_publish_imported_project_areas.py new file mode 100644 index 000000000..f3beb14bf --- /dev/null +++ b/alembic/versions/c8d9e0f1a2b3_publish_imported_project_areas.py @@ -0,0 +1,68 @@ +"""publish imported project areas + +Promotes every group carrying a project_area boundary from the untouched +"draft" default to release_status = 'public', so the boundaries imported from +the maps.nmt.edu Water_Resources layer reach the ogc_project_areas OGC layer. + +Why this is needed: cli/project_area_import.py writes group.project_area but +never set release_status, so imported rows kept ReleaseMixin's "draft" default. +ogc_project_areas (see t6u7v8w9x0y1, re-created by f4a5b6c7d8e9) filters on +release_status = 'public', so the collection is published and advertised while +serving zero features. + +Scope is deliberately narrow on two axes: + +* Only rows with a non-null project_area. A group without geometry has nothing + to contribute to the layer, and its release status is none of this + migration's business. +* Only rows still at "draft". "private" and "archived" are deliberate curation + decisions and must survive this migration; "provisional" and "public" need no + change. Project areas are boundary polygons already published on + maps.nmt.edu, so publishing the untouched default carries no disclosure risk. + +group_type is deliberately NOT used to select rows. It is not provenance -- a +"Geographic Area" may itself be a legacy project -- so filtering on it would +both miss imported areas and catch rows this migration has no claim on. + +Revision ID: c8d9e0f1a2b3 +Revises: b7c8d9e0f1a2 +Create Date: 2026-08-13 14:05:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "c8d9e0f1a2b3" +down_revision: Union[str, Sequence[str], None] = "b7c8d9e0f1a2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +PUBLISH_SQL = """ + UPDATE "group" + SET release_status = 'public' + WHERE project_area IS NOT NULL + AND release_status = 'draft' +""" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + if "group" not in set(inspector.get_table_names(schema="public")): + raise RuntimeError( + "Cannot publish project areas. Missing required table: group" + ) + + result = bind.execute(text(PUBLISH_SQL)) + print(f"Published {result.rowcount} project area group(s) to the OGC layer.") + + +def downgrade() -> None: + # Deliberately a no-op. Once applied, a published project area is + # indistinguishable from one that was public before this ran, so reverting + # would demote rows this migration never touched. Re-privatising a specific + # group is an editorial action, not a schema rollback. + pass diff --git a/cli/cli.py b/cli/cli.py index b4ff204c6..21d42e0c5 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -1352,6 +1352,7 @@ def import_project_area_boundaries_command( typer.echo(f"Created {result.created} group(s).") typer.echo(f"Updated {result.updated} group project area(s).") typer.echo(f"Skipped {result.skipped} unchanged group(s).") + typer.echo(f"Published {result.published} group(s) to the OGC layer.") if result.unmatched_locations: typer.echo( "Unmatched locations: " + ", ".join(result.unmatched_locations), diff --git a/cli/project_area_import.py b/cli/project_area_import.py index a16d4147a..f4803785d 100644 --- a/cli/project_area_import.py +++ b/cli/project_area_import.py @@ -19,6 +19,15 @@ ) PROJECT_AREA_PAGE_SIZE = 1000 +# Boundaries published on maps.nmt.edu are already public, so the groups they +# land on belong in the ogc_project_areas view (release_status = 'public'). +# Without this the rows keep ReleaseMixin's "draft" default and the OGC layer +# serves nothing. +PUBLIC_RELEASE_STATUS = "public" +# Only promote from the untouched default. "private" and "archived" are +# deliberate curation decisions, and an import must not overturn them. +PROMOTABLE_RELEASE_STATUSES = frozenset({"draft"}) + @dataclass(frozen=True) class ProjectAreaImportResult: @@ -27,6 +36,7 @@ class ProjectAreaImportResult: updated: int created: int skipped: int + published: int unmatched_locations: tuple[str, ...] @@ -95,6 +105,7 @@ def import_project_area_boundaries( updated = 0 created = 0 skipped = 0 + published = 0 with session_ctx() as session: for feature in features: @@ -124,9 +135,11 @@ def import_project_area_boundaries( name=location_name, group_type=group_type, project_area=project_area, + release_status=PUBLIC_RELEASE_STATUS, ) session.add(new_group) created += 1 + published += 1 matched += 1 continue @@ -146,6 +159,13 @@ def import_project_area_boundaries( else: skipped += 1 + # Publish on every match, not just on a geometry change: a + # group whose boundary is already current still has to reach + # the OGC layer, and earlier imports left these at "draft". + if group.release_status in PROMOTABLE_RELEASE_STATUSES: + group.release_status = PUBLIC_RELEASE_STATUS + published += 1 + session.commit() return ProjectAreaImportResult( @@ -154,5 +174,6 @@ def import_project_area_boundaries( updated=updated, created=created, skipped=skipped, + published=published, unmatched_locations=tuple(sorted(set(unmatched_locations))), ) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index f64a81306..7bbc634c0 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -137,8 +137,9 @@ def test_refresh_materialized_views_rejects_invalid_identifier(): def test_import_project_area_boundaries_updates_matching_groups(monkeypatch): class FakeGroup: - def __init__(self): + def __init__(self, release_status="draft"): self.project_area = None + self.release_status = release_status fake_group = FakeGroup() @@ -236,8 +237,98 @@ def __exit__(self, exc_type, exc, tb): assert "Created 1 group(s)." in result.output assert "Updated 1 group project area(s)." in result.output assert "Skipped 0 unchanged group(s)." in result.output + # The matched draft group plus the newly created one. + assert "Published 2 group(s) to the OGC layer." in result.output assert "Unmatched locations: Missing Group" in result.output assert fake_group.project_area is not None + assert fake_group.release_status == "public" + + +def test_import_project_area_boundaries_preserves_curated_release_status( + monkeypatch, +): + """A group someone deliberately made private stays private.""" + + class FakeGroup: + def __init__(self, release_status): + self.project_area = None + self.release_status = release_status + + private_group = FakeGroup("private") + + class FakeClient: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("cli.project_area_import.httpx.Client", FakeClient) + monkeypatch.setattr( + "cli.project_area_import._fetch_project_area_features", + lambda client, layer_url: [ + { + "properties": {"location": "Private Group"}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-106.9, 33.9], + [-106.7, 33.9], + [-106.7, 34.1], + [-106.9, 34.1], + [-106.9, 33.9], + ] + ], + }, + }, + ], + ) + + class FakeScalarResult: + def __init__(self, groups): + self._groups = groups + + def all(self): + return self._groups + + class FakeSession: + def __init__(self): + self.added = [] + + def scalars(self, stmt): + return FakeScalarResult([private_group]) + + def add(self, obj): + self.added.append(obj) + + def commit(self): + pass + + class FakeSessionCtx: + def __enter__(self): + self.session = FakeSession() + return self.session + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr( + "cli.project_area_import.session_ctx", + lambda: FakeSessionCtx(), + ) + + runner = CliRunner() + result = runner.invoke(cli, ["import-project-area-boundaries"]) + + assert result.exit_code == 0, result.output + assert "Published 0 group(s) to the OGC layer." in result.output + # The boundary still updates -- only the release status is left alone. + assert private_group.project_area is not None + assert private_group.release_status == "private" def test_initialize_lexicon_invokes_initializer(monkeypatch):