diff --git a/deployments/api/pyproject.toml b/deployments/api/pyproject.toml index 4eff8e4..abb07cc 100644 --- a/deployments/api/pyproject.toml +++ b/deployments/api/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "sqlalchemy>=2.0.44", "stitch-auth", "stitch-core", + "deleteme_model_oilgas", ] [project.scripts] @@ -42,3 +43,5 @@ addopts = ["-v", "--strict-markers", "--tb=short"] [tool.uv.sources] stitch-auth = { workspace = true } stitch-core = { workspace = true } +deleteme_model_oilgas = { workspace = true } + diff --git a/deployments/api/src/stitch/api/db/init_job.py b/deployments/api/src/stitch/api/db/init_job.py index 5a256c2..9b42d69 100644 --- a/deployments/api/src/stitch/api/db/init_job.py +++ b/deployments/api/src/stitch/api/db/init_job.py @@ -20,6 +20,7 @@ StitchBase, UserModel, WMSourceModel, + OilGasFieldModel, ) from stitch.api.entities import ( GemData, @@ -28,6 +29,8 @@ WMData, ) +from deleteme_model_oilgas.field import OilGasFieldBase + """ DB init/seed job. @@ -320,6 +323,52 @@ def create_seed_sources(): return gem_sources, wm_sources, rmi_sources, cc_sources +def create_og_fields(user: UserEntity) -> list[OilGasFieldModel]: + ogfields = [ + OilGasFieldModel.from_entity( + created_by=user, + field=OilGasFieldBase( + name="Foo OG field", + name_local="Föö", + latitude=1, + longitude=2, + production_start_year=1901, + ), + ), + OilGasFieldModel.from_entity( + created_by=user, + field=OilGasFieldBase( + name="Minimal OG field", + ), + ), + OilGasFieldModel.from_entity( + created_by=user, + field=OilGasFieldBase( + name="Bar OG field", + name_local="Bär", + country="USA", + basin="Super Cool Basin", + location_type="Offshore", + production_conventionality="Mixed", + fuel_group="Condensate", + operator="Very Good Operators (VGO, Inc.)", + discovery_year=1492, + production_start_year=1902, + fid_year=1903, + latitude=1.1, + longitude=2.2, + field_status="Online", + owners="Even Better Owners", + region="North America", + reservoir_formation="Cool Formation", + field_depth=9000.01, + subdivision="MI", + ), + ), + ] + return ogfields + + def create_seed_resources(user: UserEntity) -> list[ResourceModel]: resources = [ ResourceModel.create(user, name="Multi-Source Asset", country="USA"), @@ -386,6 +435,9 @@ def seed_dev(engine) -> None: gem_sources, wm_sources, rmi_sources, cc_sources = create_seed_sources() session.add_all(gem_sources + wm_sources + rmi_sources + cc_sources) + ogfields = create_og_fields(user_entity) + session.add_all(ogfields) + resources = create_seed_resources(user_entity) resources = create_seed_resources(dev_entity) session.add_all(resources) diff --git a/deployments/api/src/stitch/api/db/model/__init__.py b/deployments/api/src/stitch/api/db/model/__init__.py index d3f74ab..12c23bb 100644 --- a/deployments/api/src/stitch/api/db/model/__init__.py +++ b/deployments/api/src/stitch/api/db/model/__init__.py @@ -7,6 +7,7 @@ ) from .resource import MembershipStatus, MembershipModel, ResourceModel from .user import User as UserModel +from .oilgas_field import OilGasFieldModel __all__ = [ "CCReservoirsSourceModel", @@ -18,4 +19,5 @@ "StitchBase", "UserModel", "WMSourceModel", + "OilGasFieldModel", ] diff --git a/deployments/api/src/stitch/api/db/model/oilgas_field.py b/deployments/api/src/stitch/api/db/model/oilgas_field.py new file mode 100644 index 0000000..6a6c555 --- /dev/null +++ b/deployments/api/src/stitch/api/db/model/oilgas_field.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy import Float, Integer, String +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from .common import Base +from .mixins import TimestampMixin, UserAuditMixin + +# This is your shared package model (the "base shape") +from deleteme_model_oilgas.field import OilGasFieldBase + + +class OilGasFieldModel(Base, TimestampMixin, UserAuditMixin): + __tablename__ = "oil_gas_fields" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + # --- Core identifiers + name: Mapped[str] = mapped_column(String, nullable=False) + name_local: Mapped[str | None] = mapped_column(String, nullable=True) + + # --- Descriptors (mostly optional) + country: Mapped[str | None] = mapped_column(String, nullable=True) + basin: Mapped[str | None] = mapped_column(String, nullable=True) + location_type: Mapped[str | None] = mapped_column(String, nullable=True) + production_conventionality: Mapped[str | None] = mapped_column( + String, nullable=True + ) + fuel_group: Mapped[str | None] = mapped_column(String, nullable=True) + operator: Mapped[str | None] = mapped_column(String, nullable=True) + + # --- Dates / years (optional) + discovery_year: Mapped[int | None] = mapped_column(Integer, nullable=True) + production_start_year: Mapped[int | None] = mapped_column(Integer, nullable=True) + fid_year: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # --- Geo (optional) + latitude: Mapped[float | None] = mapped_column(Float, nullable=True) + longitude: Mapped[float | None] = mapped_column(Float, nullable=True) + + # --- Status / provenance-ish + field_status: Mapped[str | None] = mapped_column(String, nullable=True) + last_updated_source: Mapped[str | None] = mapped_column(String, nullable=True) + + # raw_lineage is `Optional[object]` in your package model :contentReference[oaicite:2]{index=2} + # Use JSONB in Postgres so you can store dict/list scalars easily. + raw_lineage: Mapped[Any | None] = mapped_column(JSONB, nullable=True) + + # --- Misc optional fields + owners: Mapped[str | None] = mapped_column(String, nullable=True) + region: Mapped[str | None] = mapped_column(String, nullable=True) + reservoir_formation: Mapped[str | None] = mapped_column(String, nullable=True) + field_depth: Mapped[float | None] = mapped_column(Float, nullable=True) + subdivision: Mapped[str | None] = mapped_column(String, nullable=True) + + @classmethod + def from_entity(cls, *, created_by, field: OilGasFieldBase) -> "OilGasFieldModel": + """ + Build an ORM model from the shared Pydantic model. + + Key behavior: + - exclude_unset=True: only includes keys the caller actually provided, so + missing optional fields won't cause constructor errors. + - nullable columns allow DB rows to persist without those fields. + """ + data = field.model_dump(exclude_unset=True) + + # Ensure required fields are present (name is required in your Pydantic model). + # If you later make name optional, you should enforce it here or in API schema. + model = cls(**data) + + # Your UserAuditMixin uses these columns (based on your earlier SQL). + model.created_by_id = created_by.id + model.last_updated_by_id = created_by.id + return model + + def apply_updates_from_entity(self, *, updated_by, field: OilGasFieldBase) -> None: + """ + Optional helper for PATCH/PUT later. + Only updates fields explicitly provided (exclude_unset=True). + """ + data = field.model_dump(exclude_unset=True) + for k, v in data.items(): + setattr(self, k, v) + + self.last_updated_by_id = updated_by.id diff --git a/deployments/api/src/stitch/api/db/oilgas_field_actions.py b/deployments/api/src/stitch/api/db/oilgas_field_actions.py new file mode 100644 index 0000000..0646f2f --- /dev/null +++ b/deployments/api/src/stitch/api/db/oilgas_field_actions.py @@ -0,0 +1,47 @@ +from collections.abc import Sequence + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.status import HTTP_404_NOT_FOUND + +from stitch.api.auth import CurrentUser +from stitch.api.entities import CreateOilGasField, OilGasField +from stitch.api.db.model.oilgas_field import OilGasFieldModel + + +async def get_all(*, session: AsyncSession) -> Sequence[OilGasField]: + stmt = select(OilGasFieldModel) + models = (await session.scalars(stmt)).all() + # from_attributes=True on OilGasField makes this work cleanly + return [OilGasField.model_validate(m) for m in models] + + +async def get(*, session: AsyncSession, id: int) -> OilGasField: + model = await session.get(OilGasFieldModel, id) + if model is None: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"No OilGasField with id `{id}` found.", + ) + return OilGasField.model_validate(model) + + +async def create( + *, + session: AsyncSession, + user: CurrentUser, + oilgas_field: CreateOilGasField, +) -> OilGasField: + model = OilGasFieldModel.create( + created_by=user, + name=oilgas_field.name, + name_local=oilgas_field.name_local, + production_start_year=oilgas_field.production_start_year, + latitude=oilgas_field.latitude, + longitude=oilgas_field.longitude, + ) + session.add(model) + await session.flush() + await session.refresh(model) + return OilGasField.model_validate(model) diff --git a/deployments/api/src/stitch/api/entities.py b/deployments/api/src/stitch/api/entities.py index 337a5ee..bbc70f0 100644 --- a/deployments/api/src/stitch/api/entities.py +++ b/deployments/api/src/stitch/api/entities.py @@ -11,6 +11,7 @@ ) from uuid import UUID from pydantic import BaseModel, ConfigDict, EmailStr, Field +from deleteme_model_oilgas import OilGasFieldBase IdType = int | str | UUID @@ -171,3 +172,12 @@ class User(BaseModel): class SourceSelectionLogic(BaseModel): ... + + +class OilGasField(OilGasFieldBase, Timestamped): + id: int + model_config = ConfigDict(from_attributes=True) + + +class CreateOilGasField(OilGasFieldBase): + pass diff --git a/deployments/api/src/stitch/api/main.py b/deployments/api/src/stitch/api/main.py index 0cbf561..3852cd6 100644 --- a/deployments/api/src/stitch/api/main.py +++ b/deployments/api/src/stitch/api/main.py @@ -10,10 +10,12 @@ from .routers.resources import router as resource_router from .routers.health import router as health_router +from stitch.api.routers.oilgasfields import router as oilgas_fields_router base_router = APIRouter(prefix="/api/v1") base_router.include_router(resource_router) base_router.include_router(health_router) +base_router.include_router(oilgas_fields_router) @asynccontextmanager diff --git a/deployments/api/src/stitch/api/routers/oilgasfields.py b/deployments/api/src/stitch/api/routers/oilgasfields.py new file mode 100644 index 0000000..a24be6f --- /dev/null +++ b/deployments/api/src/stitch/api/routers/oilgasfields.py @@ -0,0 +1,37 @@ +from collections.abc import Sequence +from fastapi import APIRouter + +from stitch.api.auth import CurrentUser +from stitch.api.db.config import UnitOfWorkDep +from stitch.api.entities import CreateOilGasField, OilGasField +from stitch.api.db import oilgas_field_actions + +router = APIRouter( + prefix="/oilgasfields", + responses={404: {"description": "Not found"}}, +) + + +@router.get("/") +async def get_all_oilgas_fields( + *, uow: UnitOfWorkDep, user: CurrentUser +) -> Sequence[OilGasField]: + return await oilgas_field_actions.get_all(session=uow.session) + + +@router.get("/{id}", response_model=OilGasField) +async def get_oilgas_field( + *, uow: UnitOfWorkDep, user: CurrentUser, id: int +) -> OilGasField: + return await oilgas_field_actions.get(session=uow.session, id=id) + + +@router.post("/", response_model=OilGasField) +async def create_oilgas_field( + *, uow: UnitOfWorkDep, user: CurrentUser, oilgas_field_in: CreateOilGasField +) -> OilGasField: + return await oilgas_field_actions.create( + session=uow.session, + user=user, + oilgas_field=oilgas_field_in, + ) diff --git a/deployments/stitch-frontend/src/App.jsx b/deployments/stitch-frontend/src/App.jsx index 0568759..c3361a5 100644 --- a/deployments/stitch-frontend/src/App.jsx +++ b/deployments/stitch-frontend/src/App.jsx @@ -1,5 +1,5 @@ -import ResourcesView from "./components/ResourcesView"; -import ResourceView from "./components/ResourceView"; +import OGFieldsView from "./components/OGFieldsView"; +import OGFieldView from "./components/OGFieldView"; import { LogoutButton } from "./components/LogoutButton"; function App() { @@ -8,8 +8,8 @@ function App() {
- - + + ); } diff --git a/deployments/stitch-frontend/src/components/OGFieldView.jsx b/deployments/stitch-frontend/src/components/OGFieldView.jsx new file mode 100644 index 0000000..a722cf2 --- /dev/null +++ b/deployments/stitch-frontend/src/components/OGFieldView.jsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useOGField } from "../hooks/useOGFields"; +import FetchButton from "./FetchButton"; +import ClearCacheButton from "./ClearCacheButton"; +import JsonView from "./JsonView"; +import Input from "./Input"; +import { ogfieldKeys } from "../queries/ogfields"; +import config from "../config/env"; + +export default function OGFieldView({ className, endpoint }) { + const queryClient = useQueryClient(); + const [id, setId] = useState(1); + const { data, isLoading, isError, error, refetch } = useOGField(id); + + const handleClear = (id) => { + queryClient.resetQueries({ queryKey: ogfieldKeys.detail(id) }); + }; + + const handleKeyDown = (e) => { + if (e.key === "Enter") { + refetch(); + } + }; + + return ( +
+

+ OGField ID: {id} +

+
+ + {config.apiBaseUrl} + {endpoint} + +
+
+ setId(Number(e.target.value))} + onKeyDown={handleKeyDown} + min={1} + max={1000} + className="w-24" + /> + refetch()} isLoading={isLoading} /> + handleClear(id)} + disabled={!data && !error} + /> +
+ +
+ ); +} diff --git a/deployments/stitch-frontend/src/components/OGFieldsList.jsx b/deployments/stitch-frontend/src/components/OGFieldsList.jsx new file mode 100644 index 0000000..00e2836 --- /dev/null +++ b/deployments/stitch-frontend/src/components/OGFieldsList.jsx @@ -0,0 +1,50 @@ +import Card from "./Card"; + +function OGFieldsList({ ogfields, isLoading, isError, error }) { + if (isError) { + return ( + +

{error.message}

+
+ ); + } + + if (isLoading) { + return ( + +

Loading...

+
+ ); + } + + if (ogfields?.length > 0) { + return ( + +
    + {ogfields.map((ogfield, index) => ( + + {ogfield.id} + {index < ogfields.length - 1 ? ", " : ""} + + ))} +
+
+
{JSON.stringify(ogfields, null, 2)}
+
+ ); + } + + if (!isLoading && !ogfields?.length) { + return ( + +

+ No ogfields loaded. Click the button above to fetch ogfields. +

+
+ ); + } + + return null; +} + +export default OGFieldsList; diff --git a/deployments/stitch-frontend/src/components/OGFieldsView.jsx b/deployments/stitch-frontend/src/components/OGFieldsView.jsx new file mode 100644 index 0000000..6df6afc --- /dev/null +++ b/deployments/stitch-frontend/src/components/OGFieldsView.jsx @@ -0,0 +1,37 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useOGFields } from "../hooks/useOGFields"; +import FetchButton from "./FetchButton"; +import ClearCacheButton from "./ClearCacheButton"; +import OGFieldsList from "./OGFieldsList"; +import { ogfieldKeys } from "../queries/ogfields"; + +export default function OGFieldsView({ className, endpoint }) { + const queryClient = useQueryClient(); + const { data, isLoading, isError, error, refetch } = useOGFields(); + + const handleClear = () => { + queryClient.setQueryData(ogfieldKeys.lists(), []); + }; + + return ( +
+

OGFields

+
+ {endpoint} +
+
+ refetch()} isLoading={isLoading} /> + +
+ +
+ ); +} diff --git a/deployments/stitch-frontend/src/hooks/useOGFields.js b/deployments/stitch-frontend/src/hooks/useOGFields.js new file mode 100644 index 0000000..2390e45 --- /dev/null +++ b/deployments/stitch-frontend/src/hooks/useOGFields.js @@ -0,0 +1,10 @@ +import { useAuthenticatedQuery } from "./useAuthenticatedQuery"; +import { ogfieldQueries } from "../queries/ogfields"; + +export function useOGFields() { + return useAuthenticatedQuery(ogfieldQueries.list()); +} + +export function useOGField(id) { + return useAuthenticatedQuery(ogfieldQueries.detail(id)); +} diff --git a/deployments/stitch-frontend/src/queries/api.js b/deployments/stitch-frontend/src/queries/api.js index 91f54d9..060ae51 100644 --- a/deployments/stitch-frontend/src/queries/api.js +++ b/deployments/stitch-frontend/src/queries/api.js @@ -21,3 +21,25 @@ export async function getResource(id, fetcher) { const data = await response.json(); return data; } + +export async function getOGFields(fetcher) { + const url = `${config.apiBaseUrl}/oilgasfields/`; + const response = await fetcher(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + return data; +} + +export async function getOGField(id, fetcher) { + const url = `${config.apiBaseUrl}/oilgasfields/${id}`; + const response = await fetcher(url); + if (!response.ok) { + const error = new Error(`HTTP error! status: ${response.status}`); + error.status = response.status; + throw error; + } + const data = await response.json(); + return data; +} diff --git a/deployments/stitch-frontend/src/queries/ogfields.js b/deployments/stitch-frontend/src/queries/ogfields.js new file mode 100644 index 0000000..7574143 --- /dev/null +++ b/deployments/stitch-frontend/src/queries/ogfields.js @@ -0,0 +1,25 @@ +import { getOGField, getOGFields } from "./api"; + +// Query key factory - hierarchical for easy invalidation +export const ogfieldKeys = { + all: ["ogfields"], + lists: () => [...ogfieldKeys.all, "list"], + list: (filters) => [...ogfieldKeys.lists(), filters], + details: () => [...ogfieldKeys.all, "detail"], + detail: (id) => [...ogfieldKeys.details(), id], +}; + +// Query definitions +export const ogfieldQueries = { + list: () => ({ + queryKey: ogfieldKeys.lists(), + queryFn: (fetcher) => getOGFields(fetcher), + enabled: false, + }), + + detail: (id) => ({ + queryKey: ogfieldKeys.detail(id), + queryFn: (fetcher) => getOGField(id, fetcher), + enabled: false, + }), +}; diff --git a/packages/deleteme-model-oilgas/pyproject.toml b/packages/deleteme-model-oilgas/pyproject.toml new file mode 100644 index 0000000..0be49d3 --- /dev/null +++ b/packages/deleteme-model-oilgas/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "deleteme-model-oilgas" +version = "0.1.0" +description = "Temporary placeholder oil & gas data models" +requires-python = ">=3.12" +dependencies = [ + "pydantic>=2.6", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/deleteme_model_oilgas"] diff --git a/packages/deleteme-model-oilgas/src/deleteme_model_oilgas/__init__.py b/packages/deleteme-model-oilgas/src/deleteme_model_oilgas/__init__.py new file mode 100644 index 0000000..ba8aea8 --- /dev/null +++ b/packages/deleteme-model-oilgas/src/deleteme_model_oilgas/__init__.py @@ -0,0 +1,3 @@ +from .field import OilGasFieldBase + +__all__ = ["OilGasFieldBase"] diff --git a/packages/deleteme-model-oilgas/src/deleteme_model_oilgas/field.py b/packages/deleteme-model-oilgas/src/deleteme_model_oilgas/field.py new file mode 100644 index 0000000..6ffeaef --- /dev/null +++ b/packages/deleteme-model-oilgas/src/deleteme_model_oilgas/field.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel, Field, ConfigDict +from typing import Optional + + +class OilGasFieldBase(BaseModel): + """ + Minimal Oil / Gas field domain model. + """ + + model_config = ConfigDict( + extra="forbid", # reject unknown fields + frozen=False, # allow mutation (change to True if you want immutability) + ) + name: str = Field(..., min_length=1) + name_local: Optional[str] = Field(default=None) + country: Optional[str] = Field(default=None) + basin: Optional[str] = Field(default=None) + location_type: Optional[str] = Field(default=None) + production_conventionality: Optional[str] = Field(default=None) + fuel_group: Optional[str] = Field(default=None) + operator: Optional[str] = Field(default=None) + discovery_year: Optional[int] = Field(default=None) + production_start_year: Optional[int] = Field(default=None) + fid_year: Optional[int] = Field(default=None) + latitude: Optional[float] = Field(default=None, ge=-90, le=90) + longitude: Optional[float] = Field(default=None, ge=-180, le=180) + field_status: Optional[str] = Field(default=None) + last_updated_source: Optional[str] = Field(default=None) + raw_lineage: Optional[object] = Field(default=None) + owners: Optional[str] = Field(default=None) + region: Optional[str] = Field(default=None) + reservoir_formation: Optional[str] = Field(default=None) + field_depth: Optional[float] = Field(default=None) + subdivision: Optional[str] = Field(default=None) diff --git a/packages/deleteme-model-oilgas/src/deleteme_model_oilgas/py.typed b/packages/deleteme-model-oilgas/src/deleteme_model_oilgas/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 87d0322..5af1a14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,12 @@ requires-python = ">=3.12" dependencies = ["stitch-core"] [tool.uv.workspace] -members = ["deployments/api", "packages/stitch-core", "packages/stitch-auth"] +members = [ + "deployments/api", + "packages/deleteme-model-oilgas", + "packages/stitch-auth", + "packages/stitch-core", +] [tool.uv.sources] stitch-core = { workspace = true } diff --git a/uv.lock b/uv.lock index ed186f8..47dc76c 100644 --- a/uv.lock +++ b/uv.lock @@ -4,6 +4,7 @@ requires-python = ">=3.12.12" [manifest] members = [ + "deleteme-model-oilgas", "stitch", "stitch-api", "stitch-auth", @@ -190,6 +191,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, ] +[[package]] +name = "deleteme-model-oilgas" +version = "0.1.0" +source = { editable = "packages/deleteme-model-oilgas" } +dependencies = [ + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [{ name = "pydantic", specifier = ">=2.6" }] + [[package]] name = "dnspython" version = "2.8.0" @@ -1103,6 +1115,7 @@ name = "stitch-api" version = "0.1.0" source = { editable = "deployments/api" } dependencies = [ + { name = "deleteme-model-oilgas" }, { name = "fastapi", extra = ["standard"] }, { name = "greenlet" }, { name = "pydantic-settings" }, @@ -1121,6 +1134,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "deleteme-model-oilgas", editable = "packages/deleteme-model-oilgas" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.124.0" }, { name = "greenlet", specifier = ">=3.3.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" },