Skip to content
Merged
47 changes: 45 additions & 2 deletions deployments/api/src/stitch/api/db/og_field_resource_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,19 @@
attach_sources_to_resource,
get_or_create_sources,
)
from stitch.ogsi.model import OGFieldListItemView, OGFieldResource
from stitch.ogsi.model import (
OGFieldListItemView,
OGFieldResource,
OGFieldSourceValueView,
)
from stitch.ogsi.model.types import OGSISrcKey

from .model import (
MembershipModel,
MembershipStatus,
ResourceModel,
)
from .model.oil_gas_field_source_value import value_attr_for
from .model.oil_gas_field_source_value import ATTRIBUTE_NAMES, value_attr_for
from .queries import base_resource_query, construct_base_query_statement, add_ranking
from .utils import (
coalesce_resources,
Expand Down Expand Up @@ -127,6 +131,45 @@ async def get(
)


async def field_source_values(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need a separate endpoint? The OGFieldDetailView already has a full list of sources, sorted in priority order. Could we drop the extra API call & endpoint if we use that data?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We need a finer grained endpoint, since we'll need to move away from the idea that a resource only has a single set of priorities, and to the idea that each field has its own set of priorities (so the priorities for "Basin" and "Local name" may be differently ordered).

To that end, I think dropping the full list of sources from the OGFieldDetailView is probably a good call moving forward (or at least don't imply that it's an ordered set), and if we need source details, we can fetch those as needed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If we drop the full list of sources, then the Detail view becomes just the ListItemView and forces us to make calls for every field attribute the user wants to view. Any other service will need to do the same to get the full, equivalent data for a Field.

session: AsyncSession,
id: int,
field: str,
licensed_sources: Collection[OGSISrcKey] | None = None,
) -> list[OGFieldSourceValueView]:
"""Every source's value for one field of a resource, best-priority first.

Returns only sources that carry a value for ``field`` (empty/null omitted),
each with its effective per-resource priority. The first entry is the
coalesced winner. Licensing is applied. Priority is source-scoped today; the
contract is unchanged when it becomes resource/field-scoped.
"""
if field not in ATTRIBUTE_NAMES:
raise HTTPException(
status_code=422,
detail=f"field={field} is not a known resource field.",
)
if await session.get(ResourceModel, id) is None:
raise HTTPException(
status_code=HTTP_404_NOT_FOUND, detail=f"No Resource with id `{id}` found."
)

by_id = await ResourceModel.source_data_by_resource_id(
session, [id], licensed_sources
)
values = [
OGFieldSourceValueView(
source=src.source, id=src.id, value=value, priority=priority
)
for src, priority in by_id.get(id, [])
if src.id is not None
and (value := getattr(src, field)) is not None
and value != ""
]
values.sort(key=lambda v: (v.priority, v.id))
return values


async def create(
session: AsyncSession, user: CurrentUser, resource: OGFieldResource
) -> OGFieldResource:
Expand Down
22 changes: 22 additions & 0 deletions deployments/api/src/stitch/api/routers/oil_gas_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
OGFieldListItemView,
OGFieldResource,
OGFieldResourceView,
OGFieldSourceValueView,
OGFieldView,
)

Expand Down Expand Up @@ -284,6 +285,27 @@ async def get_resource_detail(
return resource_to_detail_view(resource=res)


@router.get(
"/{id}/fields/{field}/sources",
response_model=list[OGFieldSourceValueView],
dependencies=[Depends(require_permissions(RESOURCE_READ))],
)
async def get_field_source_values(
*,
uow: UnitOfWorkDep,
user: CurrentUser,
claims: Claims,
id: int,
field: str,
) -> list[OGFieldSourceValueView]:
return await resource_actions.field_source_values(
session=uow.session,
id=id,
field=field,
licensed_sources=licensed_sources(claims),
)


@router.post(
"/",
response_model=OGFieldResourceView,
Expand Down
109 changes: 109 additions & 0 deletions deployments/api/tests/db/test_resource_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,115 @@ async def test_override_is_scoped_to_its_resource(
assert (await resource_actions.get(session, untouched)).view.name == "GEM Name"


class TestFieldSourceValues:
"""Per-field source-value listing, best-priority first."""

async def _seed(self, session, user) -> int:
# gem(2) outranks wm(3) by default; llm has no state_province.
return await _create_resource_with_sources(
session,
user,
{"source": "gem", "name": "GEM Name", "country": "USA", "basin": "Alpha"},
{"source": "wm", "name": "WM Name", "country": "CAN", "basin": "Beta"},
{"source": "llm", "name": "LLM Name", "country": "GBR"},
)

@pytest.mark.anyio
async def test_lists_values_sorted_by_priority_with_winner_first(
self,
seeded_integration_session: AsyncSession,
test_user: User,
):
session = seeded_integration_session
rid = await self._seed(session, test_user)

rows = await resource_actions.field_source_values(session, rid, "name")

# gem(2) < wm(3) < llm(4): winner (gem) first, then in priority order.
assert [(r.source, r.value) for r in rows] == [
("gem", "GEM Name"),
("wm", "WM Name"),
("llm", "LLM Name"),
]
assert [r.priority for r in rows] == sorted(r.priority for r in rows)

@pytest.mark.anyio
async def test_override_reorders_field_values(
self,
seeded_integration_session: AsyncSession,
test_user: User,
):
session = seeded_integration_session
rid = await self._seed(session, test_user)
session.add(
OGFieldResourceSourcePriority(resource_id=rid, source="wm", priority=1)
)
await session.flush()

rows = await resource_actions.field_source_values(session, rid, "basin")

# wm promoted above gem; llm has no basin so it is omitted.
assert [(r.source, r.value) for r in rows] == [
("wm", "Beta"),
("gem", "Alpha"),
]

@pytest.mark.anyio
async def test_omits_sources_without_a_value(
self,
seeded_integration_session: AsyncSession,
test_user: User,
):
session = seeded_integration_session
rid = await self._seed(session, test_user)

rows = await resource_actions.field_source_values(
session, rid, "state_province"
)

assert rows == []

@pytest.mark.anyio
async def test_unlicensed_sources_are_excluded(
self,
seeded_integration_session: AsyncSession,
test_user: User,
):
session = seeded_integration_session
rid = await self._seed(session, test_user)

rows = await resource_actions.field_source_values(
session, rid, "name", licensed_sources=["gem"]
)

assert [r.source for r in rows] == ["gem"]

@pytest.mark.anyio
async def test_unknown_field_is_rejected(
self,
seeded_integration_session: AsyncSession,
test_user: User,
):
session = seeded_integration_session
rid = await self._seed(session, test_user)

with pytest.raises(HTTPException) as exc:
await resource_actions.field_source_values(session, rid, "not_a_field")
assert exc.value.status_code == 422

@pytest.mark.anyio
async def test_missing_resource_is_404(
self,
seeded_integration_session: AsyncSession,
test_user: User,
):
with pytest.raises(HTTPException) as exc:
await resource_actions.field_source_values(
seeded_integration_session, 9_999_999, "name"
)
assert exc.value.status_code == 404


class TestResourceDetailCoalescing:
"""Detail-path (``resource_actions.get``) coalescing behavior.

Expand Down
56 changes: 56 additions & 0 deletions deployments/seed/data/006-source-values-demo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
[
{
"id": 0,
"source_data": [
{
"source": "rmi",
"name": "Coalescing Demo Field",
"country": "USA",
"location_type": "Onshore",
"discovery_year": 1969
},
{
"source": "gem",
"name": "Coalescing Demo (GEM A)",
"country": "USA",
"basin": "Permian Basin",
"latitude": 31.9,
"longitude": -102.1,
"production_conventionality": "Unconventional",
"discovery_year": 1970,
"production_start_year": 1975
},
{
"source": "gem",
"name": "Coalescing Demo (GEM B)",
"country": "CAN",
"basin": "Williston Basin",
"production_conventionality": "Conventional",
"discovery_year": 1971
},
{
"source": "wm",
"name": "Coalescing Demo (WM)",
"country": "GBR",
"basin": "North Sea",
"state_province": "Texas",
"latitude": 31.85,
"longitude": -102.2,
"location_type": "Offshore",
"primary_hydrocarbon_group": "Light Oil",
"discovery_year": 1968,
"production_start_year": 1974,
"field_status": "Producing"
},
{
"source": "llm",
"name": "Coalescing Demo (LLM)",
"country": null,
"basin": "Ghawar (guess)",
"primary_hydrocarbon_group": "Medium Oil",
"field_status": "Non-Producing"
}
],
"constituents": []
}
]
82 changes: 63 additions & 19 deletions deployments/stitch-frontend/src/components/FieldCard.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useId } from "react";
import {
SOURCE_COLORS,
SOURCE_LABELS,
Expand All @@ -7,7 +8,19 @@ import {

// Used to display a single field value in a card, as seen in the ResourceDetailPage.
// Pass `source` (one of "gem" | "wm" | "rmi" | "llm") to tint the left border by data source.
export function FieldCard({ label, value, 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.
export function FieldCard({
label,
value,
source,
expandable = false,
isOpen = false,
onToggle,
children,
}) {
const panelId = useId();
const display =
value === null || value === undefined || value === ""
? null
Expand All @@ -18,30 +31,61 @@ export function FieldCard({ label, value, source }) {
? (SOURCE_LABELS[source] ?? UNKNOWN_SOURCE_LABEL)
: null;

const boxContent = (
<>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 break-words">
{display ?? <span className="text-ink-muted">—</span>}
</div>
{expandable && (
<span aria-hidden="true" className="shrink-0 text-xs text-ink-muted">
{isOpen ? "▾" : "▸"}
</span>
)}
</div>
{sourceLabel && (
<div className="mt-1 flex items-center gap-1.5 text-xs font-medium leading-4 text-ink-muted">
<span
className="h-2 w-2 shrink-0 rounded-sm ring-1 ring-ink/10"
style={{ backgroundColor: borderColor }}
aria-hidden="true"
/>
Source: {sourceLabel}
</div>
)}
</>
);

const boxClasses =
"min-h-[2.5rem] w-full rounded-md border border-line bg-panel px-3 py-2 text-left text-sm text-ink";

return (
<div className="min-w-0">
<p className="mb-1 text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
{label}
</p>
<div
className="min-h-[2.5rem] rounded-md border border-line bg-panel px-3 py-2 text-sm text-ink"
style={{ borderLeftColor: borderColor }}
title={sourceLabel ? `Source: ${sourceLabel}` : undefined}
>
<div className="break-words">
{display ?? <span className="text-ink-muted">—</span>}
{expandable ? (
<button
type="button"
aria-expanded={isOpen}
// Only reference the panel while it is mounted (rendered on open).
aria-controls={isOpen ? panelId : undefined}
onClick={onToggle}
className={`${boxClasses} transition-colors hover:bg-surface focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:ring-offset-1`}
style={{ borderLeftColor: borderColor }}
>
{boxContent}
</button>
) : (
<div
className={boxClasses}
style={{ borderLeftColor: borderColor }}
title={sourceLabel ? `Source: ${sourceLabel}` : undefined}
>
{boxContent}
</div>
{sourceLabel && (
<div className="mt-1 flex items-center gap-1.5 text-xs font-medium leading-4 text-ink-muted">
<span
className="h-2 w-2 shrink-0 rounded-sm ring-1 ring-ink/10"
style={{ backgroundColor: borderColor }}
aria-hidden="true"
/>
Source: {sourceLabel}
</div>
)}
</div>
)}
{expandable && isOpen && <div id={panelId}>{children}</div>}
Comment thread
AlexAxthelm marked this conversation as resolved.
</div>
);
}
Expand Down
Loading
Loading