feat: add repair parts tracking
This commit is contained in:
1 parent
6e550c1c13
commit
eab9646960
23 files changed
+2386
-4
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Minimal durable component registry for repair-part references."""
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user