Skip to content
Open
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
51 changes: 51 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
## Resource Filter Options Endpoint

### Summary
Add a read-only endpoint at `GET /oil-gas-fields/filter-options?field=country` that returns the distinct coalesced resource values visible to the current user for one requested field. The query must use the same resource-side coalescing and permission scoping as `GET /oil-gas-fields/`, but it will not apply the rest of the current list filters in this PR.

### Key Changes
- Add a new endpoint under the existing `oil_gas_fields` router:
- `GET /oil-gas-fields/filter-options`
- Required query param: `field`
- Define a small response model in the API entities layer.
- Default assumption: `{ "field": "country", "values": ["CAN", "USA"] }`
- Keep `values` sorted ascending and omit `null` / empty-string values.
- Add a dedicated field-param type for this endpoint rather than reusing `OGFieldQueryParams`.
- Validate `field` against a whitelist of supported resource filter-option fields.
- Default assumption: support the coalesced scalar filter fields used by the table filters, not `q`, not list/JSON fields (`owners`, `operators`), and not raw-source-only fields.
- Implement a new DB action on top of the existing coalesced resource CTE in `og_field_resource_actions.py`.
- Reuse `_build_licensed_resource_list_cte(...)` so results match resource visibility and source-priority coalescing.
- Do not apply `_build_final_conditions(...)` in this endpoint.
- Do still pass `licensed_sources(claims)` and the selected `source` set, so the result reflects the user-visible resource universe.
- Keep this endpoint resource-based, not source-based.
- Distinct values come from the coalesced resource columns after permission scoping, never from raw source rows.

### API / Behavior Details
- Request:
- `GET /oil-gas-fields/filter-options?field=country`
- Optional `source` query params may still be accepted if convenient, since source selection is part of resource visibility.
- Response:
- `field`: echoed requested field
- `values`: distinct visible values for that coalesced field
- Validation:
- Unknown or unsupported `field` returns `422`.
- Non-goal for this PR:
- No interaction with the rest of the active resource filters.
- This means the UI may later allow choosing a filter value that produces an empty result set; that is accepted for now.

### Test Plan
- Router unit tests:
- `GET /oil-gas-fields/filter-options?field=country` returns `200` and the expected response envelope.
- Invalid field such as `owners` returns `422`.
- Router passes claims-derived licensed sources into the DB action.
- DB integration tests:
- Distinct values are computed from coalesced resource data, not individual source rows.
- Licensed-source restrictions remove values that only exist in unlicensed sources.
- Repointed / inactive-membership resources are excluded consistently with the main resource list.
- `null` and empty-string values are excluded.
- Results are sorted and deduplicated.

### Assumptions
- The endpoint should ignore current non-source resource filters in this PR, per your direction.
- The response should be a simple object with `field` and `values`.
- Supported `field` values should be limited to resource-table filter fields, not every `OilGasFieldBase` attribute.
40 changes: 38 additions & 2 deletions deployments/api/src/stitch/api/db/og_field_resource_actions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from collections.abc import Collection, Sequence
from typing import Any
from typing import Any, get_args

from fastapi import HTTPException
from sqlalchemy import (
Expand All @@ -25,7 +25,11 @@
ResourceNotFoundError,
)
from stitch.api.auth import CurrentUser
from stitch.api.entities import OGFieldQueryParams
from stitch.api.entities import (
FilterOptionField,
OGFieldFilterOptionsParams,
OGFieldQueryParams,
)
from stitch.api.db.og_field_source_actions import (
attach_sources_to_resource,
get_or_create_sources,
Expand Down Expand Up @@ -53,6 +57,7 @@
)
_LIST_DATA_FIELDS = (*_LIST_SCALAR_FIELDS, *_LIST_JSON_FIELDS)
_PROVENANCE_SUFFIX = "__provenance_source"
_FILTER_OPTION_FIELDS: frozenset[str] = frozenset(get_args(FilterOptionField))


def _priority_values() -> tuple[int, ...]:
Expand Down Expand Up @@ -89,6 +94,37 @@ async def query(
return [_list_item_from_row(row) for row in rows], total


async def filter_options(
session: AsyncSession,
params: OGFieldFilterOptionsParams,
licensed_sources: Collection[OGSISrcKey] | None = None,
) -> list[str]:
"""Return distinct coalesced resource values for one filterable field."""
if params.field not in _FILTER_OPTION_FIELDS:
raise HTTPException(
status_code=422,
detail=f"field={params.field} is not supported for resource filter options.",
)

coalesced = _build_licensed_resource_list_cte(params, licensed_sources)
col = _resource_list_column(coalesced, params.field)
if col is None:
Comment on lines +107 to +111
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.

@mbarlow12 open to input on the best way to handle this. My robot is suggesting a Protocol, but that seems a bigger change than I want to slip into this PR.

raise HTTPException(
status_code=422,
detail=f"field={params.field} is not supported for resource filter options.",
)

value_col = cast(col, String).label("value")
stmt = (
select(value_col)
.where(col.is_not(None), cast(col, String) != "")
.distinct()
.order_by(value_col)
)
values = await session.scalars(stmt)
return list(values.all())


def _build_licensed_resource_list_cte(
params: OGFieldQueryParams,
licensed_sources: Collection[OGSISrcKey] | None,
Expand Down
23 changes: 23 additions & 0 deletions deployments/api/src/stitch/api/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,19 @@ def total_pages(self) -> int:
"resource_id",
]

FilterOptionField = Literal[
"name",
"name_local",
"basin",
"state_province",
"region",
"country",
"field_status",
"location_type",
"production_conventionality",
"primary_hydrocarbon_group",
]


class OGFieldFilterParams(BaseModel):
q: str | None = None
Expand All @@ -121,6 +134,16 @@ class OGFieldQueryParams(PaginationParams, OGFieldFilterParams, OGFieldSortParam
source: list[OGSISrcKey] = Field(default_factory=lambda: list(OGSI_SOURCE_DEFAULT))


class OGFieldFilterOptionsParams(BaseModel):
field: FilterOptionField
source: list[OGSISrcKey] = Field(default_factory=lambda: list(OGSI_SOURCE_DEFAULT))


class OGFieldFilterOptionsResponse(BaseModel):
field: FilterOptionField
values: list[str]


class MergeCandidateStatus(StrEnum):
PENDING = "PENDING"
APPROVED = "APPROVED"
Expand Down
18 changes: 18 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 @@ -4,6 +4,8 @@
from fastapi import APIRouter, HTTPException, Query

from stitch.api.entities import (
OGFieldFilterOptionsParams,
OGFieldFilterOptionsResponse,
MergeCandidateCreateRequest,
MergeCandidateReviewRequest,
MergeCandidateView,
Expand Down Expand Up @@ -66,6 +68,22 @@ async def get_all_resources(
)


@router.get("/filter-options", response_model=OGFieldFilterOptionsResponse)
async def get_resource_filter_options(
*,
uow: UnitOfWorkDep,
_user: CurrentUser,
claims: Claims,
params: Annotated[OGFieldFilterOptionsParams, Query()],
) -> OGFieldFilterOptionsResponse:
values = await resource_actions.filter_options(
session=uow.session,
params=params,
licensed_sources=licensed_sources(claims),
)
return OGFieldFilterOptionsResponse(field=params.field, values=values)


@router.get("/merge-candidates", response_model=list[MergeCandidateView])
async def list_merge_candidates(
*, uow: UnitOfWorkDep, _user: CurrentUser
Expand Down
143 changes: 143 additions & 0 deletions deployments/api/tests/db/test_resource_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import pytest
from fastapi import HTTPException
from sqlalchemy.dialects import postgresql
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from stitch.api.db import og_field_resource_actions as resource_actions
Expand All @@ -12,6 +14,7 @@
ResourceModel,
)
from stitch.api.entities import (
OGFieldFilterOptionsParams,
OGFieldQueryParams,
User,
)
Expand Down Expand Up @@ -517,6 +520,146 @@ async def test_unlicensed_source_falls_through_to_lower_priority_source(
assert items[0].data.name == "LLM Name"
assert items[0].provenance["name"] == "llm"


class TestResourceFilterOptionsAction:
"""Integration tests for resource_actions.filter_options()."""

@pytest.mark.anyio
async def test_returns_distinct_sorted_coalesced_values(
self,
seeded_integration_session: AsyncSession,
test_user: User,
):
await _create_resource_with_sources(
seeded_integration_session,
test_user,
{"source": "rmi", "country": None},
{"source": "gem", "country": "CAN"},
)
await _create_resource_with_sources(
seeded_integration_session,
test_user,
{"source": "rmi", "country": "USA"},
)
await _create_resource_with_sources(
seeded_integration_session,
test_user,
{"source": "rmi", "country": ""},
)
await _create_resource_with_sources(
seeded_integration_session,
test_user,
{"source": "rmi", "country": None},
)

values = await resource_actions.filter_options(
seeded_integration_session,
OGFieldFilterOptionsParams(field="country"),
)

assert values == ["CAN", "USA"]

@pytest.mark.anyio
async def test_honors_licensed_sources_after_coalescing(
self,
seeded_integration_session: AsyncSession,
test_user: User,
):
await _create_resource_with_sources(
seeded_integration_session,
test_user,
{"source": "rmi", "country": None},
{"source": "gem", "country": "CAN"},
)
await _create_resource_with_sources(
seeded_integration_session,
test_user,
{"source": "rmi", "country": "USA"},
)

values = await resource_actions.filter_options(
seeded_integration_session,
OGFieldFilterOptionsParams(field="country"),
licensed_sources=frozenset({"gem", "wm", "llm"}),
)

assert values == ["CAN"]

@pytest.mark.anyio
async def test_excludes_repointed_and_inactive_memberships(
self,
seeded_integration_session: AsyncSession,
test_user: User,
):
active_id = await _create_resource_with_sources(
seeded_integration_session,
test_user,
{"source": "rmi", "country": "USA"},
)
repointed_to_id = await _create_resource_with_sources(
seeded_integration_session,
test_user,
{"source": "rmi", "country": "BRA"},
)
inactive_id = await _create_resource_with_sources(
seeded_integration_session,
test_user,
{"source": "rmi", "country": "CAN"},
)

repointed_resource = await seeded_integration_session.get(
ResourceModel, repointed_to_id
)
assert repointed_resource is not None
repointed_resource.repointed_id = active_id

inactive_membership = await seeded_integration_session.scalar(
select(MembershipModel).where(MembershipModel.resource_id == inactive_id)
)
assert inactive_membership is not None
inactive_membership.status = MembershipStatus.INACTIVE
await seeded_integration_session.flush()

values = await resource_actions.filter_options(
seeded_integration_session,
OGFieldFilterOptionsParams(field="country"),
)

assert values == ["USA"]

def test_postgres_distinct_query_orders_by_selected_value_alias(self):
params = OGFieldFilterOptionsParams(field="basin")
coalesced = resource_actions._build_licensed_resource_list_cte(
params,
licensed_sources=frozenset({"gem", "wm", "rmi", "llm"}),
)
col = resource_actions._resource_list_column(coalesced, params.field)
assert col is not None

value_col = resource_actions.cast(col, resource_actions.String).label("value")
stmt = (
resource_actions.select(value_col)
.where(
col.is_not(None),
resource_actions.cast(col, resource_actions.String) != "",
)
.distinct()
.order_by(value_col)
)

sql = str(
stmt.compile(
dialect=postgresql.dialect(),
compile_kwargs={"literal_binds": True},
)
)

assert (
"SELECT DISTINCT CAST(licensed_resource_list.basin AS VARCHAR) AS value"
in sql
)
assert "ORDER BY value" in sql

@pytest.mark.anyio
async def test_only_unlicensed_selected_sources_still_return_resource(
self,
Expand Down
10 changes: 10 additions & 0 deletions deployments/api/tests/routers/test_query_param_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ async def test_sort_by_source_returns_422(self, integration_client: AsyncClient)
)
assert resp.status_code == 422

@pytest.mark.anyio
async def test_invalid_filter_options_field_returns_422(
self, async_client: AsyncClient
):
"""filter-options field must be one of the supported coalesced scalar fields."""
resp = await async_client.get(
"/oil-gas-fields/filter-options", params={"field": "owners"}
)
assert resp.status_code == 422


class TestSourceRouterParamValidation:
"""Verify FastAPI/Pydantic rejects invalid filter/sort params on GET /oil-gas-field-sources/."""
Expand Down
Loading
Loading