Skip to content
Closed
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
68 changes: 68 additions & 0 deletions alembic/versions/c8d9e0f1a2b3_publish_imported_project_areas.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
21 changes: 21 additions & 0 deletions cli/project_area_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -27,6 +36,7 @@ class ProjectAreaImportResult:
updated: int
created: int
skipped: int
published: int
unmatched_locations: tuple[str, ...]


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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(
Expand All @@ -154,5 +174,6 @@ def import_project_area_boundaries(
updated=updated,
created=created,
skipped=skipped,
published=published,
unmatched_locations=tuple(sorted(set(unmatched_locations))),
)
93 changes: 92 additions & 1 deletion tests/test_cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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):
Expand Down