Skip to content
Merged
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,49 @@
"""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

_REV_SOURCE_PRIORITY = ("rmi", "wm", "ccr", "bc", "alb", "nor", "gem", "llm")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

NB (this time): It would be a good idea to have a DB test (against the applied migrations) that the DB thinks the priority list is the same as the app code SOURCE_PRIORITY. Out of scope for this PR, but it's an easy spot to have drift.



def upgrade() -> None:
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}")
2 changes: 1 addition & 1 deletion deployments/api/tests/routers/test_resources_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
)
3 changes: 3 additions & 0 deletions deployments/api/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
CCRSource,
GemSource,
LLMSource,
NORSource,
OGSISrcKey,
RMISource,
WoodMacSource,
Expand Down Expand Up @@ -117,6 +118,8 @@ def make_source(
return ALBSource(**kwargs)
case "bc":
return BCSource(**kwargs)
case "nor":
return NORSource(**kwargs)


def make_resource(
Expand Down
2 changes: 1 addition & 1 deletion deployments/stitch-frontend/src/components/FieldCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 13 additions & 2 deletions deployments/stitch-frontend/src/constants/sourceMeta.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
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
* the backend's canonical SOURCE_PRIORITY in
* 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
wm: "#7b76ad", // RMI purple
ccr: "#f2994a", // amber
alb: "#eb5757", // red
bc: "#27ae60", // green
nor: "#c14ea3", // magenta
rmi: "#ffcb00", // solar
llm: "#529cba", // RMI blue
};
Expand All @@ -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",
};

Expand Down
13 changes: 9 additions & 4 deletions deployments/stitch-frontend/src/pages/EtlPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,9 @@ 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

NB: Even with no run params, we probably want to expose the same "use cached" toggle that we have for the others.

const ALB_FIELDS = [];
const NOR_FIELDS = [];

const WOODMAC_FIELDS = [
{
Expand Down Expand Up @@ -342,6 +340,13 @@ export default function EtlPage() {
fields={ALB_FIELDS}
getToken={getToken}
/>
<EtlPanel
title="Norway"
description="Fetch Norwegian Offshore Directorate (Sodir) field data and post it to Stitch."
baseUrl={`${config.etlBaseUrl}/nor`}
fields={NOR_FIELDS}
getToken={getToken}
/>
</div>
</div>
);
Expand Down
42 changes: 40 additions & 2 deletions deployments/stitch-frontend/src/pages/EtlPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(<EtlPage />);

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,
Expand Down
2 changes: 2 additions & 0 deletions packages/stitch-auth/src/stitch/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/stitch-auth/src/stitch/auth/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,6 +33,7 @@
SOURCE_READ_CCR,
SOURCE_READ_BC,
SOURCE_READ_ALB,
SOURCE_READ_NOR,
}
)
SOURCE_WRITE: Permission = "source:write"
Expand Down
14 changes: 14 additions & 0 deletions packages/stitch-auth/tests/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
SOURCE_READ_BC,
SOURCE_READ_CCR,
SOURCE_READ_GEM,
SOURCE_READ_NOR,
SOURCE_READ_PERMISSIONS,
SOURCE_READ_RMI,
SOURCE_WRITE,
Expand Down Expand Up @@ -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}

Expand Down
19 changes: 17 additions & 2 deletions packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
GEMSrcKey,
LLMSrcKey,
LocationType,
NORSrcKey,
OGSISrcKey,
RMISrcKey,
WMSrcKey,
Expand All @@ -42,6 +43,8 @@
"ALBSourceView",
"BCSource",
"BCSourceView",
"NORSource",
"NORSourceView",
"SourceRecord",
"LocationType",
"OGFieldName",
Expand All @@ -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.
Expand All @@ -68,6 +72,7 @@
CCR_SRC,
BC_SRC,
ALB_SRC,
NOR_SRC,
GEM_SRC,
LLM_SRC,
)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -136,7 +149,8 @@ class LLMSourceView(SourceView[int, LLMSrcKey], OilGasFieldBase):
| LLMSource
| CCRSource
| ALBSource
| BCSource,
| BCSource
| NORSource,
Field(discriminator="source"),
]

Expand All @@ -147,7 +161,8 @@ class LLMSourceView(SourceView[int, LLMSrcKey], OilGasFieldBase):
| LLMSourceView
| CCRSourceView
| ALBSourceView
| BCSourceView,
| BCSourceView
| NORSourceView,
Field(discriminator="source"),
]

Expand Down
3 changes: 2 additions & 1 deletion packages/stitch-ogsi/src/stitch/ogsi/model/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
9 changes: 9 additions & 0 deletions packages/stitch-ogsi/tests/test_og_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
BCSource,
CCRSource,
GemSource,
NORSource,
OGFieldDetailView,
OGFieldResource,
OGFieldSource,
Expand Down Expand Up @@ -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(
Expand Down
Loading