From 118e998cab66109d63d0664b70bad49ece1d5dbe Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Wed, 23 Sep 2026 14:56:42 -0600 Subject: [PATCH 1/5] feat(stitch-ogsi): add nor source key for Norway (Sodir) - add NORSource/NORSourceView to the OGFieldSource discriminated union - add nor to SOURCE_PRIORITY between alb and gem - export NORSource/NORSourceView from __all__ --- .../src/stitch/ogsi/model/__init__.py | 19 +++++++++++++++++-- .../src/stitch/ogsi/model/types.py | 3 ++- packages/stitch-ogsi/tests/test_og_field.py | 9 +++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py b/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py index 415220cc..80958072 100644 --- a/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py +++ b/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py @@ -16,6 +16,7 @@ GEMSrcKey, LLMSrcKey, LocationType, + NORSrcKey, OGSISrcKey, RMISrcKey, WMSrcKey, @@ -42,6 +43,8 @@ "ALBSourceView", "BCSource", "BCSourceView", + "NORSource", + "NORSourceView", "SourceRecord", "LocationType", "OGFieldName", @@ -59,6 +62,7 @@ CCR_SRC: Final[CCRSrcKey] = "ccr" ALB_SRC: Final[ALBSrcKey] = "alb" BC_SRC: Final[BCSrcKey] = "bc" +NOR_SRC: Final[NORSrcKey] = "nor" # Canonical source coalescing priority (highest first). Single source of truth # for the coalescer, the query-param default, and the DB seed. @@ -68,6 +72,7 @@ CCR_SRC, BC_SRC, ALB_SRC, + NOR_SRC, GEM_SRC, LLM_SRC, ) @@ -105,6 +110,14 @@ class BCSourceView(SourceView[int, BCSrcKey], OilGasFieldBase): source: BCSrcKey = BC_SRC +class NORSource(Source[int, NORSrcKey], OilGasFieldBase): + source: NORSrcKey = NOR_SRC + + +class NORSourceView(SourceView[int, NORSrcKey], OilGasFieldBase): + source: NORSrcKey = NOR_SRC + + class WoodMacSource(Source[int, WMSrcKey], OilGasFieldBase): source: WMSrcKey = WM_SRC @@ -136,7 +149,8 @@ class LLMSourceView(SourceView[int, LLMSrcKey], OilGasFieldBase): | LLMSource | CCRSource | ALBSource - | BCSource, + | BCSource + | NORSource, Field(discriminator="source"), ] @@ -147,7 +161,8 @@ class LLMSourceView(SourceView[int, LLMSrcKey], OilGasFieldBase): | LLMSourceView | CCRSourceView | ALBSourceView - | BCSourceView, + | BCSourceView + | NORSourceView, Field(discriminator="source"), ] diff --git a/packages/stitch-ogsi/src/stitch/ogsi/model/types.py b/packages/stitch-ogsi/src/stitch/ogsi/model/types.py index 4ef5200f..8031145c 100644 --- a/packages/stitch-ogsi/src/stitch/ogsi/model/types.py +++ b/packages/stitch-ogsi/src/stitch/ogsi/model/types.py @@ -7,9 +7,10 @@ CCRSrcKey = Literal["ccr"] BCSrcKey = Literal["bc"] ALBSrcKey = Literal["alb"] +NORSrcKey = Literal["nor"] -OGSISrcKey = Literal["gem", "wm", "rmi", "llm", "ccr", "alb", "bc"] +OGSISrcKey = Literal["gem", "wm", "rmi", "llm", "ccr", "alb", "bc", "nor"] LocationType = Literal["Onshore", "Offshore", "Unknown"] diff --git a/packages/stitch-ogsi/tests/test_og_field.py b/packages/stitch-ogsi/tests/test_og_field.py index 52f4b12b..9a75de6c 100644 --- a/packages/stitch-ogsi/tests/test_og_field.py +++ b/packages/stitch-ogsi/tests/test_og_field.py @@ -16,6 +16,7 @@ BCSource, CCRSource, GemSource, + NORSource, OGFieldDetailView, OGFieldResource, OGFieldSource, @@ -75,6 +76,14 @@ def test_alb_source_from_json(self): assert obj.source == "alb" assert obj.name == "Test Field" + def test_nor_source_from_json(self): + obj = _source_adapter.validate_json( + '{"source": "nor", "name": "Test Field", "country": "NOR", "source_record": {"observed_at": "2026-01-01T00:00:00Z", "producer": "test", "payload": {"kind": "fixture"}}}' + ) + assert isinstance(obj, NORSource) + assert obj.source == "nor" + assert obj.name == "Test Field" + def test_invalid_source_key_rejected(self): with pytest.raises(ValidationError): _source_adapter.validate_json( From 8b1e74c6c165fce81ead73fb9b5f8c347b67e4c7 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Wed, 23 Sep 2026 14:57:25 -0600 Subject: [PATCH 2/5] feat(stitch-auth): add source:read:nor permission - add SOURCE_READ_NOR constant and register it in SOURCE_READ_PERMISSIONS - export SOURCE_READ_NOR from the package __init__ --- packages/stitch-auth/src/stitch/auth/__init__.py | 2 ++ .../stitch-auth/src/stitch/auth/permissions.py | 2 ++ packages/stitch-auth/tests/test_permissions.py | 14 ++++++++++++++ 3 files changed, 18 insertions(+) diff --git a/packages/stitch-auth/src/stitch/auth/__init__.py b/packages/stitch-auth/src/stitch/auth/__init__.py index 099036d8..9aba71b9 100644 --- a/packages/stitch-auth/src/stitch/auth/__init__.py +++ b/packages/stitch-auth/src/stitch/auth/__init__.py @@ -20,6 +20,7 @@ SOURCE_READ_CCR, SOURCE_READ_GEM, SOURCE_READ_LLM, + SOURCE_READ_NOR, SOURCE_READ_PERMISSIONS, SOURCE_READ_PREFIX, SOURCE_READ_RMI, @@ -49,6 +50,7 @@ "SOURCE_READ_CCR", "SOURCE_READ_GEM", "SOURCE_READ_LLM", + "SOURCE_READ_NOR", "SOURCE_READ_PERMISSIONS", "SOURCE_READ_PREFIX", "SOURCE_READ_RMI", diff --git a/packages/stitch-auth/src/stitch/auth/permissions.py b/packages/stitch-auth/src/stitch/auth/permissions.py index c36afa3e..702595dd 100644 --- a/packages/stitch-auth/src/stitch/auth/permissions.py +++ b/packages/stitch-auth/src/stitch/auth/permissions.py @@ -23,6 +23,7 @@ SOURCE_READ_CCR: Permission = f"{SOURCE_READ_PREFIX}ccr" SOURCE_READ_BC: Permission = f"{SOURCE_READ_PREFIX}bc" SOURCE_READ_ALB: Permission = f"{SOURCE_READ_PREFIX}alb" +SOURCE_READ_NOR: Permission = f"{SOURCE_READ_PREFIX}nor" SOURCE_READ_PERMISSIONS: frozenset[Permission] = frozenset( { SOURCE_READ_RMI, @@ -32,6 +33,7 @@ SOURCE_READ_CCR, SOURCE_READ_BC, SOURCE_READ_ALB, + SOURCE_READ_NOR, } ) SOURCE_WRITE: Permission = "source:write" diff --git a/packages/stitch-auth/tests/test_permissions.py b/packages/stitch-auth/tests/test_permissions.py index 882e7c48..69dc81da 100644 --- a/packages/stitch-auth/tests/test_permissions.py +++ b/packages/stitch-auth/tests/test_permissions.py @@ -12,6 +12,7 @@ SOURCE_READ_BC, SOURCE_READ_CCR, SOURCE_READ_GEM, + SOURCE_READ_NOR, SOURCE_READ_PERMISSIONS, SOURCE_READ_RMI, SOURCE_WRITE, @@ -77,6 +78,19 @@ def test_source_read_sources_resolves_alb(): ) == frozenset({"alb"}) +def test_nor_source_read_permission_is_registered(): + assert SOURCE_READ_NOR == "source:read:nor" + assert SOURCE_READ_NOR in SOURCE_READ_PERMISSIONS + assert SOURCE_READ_NOR in ALL_PERMISSIONS + + +def test_source_read_sources_resolves_nor(): + assert source_read_sources( + [SOURCE_READ_NOR], + valid_sources={"nor"}, + ) == frozenset({"nor"}) + + def test_missing_permissions_uses_exact_matching(): granted = {RESOURCE_READ, SOURCE_READ_RMI} From 912818350cf750a72b49ed3c96bd3ccc8be2d7e9 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Wed, 23 Sep 2026 14:58:00 -0600 Subject: [PATCH 3/5] feat(api): seed nor source priority - migration renumbers priorities and inserts the nor row (rmi>wm>ccr>bc>alb>nor>gem>llm) - add a nor case to the make_source test factory - update the licensed_sources test fixture to include nor --- .../71aa7ef8150b_add_nor_source_priority.py | 61 +++++++++++++++++++ .../api/tests/routers/test_resources_unit.py | 2 +- deployments/api/tests/utils.py | 3 + 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 deployments/api/alembic/versions/71aa7ef8150b_add_nor_source_priority.py diff --git a/deployments/api/alembic/versions/71aa7ef8150b_add_nor_source_priority.py b/deployments/api/alembic/versions/71aa7ef8150b_add_nor_source_priority.py new file mode 100644 index 00000000..ff6487c6 --- /dev/null +++ b/deployments/api/alembic/versions/71aa7ef8150b_add_nor_source_priority.py @@ -0,0 +1,61 @@ +"""add nor source priority + +Revision ID: 71aa7ef8150b +Revises: 3a7e120d22d1 +Create Date: 2026-09-23 00:00:00.000000 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "71aa7ef8150b" +down_revision = "3a7e120d22d1" +branch_labels = None +depends_on = None + +# Frozen snapshot of ``stitch.ogsi.model.SOURCE_PRIORITY`` as of this revision. +# Migrations are historical records, so this is intentionally hardcoded rather +# than imported: importing the live constant would silently rewrite what this +# already-applied revision means whenever the canonical order changes. If +# SOURCE_PRIORITY changes, add a NEW revision -- do not edit this tuple. +_REV_SOURCE_PRIORITY = ("rmi", "wm", "ccr", "bc", "alb", "nor", "gem", "llm") + + +def upgrade() -> None: + # Bring og_field_source_priority to exactly _REV_SOURCE_PRIORITY (priority = + # 1-based index). Two other tables FK to this table's ``source`` column + # (og_field_resource_source_priority, og_field_memberships), so we cannot + # drop or delete rows -- we upsert in place: UPDATE the sources that already + # exist and INSERT the new one (nor). First offset every existing row past + # the target range so the per-source UPDATEs below can't transiently + # collide with the UNIQUE constraint on ``priority``. + bind = op.get_bind() + bind.execute( + sa.text("UPDATE og_field_source_priority SET priority = priority + 100") + ) + existing = { + row[0] + for row in bind.execute(sa.text("SELECT source FROM og_field_source_priority")) + } + for priority, source in enumerate(_REV_SOURCE_PRIORITY, start=1): + if source in existing: + bind.execute( + sa.text( + "UPDATE og_field_source_priority SET priority = :priority " + "WHERE source = :source" + ).bindparams(priority=priority, source=source) + ) + else: + bind.execute( + sa.text( + "INSERT INTO og_field_source_priority (source, priority) " + "VALUES (:source, :priority)" + ).bindparams(source=source, priority=priority) + ) + + +def downgrade() -> None: + raise RuntimeError(f"Irreversible migration: {revision}") diff --git a/deployments/api/tests/routers/test_resources_unit.py b/deployments/api/tests/routers/test_resources_unit.py index 408d5c04..069502e3 100644 --- a/deployments/api/tests/routers/test_resources_unit.py +++ b/deployments/api/tests/routers/test_resources_unit.py @@ -281,5 +281,5 @@ async def override_get_uow(): mock_repo.filter_options.assert_awaited_once() call_kwargs = mock_repo.filter_options.call_args.kwargs assert call_kwargs["licensed_sources"] == frozenset( - {"rmi", "gem", "wm", "ccr", "alb", "bc", "llm"} + {"rmi", "gem", "wm", "ccr", "alb", "bc", "nor", "llm"} ) diff --git a/deployments/api/tests/utils.py b/deployments/api/tests/utils.py index ce1f42b9..b3356825 100644 --- a/deployments/api/tests/utils.py +++ b/deployments/api/tests/utils.py @@ -17,6 +17,7 @@ CCRSource, GemSource, LLMSource, + NORSource, OGSISrcKey, RMISource, WoodMacSource, @@ -117,6 +118,8 @@ def make_source( return ALBSource(**kwargs) case "bc": return BCSource(**kwargs) + case "nor": + return NORSource(**kwargs) def make_resource( From d1db7a0741703ad44ddff9fbdaf877216c7a1786 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Wed, 23 Sep 2026 15:00:32 -0600 Subject: [PATCH 4/5] feat(frontend): add Norway ETL panel - add nor color and label to sourceMeta, mention it in FieldCard's source comment - add a Norway panel to the ETL page (empty fields, backend not built yet) - add/update EtlPage tests for the new panel and updated panel count --- .../src/components/FieldCard.jsx | 2 +- .../src/constants/sourceMeta.js | 15 ++++++- .../stitch-frontend/src/pages/EtlPage.jsx | 13 ++++++ .../src/pages/EtlPage.test.jsx | 42 ++++++++++++++++++- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/deployments/stitch-frontend/src/components/FieldCard.jsx b/deployments/stitch-frontend/src/components/FieldCard.jsx index 74df0d4a..4cf6db99 100644 --- a/deployments/stitch-frontend/src/components/FieldCard.jsx +++ b/deployments/stitch-frontend/src/components/FieldCard.jsx @@ -7,7 +7,7 @@ import { } from "../constants/sourceMeta"; // Used to display a single field value in a card, as seen in the ResourceDetailPage. -// Pass `source` (one of "gem" | "wm" | "ccr" | "bc" | "alb" | "rmi" | "llm") to tint the left border by data source. +// Pass `source` (one of "gem" | "wm" | "ccr" | "bc" | "alb" | "nor" | "rmi" | "llm") to tint the left border by data source. // Pass `expandable` + `isOpen` + `onToggle` to make the value a toggle button; `children` // (e.g. an "All sources" panel) render below the box while open. The card is presentational: // the parent owns open state and any data fetching. diff --git a/deployments/stitch-frontend/src/constants/sourceMeta.js b/deployments/stitch-frontend/src/constants/sourceMeta.js index 4b76a0fb..45c90f53 100644 --- a/deployments/stitch-frontend/src/constants/sourceMeta.js +++ b/deployments/stitch-frontend/src/constants/sourceMeta.js @@ -1,4 +1,4 @@ -export const SOURCES = ["rmi", "wm", "ccr", "bc", "alb", "gem", "llm"]; +export const SOURCES = ["rmi", "wm", "ccr", "bc", "alb", "nor", "gem", "llm"]; /** * Priority order for coalescing a value across sources, best first. Mirrors @@ -6,7 +6,16 @@ export const SOURCES = ["rmi", "wm", "ccr", "bc", "alb", "gem", "llm"]; * packages/stitch-ogsi/src/stitch/ogsi/model, which is the single source of * truth. Keep this list in sync with it until the constant is generated. */ -export const SOURCE_PRIORITY = ["rmi", "wm", "ccr", "bc", "alb", "gem", "llm"]; +export const SOURCE_PRIORITY = [ + "rmi", + "wm", + "ccr", + "bc", + "alb", + "nor", + "gem", + "llm", +]; export const SOURCE_COLORS = { gem: "#45cfcc", // energy teal @@ -14,6 +23,7 @@ export const SOURCE_COLORS = { ccr: "#f2994a", // amber alb: "#eb5757", // red bc: "#27ae60", // green + nor: "#c14ea3", // magenta rmi: "#ffcb00", // solar llm: "#529cba", // RMI blue }; @@ -25,6 +35,7 @@ export const SOURCE_LABELS = { ccr: "C&C Reservoirs", alb: "Alberta Energy Regulator", bc: "BC Energy Regulator", + nor: "Norwegian Offshore Directorate", rmi: "RMI", }; diff --git a/deployments/stitch-frontend/src/pages/EtlPage.jsx b/deployments/stitch-frontend/src/pages/EtlPage.jsx index 1d172ccb..12667d20 100644 --- a/deployments/stitch-frontend/src/pages/EtlPage.jsx +++ b/deployments/stitch-frontend/src/pages/EtlPage.jsx @@ -63,6 +63,12 @@ const BC_FIELDS = [ // match it. const ALB_FIELDS = []; +// No run parameters yet: the Norway (Sodir) dataset is still at the spike +// stage in stitch-etl, so the real request-body shape is unknown. Leave empty +// (no inputs, empty body) until the dataset lands and the fields can be +// defined to match it. +const NOR_FIELDS = []; + const WOODMAC_FIELDS = [ { key: "payload_limit", @@ -342,6 +348,13 @@ export default function EtlPage() { fields={ALB_FIELDS} getToken={getToken} /> + ); diff --git a/deployments/stitch-frontend/src/pages/EtlPage.test.jsx b/deployments/stitch-frontend/src/pages/EtlPage.test.jsx index df64c879..4353ce2a 100644 --- a/deployments/stitch-frontend/src/pages/EtlPage.test.jsx +++ b/deployments/stitch-frontend/src/pages/EtlPage.test.jsx @@ -32,12 +32,13 @@ describe("EtlPage", () => { expect( screen.getByRole("heading", { name: "Alberta" }), ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Norway" })).toBeInTheDocument(); expect(screen.getAllByRole("button", { name: "Start run" })).toHaveLength( - 5, + 6, ); expect( screen.getAllByRole("button", { name: "Refresh status" }), - ).toHaveLength(5); + ).toHaveLength(6); }); it("starts a GEM run with an authenticated token and shows the returned state", async () => { @@ -189,6 +190,43 @@ describe("EtlPage", () => { ); }); + it("starts a Norway run against the nor ETL endpoint", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 202, + text: async () => + JSON.stringify({ + job_id: "job-nor", + state: "running", + started_at: "2026-06-11T10:00:00Z", + initiated_by: "Test User", + }), + }); + + renderWithQueryClient(); + + const norPanel = getPanel("Norway"); + await userEvent.click( + within(norPanel).getByRole("button", { name: "Start run" }), + ); + + await waitFor(() => { + expect(within(norPanel).getAllByText("running").length).toBeGreaterThan( + 0, + ); + }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:8100/api/v1/etl/nor/start", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer test-access-token", + }), + }), + ); + }); + it("surfaces a friendly message when a run is already in progress (409)", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, From 2732065206deb3beafdf29647c1897774fd4c8a4 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Wed, 23 Sep 2026 15:15:05 -0600 Subject: [PATCH 5/5] chore: unnecessary comment removal --- .../versions/71aa7ef8150b_add_nor_source_priority.py | 12 ------------ deployments/stitch-frontend/src/pages/EtlPage.jsx | 10 +--------- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/deployments/api/alembic/versions/71aa7ef8150b_add_nor_source_priority.py b/deployments/api/alembic/versions/71aa7ef8150b_add_nor_source_priority.py index ff6487c6..e53aa612 100644 --- a/deployments/api/alembic/versions/71aa7ef8150b_add_nor_source_priority.py +++ b/deployments/api/alembic/versions/71aa7ef8150b_add_nor_source_priority.py @@ -16,22 +16,10 @@ branch_labels = None depends_on = None -# Frozen snapshot of ``stitch.ogsi.model.SOURCE_PRIORITY`` as of this revision. -# Migrations are historical records, so this is intentionally hardcoded rather -# than imported: importing the live constant would silently rewrite what this -# already-applied revision means whenever the canonical order changes. If -# SOURCE_PRIORITY changes, add a NEW revision -- do not edit this tuple. _REV_SOURCE_PRIORITY = ("rmi", "wm", "ccr", "bc", "alb", "nor", "gem", "llm") def upgrade() -> None: - # Bring og_field_source_priority to exactly _REV_SOURCE_PRIORITY (priority = - # 1-based index). Two other tables FK to this table's ``source`` column - # (og_field_resource_source_priority, og_field_memberships), so we cannot - # drop or delete rows -- we upsert in place: UPDATE the sources that already - # exist and INSERT the new one (nor). First offset every existing row past - # the target range so the per-source UPDATEs below can't transiently - # collide with the UNIQUE constraint on ``priority``. bind = op.get_bind() bind.execute( sa.text("UPDATE og_field_source_priority SET priority = priority + 100") diff --git a/deployments/stitch-frontend/src/pages/EtlPage.jsx b/deployments/stitch-frontend/src/pages/EtlPage.jsx index 12667d20..434df1cd 100644 --- a/deployments/stitch-frontend/src/pages/EtlPage.jsx +++ b/deployments/stitch-frontend/src/pages/EtlPage.jsx @@ -57,16 +57,8 @@ const BC_FIELDS = [ }, ]; -// No run parameters yet: the Alberta dataset hasn't been added to -// stitch-etl-poc, so the real request-body shape is unknown. Leave empty (no -// inputs, empty body) until the dataset lands and the fields can be defined to -// match it. +// No run parameters needed as of 2026-09-23 for ALB or NOR. const ALB_FIELDS = []; - -// No run parameters yet: the Norway (Sodir) dataset is still at the spike -// stage in stitch-etl, so the real request-body shape is unknown. Leave empty -// (no inputs, empty body) until the dataset lands and the fields can be -// defined to match it. const NOR_FIELDS = []; const WOODMAC_FIELDS = [