feat: add repair parts tracking

This commit is contained in:
lussierj committed 2026-08-02 21:26:15 +00:00
1 parent 6e550c1c13
commit eab9646960
23 files changed
+2386 -4

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Minimal durable component registry for repair-part references."""
+8
View File
@@ -0,0 +1,8 @@
"""Dependency construction for the Components domain."""
from app.components.repository import ComponentRepository
from app.components.service import ComponentService
def get_component_service() -> ComponentService:
return ComponentService(ComponentRepository())
+32
View File
@@ -0,0 +1,32 @@
"""Domain exceptions for the minimal component registry."""
class ComponentError(Exception):
"""Base exception for component operations."""
class ComponentNotFound(ComponentError):
"""Raised when a component UUID does not exist."""
def __init__(self, component_id: object):
self.component_id = component_id
super().__init__(f"Component not found: {component_id}")
class DuplicateComponentAssetID(ComponentError):
"""Raised when a normalized component Asset ID already exists."""
def __init__(self, asset_id: str):
self.asset_id = asset_id
super().__init__(f"Component Asset ID already exists: {asset_id}")
class ComponentValidationError(ComponentError):
"""Raised when component metadata violates a domain rule."""
class ComponentActorRequired(ComponentError):
"""Raised when component creation lacks an authenticated actor."""
def __init__(self):
super().__init__("An authenticated actor is required to create a component")
+36
View File
@@ -0,0 +1,36 @@
"""Typed contracts for the minimal component registry."""
from typing import Literal
from uuid import UUID
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
ComponentType = Literal["control_board", "hashboard", "power_supply", "fan"]
class ComponentCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
component_type: ComponentType
asset_id: str | None = Field(default=None, max_length=255)
manufacturer: str | None = Field(default=None, max_length=255)
model: str | None = Field(default=None, max_length=255)
manufacturer_serial: str | None = Field(default=None, max_length=255)
class ComponentDetail(BaseModel):
component_id: UUID
component_type: ComponentType
asset_id: str | None = None
manufacturer: str | None = None
model: str | None = None
manufacturer_serial: str | None = None
created_at: AwareDatetime
created_by: UUID
class ComponentResponse(BaseModel):
data: ComponentDetail
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
+143
View File
@@ -0,0 +1,143 @@
"""PostgreSQL persistence for the minimal component registry."""
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from uuid import UUID
import psycopg
from psycopg.errors import UniqueViolation
from psycopg.rows import dict_row
from app.components.exceptions import DuplicateComponentAssetID
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
COMPONENT_ASSET_ID_CONSTRAINT = "uq_components_normalized_asset_id"
def default_connection_factory() -> psycopg.Connection:
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=5,
)
class ComponentRepository:
"""Persistence boundary for minimal component identity."""
def __init__(
self,
connection_factory: ConnectionFactory | None = None,
*,
connection: psycopg.Connection | None = None,
):
self._connection_factory = connection_factory or default_connection_factory
self._connection = connection
@contextmanager
def transaction(self) -> Iterator["ComponentRepository"]:
if self._connection is not None:
yield self
return
connection = self._connection_factory()
repository = ComponentRepository(
self._connection_factory,
connection=connection,
)
try:
yield repository
connection.commit()
except UniqueViolation as exc:
connection.rollback()
if exc.diag.constraint_name == COMPONENT_ASSET_ID_CONSTRAINT:
raise DuplicateComponentAssetID("normalized Asset ID") from exc
raise
except Exception:
connection.rollback()
raise
finally:
connection.close()
@contextmanager
def _connection_scope(self) -> Iterator[psycopg.Connection]:
if self._connection is not None:
yield self._connection
return
connection = self._connection_factory()
try:
yield connection
finally:
connection.close()
def create_component(
self,
*,
component_type: str,
asset_id: str | None,
manufacturer: str | None,
model: str | None,
manufacturer_serial: str | None,
created_by: UUID,
) -> dict:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO components (
component_type,
asset_id,
manufacturer,
model,
manufacturer_serial,
created_by
)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING
id AS component_id,
component_type,
asset_id,
manufacturer,
model,
manufacturer_serial,
created_at,
created_by
""",
(
component_type,
asset_id,
manufacturer,
model,
manufacturer_serial,
created_by,
),
)
return dict(cur.fetchone())
def get_component(self, component_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
id AS component_id,
component_type,
asset_id,
manufacturer,
model,
manufacturer_serial,
created_at,
created_by
FROM components
WHERE id = %s
""",
(component_id,),
)
row = cur.fetchone()
return dict(row) if row else None
+80
View File
@@ -0,0 +1,80 @@
"""HTTP mapping for the minimal component registry."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from app.components.dependencies import get_component_service
from app.components.exceptions import (
ComponentActorRequired,
ComponentNotFound,
ComponentValidationError,
DuplicateComponentAssetID,
)
from app.components.models import (
ComponentCreateRequest,
ComponentResponse,
)
from app.components.service import ComponentService
from app.identity import require_permission
router = APIRouter(prefix="/api/components", tags=["components"])
require_component_read = require_permission("components.read")
require_component_create = require_permission("components.create")
ComponentServiceDependency = Annotated[
ComponentService,
Depends(get_component_service),
]
ComponentReadUser = Annotated[dict, Depends(require_component_read)]
ComponentCreateUser = Annotated[dict, Depends(require_component_create)]
@router.post(
"",
response_model=ComponentResponse,
status_code=status.HTTP_201_CREATED,
)
def create_component(
request: ComponentCreateRequest,
service: ComponentServiceDependency,
current_user: ComponentCreateUser,
) -> ComponentResponse:
try:
component = service.create_component(
request,
actor_user_id=UUID(str(current_user["id"])),
)
except DuplicateComponentAssetID as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except (ComponentActorRequired, ComponentValidationError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return ComponentResponse(data=component)
@router.get(
"/{component_id}",
response_model=ComponentResponse,
)
def get_component(
component_id: UUID,
service: ComponentServiceDependency,
current_user: ComponentReadUser,
) -> ComponentResponse:
del current_user
try:
component = service.get_component(component_id)
except ComponentNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Component not found",
) from exc
return ComponentResponse(data=component)
+75
View File
@@ -0,0 +1,75 @@
"""Business rules for the minimal component registry."""
from uuid import UUID
from app.components.exceptions import (
ComponentActorRequired,
ComponentNotFound,
ComponentValidationError,
DuplicateComponentAssetID,
)
from app.components.models import ComponentCreateRequest, ComponentDetail
from app.components.repository import ComponentRepository
class ComponentService:
"""Minimal component workflows independent of FastAPI."""
def __init__(self, repository: ComponentRepository):
self.repository = repository
@staticmethod
def _normalize_optional_text(value: str | None, field_name: str) -> str | None:
if value is None:
return None
normalized = str(value).strip()
if not normalized:
raise ComponentValidationError(
f"{field_name} must not be blank when supplied"
)
if len(normalized) > 255:
raise ComponentValidationError(
f"{field_name} must not exceed 255 characters"
)
return normalized
@staticmethod
def _map_component(component: dict) -> ComponentDetail:
return ComponentDetail(**component)
def create_component(
self,
request: ComponentCreateRequest,
*,
actor_user_id: UUID | None,
) -> ComponentDetail:
if actor_user_id is None:
raise ComponentActorRequired()
asset_id = self._normalize_optional_text(request.asset_id, "asset_id")
values = {
"manufacturer": self._normalize_optional_text(
request.manufacturer, "manufacturer"
),
"model": self._normalize_optional_text(request.model, "model"),
"manufacturer_serial": self._normalize_optional_text(
request.manufacturer_serial, "manufacturer_serial"
),
}
try:
with self.repository.transaction() as repository:
component = repository.create_component(
component_type=request.component_type,
asset_id=asset_id,
created_by=actor_user_id,
**values,
)
except DuplicateComponentAssetID as exc:
raise DuplicateComponentAssetID(asset_id or "") from exc
return self._map_component(component)
def get_component(self, component_id: UUID) -> ComponentDetail:
component = self.repository.get_component(component_id)
if component is None:
raise ComponentNotFound(component_id)
return self._map_component(component)