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() {
{error.message}
+Loading...
+{JSON.stringify(ogfields, null, 2)}
+ + No ogfields loaded. Click the button above to fetch ogfields. +
+