Skip to content
Closed
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
3 changes: 3 additions & 0 deletions deployments/api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"sqlalchemy>=2.0.44",
"stitch-auth",
"stitch-core",
"deleteme_model_oilgas",
]

[project.scripts]
Expand Down Expand Up @@ -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 }

52 changes: 52 additions & 0 deletions deployments/api/src/stitch/api/db/init_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
StitchBase,
UserModel,
WMSourceModel,
OilGasFieldModel,
)
from stitch.api.entities import (
GemData,
Expand All @@ -28,6 +29,8 @@
WMData,
)

from deleteme_model_oilgas.field import OilGasFieldBase

"""
DB init/seed job.

Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions deployments/api/src/stitch/api/db/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
)
from .resource import MembershipStatus, MembershipModel, ResourceModel
from .user import User as UserModel
from .oilgas_field import OilGasFieldModel

__all__ = [
"CCReservoirsSourceModel",
Expand All @@ -18,4 +19,5 @@
"StitchBase",
"UserModel",
"WMSourceModel",
"OilGasFieldModel",
]
89 changes: 89 additions & 0 deletions deployments/api/src/stitch/api/db/model/oilgas_field.py
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions deployments/api/src/stitch/api/db/oilgas_field_actions.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 10 additions & 0 deletions deployments/api/src/stitch/api/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions deployments/api/src/stitch/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions deployments/api/src/stitch/api/routers/oilgasfields.py
Original file line number Diff line number Diff line change
@@ -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,
)
8 changes: 4 additions & 4 deletions deployments/stitch-frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -8,8 +8,8 @@ function App() {
<div className="max-w-4xl mx-auto flex justify-end mb-4">
<LogoutButton />
</div>
<ResourcesView endpoint="/api/v1/resources" />
<ResourceView className="mt-24" endpoint="/api/v1/resources/{id}" />
<OGFieldsView endpoint="/api/v1/oilgasfields" />
<OGFieldView className="mt-24" endpoint="/api/v1/oilgasfields/{id}" />
</div>
);
}
Expand Down
62 changes: 62 additions & 0 deletions deployments/stitch-frontend/src/components/OGFieldView.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`max-w-4xl mx-auto ${className}`}>
<h1 className="text-3xl font-bold mb-3 text-gray-800">
OGField ID: {id}
</h1>
<div className=" text-gray-500 pb-4">
<span className="font-bold">
{config.apiBaseUrl}
{endpoint}
</span>
</div>
<div className="mb-6 flex gap-3">
<Input
type="number"
value={id}
onChange={(e) => setId(Number(e.target.value))}
onKeyDown={handleKeyDown}
min={1}
max={1000}
className="w-24"
/>
<FetchButton onFetch={() => refetch()} isLoading={isLoading} />
<ClearCacheButton
onClear={() => handleClear(id)}
disabled={!data && !error}
/>
</div>
<JsonView
data={data}
isLoading={isLoading}
isError={isError}
error={error}
message={`No ogfield loaded. Click the button above to fetch a ogfield.`}
/>
</div>
);
}
Loading
Loading