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

@@ -0,0 +1,590 @@
"""add repair parts tracking
Revision ID: 0014_repair_parts_tracking
Revises: 0013_control_board_replacement
Create Date: 2026-08-02
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0014_repair_parts_tracking"
down_revision: Union[str, None] = "0013_control_board_replacement"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
COMPONENT_TYPES = ("control_board", "hashboard", "power_supply", "fan")
CONSUMABLE_UNITS = ("each", "gram", "millilitre", "metre", "centimetre")
REPAIR_REASONS = (
"HARDWARE_FAILURE", "LOW_HASHRATE", "CONTROL_BOARD", "HASHBOARD",
"PSU", "FAN", "OVERHEATING", "NETWORKING",
"PREVENTIVE_MAINTENANCE", "INSPECTION", "OTHER",
)
REPLACEMENT_REASONS = (
"failed", "upgrade", "diagnostic_swap", "preventive_maintenance",
"temporary_donor", "unknown",
)
DISPOSITIONS = (
"repair_inventory", "sent_for_repair", "scrapped",
"retained_for_diagnosis", "returned_to_vendor", "unknown",
)
def _quoted(values: Sequence[str]) -> str:
return ", ".join(f"'{value}'" for value in values)
def _create_event_constraints(include_parts: bool) -> None:
event_types = [
"REPAIR_STARTED",
"INITIAL_NOTE",
"REPAIR_COMPLETED",
"CONTROL_BOARD_REPLACED",
]
if include_parts:
event_types.extend(("REPAIR_PART_RECORDED", "REPAIR_PART_VOIDED"))
op.create_check_constraint(
"ck_repair_events_type",
"repair_events",
f"event_type IN ({_quoted(event_types)})",
)
parts_clause = ""
if include_parts:
parts_clause = (
") OR (event_type = 'REPAIR_PART_RECORDED' "
"AND payload ?& ARRAY['repair_part_id', 'entry_kind', 'notes'] "
"AND payload ->> 'repair_part_id' ~ "
"'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-"
"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' "
"AND (jsonb_typeof(payload -> 'notes') = 'null' OR ("
"jsonb_typeof(payload -> 'notes') = 'string' "
"AND trim(payload ->> 'notes') <> '' "
"AND char_length(payload ->> 'notes') <= 4000)) "
"AND ((payload ->> 'entry_kind' = 'consumable' "
"AND payload ?& ARRAY['description', 'quantity', 'unit'] "
"AND NOT payload ? 'component_id' "
"AND jsonb_typeof(payload -> 'description') = 'string' "
"AND trim(payload ->> 'description') <> '' "
"AND char_length(payload ->> 'description') <= 255 "
"AND jsonb_typeof(payload -> 'quantity') = 'string' "
"AND payload ->> 'quantity' ~ '^[0-9]{1,9}(\\.[0-9]{1,3})?$' "
"AND (payload ->> 'quantity')::numeric > 0 "
f"AND payload ->> 'unit' IN ({_quoted(CONSUMABLE_UNITS)}) "
"AND (payload ->> 'unit' <> 'each' OR "
"(payload ->> 'quantity')::numeric = "
"trunc((payload ->> 'quantity')::numeric))) OR ("
"payload ->> 'entry_kind' = 'tracked_component' "
"AND payload ?& ARRAY['component_id', 'component_type', "
"'component_asset_id', 'component_manufacturer', "
"'component_model', 'component_manufacturer_serial'] "
"AND NOT payload ? 'description' "
"AND NOT payload ? 'quantity' "
"AND NOT payload ? 'unit' "
"AND payload ->> 'component_id' ~ "
"'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-"
"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' "
f"AND payload ->> 'component_type' IN ({_quoted(COMPONENT_TYPES)}) "
"AND jsonb_typeof(payload -> 'component_asset_id') IN ('string', 'null') "
"AND jsonb_typeof(payload -> 'component_manufacturer') IN ('string', 'null') "
"AND jsonb_typeof(payload -> 'component_model') IN ('string', 'null') "
"AND jsonb_typeof(payload -> 'component_manufacturer_serial') "
"IN ('string', 'null'))))"
" OR (event_type = 'REPAIR_PART_VOIDED' "
"AND payload ?& ARRAY['repair_part_id', 'reason'] "
"AND payload ->> 'repair_part_id' ~ "
"'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-"
"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' "
"AND jsonb_typeof(payload -> 'reason') = 'string' "
"AND trim(payload ->> 'reason') <> '' "
"AND char_length(payload ->> 'reason') <= 1000"
)
op.create_check_constraint(
"ck_repair_events_payload_shape",
"repair_events",
"(event_type = 'REPAIR_STARTED' "
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
"AND payload ->> 'to_lifecycle' = 'in_repair') OR ("
"event_type = 'INITIAL_NOTE' "
"AND jsonb_typeof(payload -> 'note') = 'string' "
"AND trim(payload ->> 'note') <> '') OR ("
"event_type = 'REPAIR_COMPLETED' "
"AND payload ?& ARRAY['previous_miner_lifecycle', 'resulting_miner_lifecycle'] "
"AND payload ->> 'previous_miner_lifecycle' = 'in_repair' "
"AND payload ->> 'resulting_miner_lifecycle' IN ('active', 'spare') "
"AND (NOT payload ? 'pre_repair_lifecycle' "
"OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) "
"AND (NOT payload ? 'completion_summary' OR ("
"jsonb_typeof(payload -> 'completion_summary') = 'string' "
"AND trim(payload ->> 'completion_summary') <> '' "
"AND char_length(payload ->> 'completion_summary') <= 4000))) OR ("
"event_type = 'CONTROL_BOARD_REPLACED' "
"AND payload ?& ARRAY['old_mac_address', 'new_mac_address', "
"'old_control_board_serial', 'new_control_board_serial', "
"'replacement_reason', 'removed_board_disposition'] "
"AND jsonb_typeof(payload -> 'old_mac_address') IN ('string', 'null') "
"AND jsonb_typeof(payload -> 'new_mac_address') = 'string' "
"AND payload ->> 'new_mac_address' ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' "
"AND regexp_replace(lower(payload ->> 'new_mac_address'), '[^0-9a-f]', '', 'g') "
"NOT IN ('000000000000', 'ffffffffffff') "
"AND get_byte(decode(regexp_replace(lower(payload ->> 'new_mac_address'), "
"'[^0-9a-f]', '', 'g'), 'hex'), 0) % 2 = 0 "
"AND (jsonb_typeof(payload -> 'old_control_board_serial') = 'null' OR ("
"jsonb_typeof(payload -> 'old_control_board_serial') = 'string' "
"AND trim(payload ->> 'old_control_board_serial') <> '' "
"AND char_length(payload ->> 'old_control_board_serial') <= 255)) "
"AND (jsonb_typeof(payload -> 'new_control_board_serial') = 'null' OR ("
"jsonb_typeof(payload -> 'new_control_board_serial') = 'string' "
"AND trim(payload ->> 'new_control_board_serial') <> '' "
"AND char_length(payload ->> 'new_control_board_serial') <= 255)) "
f"AND payload ->> 'replacement_reason' IN ({_quoted(REPLACEMENT_REASONS)}) "
f"AND payload ->> 'removed_board_disposition' IN ({_quoted(DISPOSITIONS)}) "
"AND (NOT payload ? 'notes' OR jsonb_typeof(payload -> 'notes') = 'null' OR ("
"jsonb_typeof(payload -> 'notes') = 'string' "
"AND trim(payload ->> 'notes') <> '' "
"AND char_length(payload ->> 'notes') <= 4000))"
f"{parts_clause})",
)
def upgrade() -> None:
connection = op.get_bind()
connection.exec_driver_sql(
"""
INSERT INTO permissions (key, description)
VALUES
('components.read', 'View tracked component identity'),
('components.create', 'Register tracked component identity'),
('repairs.parts.manage', 'Record and void repair-part usage')
ON CONFLICT (key) DO UPDATE SET description = EXCLUDED.description;
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.key IN (
'components.read', 'components.create', 'repairs.parts.manage'
)
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.key = 'components.read'
WHERE roles.name = 'Read Only'
ON CONFLICT DO NOTHING;
"""
)
op.create_table(
"components",
sa.Column(
"id", postgresql.UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column("component_type", sa.Text(), nullable=False),
sa.Column("asset_id", sa.Text(), nullable=True),
sa.Column("manufacturer", sa.Text(), nullable=True),
sa.Column("model", sa.Text(), nullable=True),
sa.Column("manufacturer_serial", sa.Text(), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"created_by", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False,
),
sa.CheckConstraint(
f"component_type IN ({_quoted(COMPONENT_TYPES)})",
name="ck_components_type",
),
sa.CheckConstraint(
"asset_id IS NULL OR (trim(asset_id) <> '' "
"AND asset_id = trim(asset_id) AND char_length(asset_id) <= 255)",
name="ck_components_asset_id",
),
sa.CheckConstraint(
"manufacturer IS NULL OR (trim(manufacturer) <> '' "
"AND manufacturer = trim(manufacturer) "
"AND char_length(manufacturer) <= 255)",
name="ck_components_manufacturer",
),
sa.CheckConstraint(
"model IS NULL OR (trim(model) <> '' AND model = trim(model) "
"AND char_length(model) <= 255)",
name="ck_components_model",
),
sa.CheckConstraint(
"manufacturer_serial IS NULL OR (trim(manufacturer_serial) <> '' "
"AND manufacturer_serial = trim(manufacturer_serial) "
"AND char_length(manufacturer_serial) <= 255)",
name="ck_components_manufacturer_serial",
),
)
op.create_index("idx_components_created_by", "components", ["created_by"])
op.create_index("idx_components_type", "components", ["component_type"])
op.create_index(
"uq_components_normalized_asset_id",
"components",
[sa.text("lower(trim(asset_id))")],
unique=True,
postgresql_where=sa.text("asset_id IS NOT NULL"),
)
op.create_table(
"repair_parts",
sa.Column(
"id", postgresql.UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"repair_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=False,
),
sa.Column("consumable_description", sa.Text(), nullable=True),
sa.Column("quantity", sa.Numeric(precision=12, scale=3), nullable=True),
sa.Column("unit", sa.Text(), nullable=True),
sa.Column(
"component_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("components.id", ondelete="RESTRICT"), nullable=True,
),
sa.Column("component_type_snapshot", sa.Text(), nullable=True),
sa.Column("component_asset_id_snapshot", sa.Text(), nullable=True),
sa.Column("component_manufacturer_snapshot", sa.Text(), nullable=True),
sa.Column("component_model_snapshot", sa.Text(), nullable=True),
sa.Column("component_manufacturer_serial_snapshot", sa.Text(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column(
"recorded_by", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False,
),
sa.Column(
"recorded_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"recorded_event_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("repair_events.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column(
"voided_by", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True,
),
sa.Column("voided_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("void_reason", sa.Text(), nullable=True),
sa.Column(
"void_event_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("repair_events.id", ondelete="RESTRICT"),
nullable=True,
),
sa.CheckConstraint(
"((consumable_description IS NOT NULL AND quantity IS NOT NULL "
"AND unit IS NOT NULL AND component_id IS NULL "
"AND component_type_snapshot IS NULL "
"AND component_asset_id_snapshot IS NULL "
"AND component_manufacturer_snapshot IS NULL "
"AND component_model_snapshot IS NULL "
"AND component_manufacturer_serial_snapshot IS NULL) OR "
"(consumable_description IS NULL AND quantity IS NULL "
"AND unit IS NULL AND component_id IS NOT NULL "
"AND component_type_snapshot IS NOT NULL))",
name="ck_repair_parts_entry_xor",
),
sa.CheckConstraint(
"quantity IS NULL OR quantity > 0",
name="ck_repair_parts_quantity_positive",
),
sa.CheckConstraint(
"unit IS NULL OR unit <> 'each' OR quantity = trunc(quantity)",
name="ck_repair_parts_each_whole",
),
sa.CheckConstraint(
f"unit IS NULL OR unit IN ({_quoted(CONSUMABLE_UNITS)})",
name="ck_repair_parts_unit",
),
sa.CheckConstraint(
f"component_type_snapshot IS NULL OR "
f"component_type_snapshot IN ({_quoted(COMPONENT_TYPES)})",
name="ck_repair_parts_component_type",
),
sa.CheckConstraint(
"consumable_description IS NULL OR ("
"consumable_description = trim(consumable_description) "
"AND trim(consumable_description) <> '' "
"AND char_length(consumable_description) <= 255)",
name="ck_repair_parts_description",
),
sa.CheckConstraint(
"component_asset_id_snapshot IS NULL OR ("
"component_asset_id_snapshot = trim(component_asset_id_snapshot) "
"AND trim(component_asset_id_snapshot) <> '' "
"AND char_length(component_asset_id_snapshot) <= 255)",
name="ck_repair_parts_asset_snapshot",
),
sa.CheckConstraint(
"component_manufacturer_snapshot IS NULL OR ("
"component_manufacturer_snapshot = trim(component_manufacturer_snapshot) "
"AND trim(component_manufacturer_snapshot) <> '' "
"AND char_length(component_manufacturer_snapshot) <= 255)",
name="ck_repair_parts_manufacturer_snapshot",
),
sa.CheckConstraint(
"component_model_snapshot IS NULL OR ("
"component_model_snapshot = trim(component_model_snapshot) "
"AND trim(component_model_snapshot) <> '' "
"AND char_length(component_model_snapshot) <= 255)",
name="ck_repair_parts_model_snapshot",
),
sa.CheckConstraint(
"component_manufacturer_serial_snapshot IS NULL OR ("
"component_manufacturer_serial_snapshot = "
"trim(component_manufacturer_serial_snapshot) "
"AND trim(component_manufacturer_serial_snapshot) <> '' "
"AND char_length(component_manufacturer_serial_snapshot) <= 255)",
name="ck_repair_parts_serial_snapshot",
),
sa.CheckConstraint(
"notes IS NULL OR (notes = trim(notes) AND trim(notes) <> '' "
"AND char_length(notes) <= 4000)",
name="ck_repair_parts_notes",
),
sa.CheckConstraint(
"(voided_by IS NULL AND voided_at IS NULL AND void_reason IS NULL "
"AND void_event_id IS NULL) OR "
"(voided_by IS NOT NULL AND voided_at IS NOT NULL "
"AND void_reason IS NOT NULL AND void_event_id IS NOT NULL)",
name="ck_repair_parts_void_state",
),
sa.CheckConstraint(
"void_reason IS NULL OR (void_reason = trim(void_reason) "
"AND trim(void_reason) <> '' AND char_length(void_reason) <= 1000)",
name="ck_repair_parts_void_reason",
),
sa.CheckConstraint(
"voided_at IS NULL OR voided_at >= recorded_at",
name="ck_repair_parts_void_chronology",
),
)
op.create_index(
"idx_repair_parts_repair",
"repair_parts",
["repair_id", "recorded_event_id", "id"],
)
op.create_index("idx_repair_parts_component", "repair_parts", ["component_id"])
op.create_index("idx_repair_parts_recorded_by", "repair_parts", ["recorded_by"])
op.create_index("idx_repair_parts_voided_by", "repair_parts", ["voided_by"])
op.create_index(
"uq_repair_parts_recorded_event",
"repair_parts",
["recorded_event_id"],
unique=True,
)
op.create_index(
"uq_repair_parts_void_event",
"repair_parts",
["void_event_id"],
unique=True,
postgresql_where=sa.text("void_event_id IS NOT NULL"),
)
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
_create_event_constraints(True)
connection.exec_driver_sql(
"""
CREATE FUNCTION minerops_protect_component_identity()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'Durable component identity cannot be deleted';
END IF;
IF NEW.id <> OLD.id
OR NEW.component_type <> OLD.component_type
OR NEW.created_at <> OLD.created_at
OR NEW.created_by <> OLD.created_by THEN
RAISE EXCEPTION 'Component identity, type, and creation provenance are immutable';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_protect_component_identity
BEFORE UPDATE OR DELETE ON components
FOR EACH ROW EXECUTE FUNCTION minerops_protect_component_identity();
CREATE FUNCTION minerops_protect_repair_event_history()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'Repair event history is immutable';
END $$;
CREATE TRIGGER trg_protect_repair_event_history
BEFORE UPDATE OR DELETE ON repair_events
FOR EACH ROW EXECUTE FUNCTION minerops_protect_repair_event_history();
CREATE FUNCTION minerops_protect_repair_part_history()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'Repair-part history is immutable';
END IF;
IF NEW.id <> OLD.id
OR NEW.repair_id <> OLD.repair_id
OR NEW.consumable_description IS DISTINCT FROM OLD.consumable_description
OR NEW.quantity IS DISTINCT FROM OLD.quantity
OR NEW.unit IS DISTINCT FROM OLD.unit
OR NEW.component_id IS DISTINCT FROM OLD.component_id
OR NEW.component_type_snapshot IS DISTINCT FROM OLD.component_type_snapshot
OR NEW.component_asset_id_snapshot IS DISTINCT FROM OLD.component_asset_id_snapshot
OR NEW.component_manufacturer_snapshot IS DISTINCT FROM OLD.component_manufacturer_snapshot
OR NEW.component_model_snapshot IS DISTINCT FROM OLD.component_model_snapshot
OR NEW.component_manufacturer_serial_snapshot IS DISTINCT FROM OLD.component_manufacturer_serial_snapshot
OR NEW.notes IS DISTINCT FROM OLD.notes
OR NEW.recorded_by <> OLD.recorded_by
OR NEW.recorded_at <> OLD.recorded_at
OR NEW.recorded_event_id <> OLD.recorded_event_id THEN
RAISE EXCEPTION 'Original repair-part fields are immutable';
END IF;
IF OLD.voided_by IS NOT NULL OR OLD.voided_at IS NOT NULL
OR OLD.void_reason IS NOT NULL OR OLD.void_event_id IS NOT NULL THEN
RAISE EXCEPTION 'Voided repair-part history is immutable';
END IF;
IF NEW.voided_by IS NULL OR NEW.voided_at IS NULL
OR NEW.void_reason IS NULL OR NEW.void_event_id IS NULL THEN
RAISE EXCEPTION 'Repair-part void metadata must be written atomically';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_protect_repair_part_history
BEFORE UPDATE OR DELETE ON repair_parts
FOR EACH ROW EXECUTE FUNCTION minerops_protect_repair_part_history();
CREATE FUNCTION minerops_validate_repair_part_history()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE recorded repair_events%%ROWTYPE;
DECLARE voided repair_events%%ROWTYPE;
DECLARE component components%%ROWTYPE;
BEGIN
IF TG_OP = 'INSERT' AND (
NEW.voided_by IS NOT NULL OR NEW.voided_at IS NOT NULL
OR NEW.void_reason IS NOT NULL OR NEW.void_event_id IS NOT NULL) THEN
RAISE EXCEPTION 'A repair part must be recorded before it can be voided';
END IF;
SELECT * INTO recorded FROM repair_events WHERE id = NEW.recorded_event_id;
IF recorded.id IS NULL
OR recorded.repair_id <> NEW.repair_id
OR recorded.event_type <> 'REPAIR_PART_RECORDED'
OR recorded.created_by <> NEW.recorded_by
OR recorded.created_at <> NEW.recorded_at
OR recorded.payload ->> 'repair_part_id' <> NEW.id::text THEN
RAISE EXCEPTION 'Recorded repair-part event does not match its entry';
END IF;
IF TG_OP = 'INSERT' AND NEW.component_id IS NOT NULL THEN
SELECT * INTO component FROM components WHERE id = NEW.component_id;
IF component.id IS NULL
OR component.component_type <> NEW.component_type_snapshot
OR component.asset_id IS DISTINCT FROM NEW.component_asset_id_snapshot
OR component.manufacturer IS DISTINCT FROM NEW.component_manufacturer_snapshot
OR component.model IS DISTINCT FROM NEW.component_model_snapshot
OR component.manufacturer_serial IS DISTINCT FROM NEW.component_manufacturer_serial_snapshot THEN
RAISE EXCEPTION 'Tracked-component snapshot does not match the component';
END IF;
END IF;
IF NEW.void_event_id IS NOT NULL THEN
SELECT * INTO voided FROM repair_events WHERE id = NEW.void_event_id;
IF voided.id IS NULL
OR voided.repair_id <> NEW.repair_id
OR voided.event_type <> 'REPAIR_PART_VOIDED'
OR voided.created_by <> NEW.voided_by
OR voided.created_at <> NEW.voided_at
OR voided.payload ->> 'repair_part_id' <> NEW.id::text
OR voided.payload ->> 'reason' <> NEW.void_reason THEN
RAISE EXCEPTION 'Voided repair-part event does not match its entry';
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_validate_repair_part_history
BEFORE INSERT OR UPDATE ON repair_parts
FOR EACH ROW EXECUTE FUNCTION minerops_validate_repair_part_history();
"""
)
def downgrade() -> None:
connection = op.get_bind()
connection.exec_driver_sql(
"""
DO $$
DECLARE history_count bigint;
BEGIN
SELECT count(*) INTO history_count FROM components;
history_count := history_count + (SELECT count(*) FROM repair_parts);
history_count := history_count + (
SELECT count(*) FROM repair_events
WHERE event_type IN ('REPAIR_PART_RECORDED', 'REPAIR_PART_VOIDED')
);
IF history_count > 0 THEN
RAISE EXCEPTION USING
MESSAGE = format(
'Cannot downgrade Repair Parts Tracking: %s component, repair-part, or event records would be destroyed',
history_count
),
HINT = 'Preserve or explicitly migrate Task 005 identity and history before downgrading; MinerOps will not delete or invent component, actor, timestamp, snapshot, or repair-part history.';
END IF;
END $$;
"""
)
connection.exec_driver_sql(
"""
DROP TRIGGER trg_validate_repair_part_history ON repair_parts;
DROP FUNCTION minerops_validate_repair_part_history();
DROP TRIGGER trg_protect_repair_part_history ON repair_parts;
DROP FUNCTION minerops_protect_repair_part_history();
DROP TRIGGER trg_protect_repair_event_history ON repair_events;
DROP FUNCTION minerops_protect_repair_event_history();
DROP TRIGGER trg_protect_component_identity ON components;
DROP FUNCTION minerops_protect_component_identity();
"""
)
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
_create_event_constraints(False)
op.drop_index("uq_repair_parts_void_event", table_name="repair_parts")
op.drop_index("uq_repair_parts_recorded_event", table_name="repair_parts")
op.drop_index("idx_repair_parts_voided_by", table_name="repair_parts")
op.drop_index("idx_repair_parts_recorded_by", table_name="repair_parts")
op.drop_index("idx_repair_parts_component", table_name="repair_parts")
op.drop_index("idx_repair_parts_repair", table_name="repair_parts")
op.drop_table("repair_parts")
op.drop_index("uq_components_normalized_asset_id", table_name="components")
op.drop_index("idx_components_type", table_name="components")
op.drop_index("idx_components_created_by", table_name="components")
op.drop_table("components")
connection.exec_driver_sql(
"""
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions
WHERE key IN ('components.read', 'components.create', 'repairs.parts.manage')
);
DELETE FROM permissions
WHERE key IN ('components.read', 'components.create', 'repairs.parts.manage');
"""
)
+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)
+25
View File
@@ -691,7 +691,32 @@ def repair_completion_request(path: str) -> bool:
)
def repair_parts_request(path: str) -> bool:
parts = path.split("/")
return (
len(parts) == 5
and parts[:3] == ["", "api", "repairs"]
and parts[4] == "parts"
) or (
len(parts) == 7
and parts[:3] == ["", "api", "repairs"]
and parts[4] == "parts"
and parts[6] == "void"
)
def component_request(path: str) -> bool:
parts = path.split("/")
return path == "/api/components" or (
len(parts) == 4 and parts[:3] == ["", "api", "components"]
)
def required_api_permission(method: str, path: str) -> str | None:
if component_request(path):
return "components.create" if method == "POST" else "components.read"
if repair_parts_request(path):
return "repairs.read" if method == "GET" else "repairs.parts.manage"
if path.startswith("/api/admin/"):
return "users.manage"
if method == "POST" and path.endswith("/change-mac") and path.startswith("/api/miners/"):
+2
View File
@@ -19,6 +19,7 @@ from app.identity import (
router as identity_router,
)
from app.inventory.router import router as inventory_router
from app.components.router import router as components_router
from app.repairs.exceptions import (
ControlBoardReplacementActorRequired,
InvalidMacAddress,
@@ -41,6 +42,7 @@ from app.repairs.router import (
app = FastAPI(title=settings.app_name)
app.include_router(identity_router)
app.include_router(inventory_router)
app.include_router(components_router)
install_identity_middleware(app)
+35
View File
@@ -135,3 +135,38 @@ class MacOwnershipConflict(ControlBoardReplacementConflict):
super().__init__(
"MAC address has current or historical ownership on another miner"
)
class RepairPartNotFound(RepairError):
"""Raised when a nested repair-part UUID does not exist for a repair."""
def __init__(self, repair_part_id: object):
self.repair_part_id = repair_part_id
super().__init__(f"Repair part not found: {repair_part_id}")
class RepairPartComponentNotFound(RepairError):
"""Raised when a tracked 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 RepairPartValidationError(RepairError):
"""Raised when repair-part input violates a domain rule."""
class RepairPartActorRequired(RepairError):
"""Raised when a repair-part action lacks an authenticated actor."""
def __init__(self):
super().__init__("An authenticated actor is required for repair-part actions")
class RepairPartAlreadyVoided(RepairConflict):
"""Raised when an immutable repair-part entry was already voided."""
def __init__(self, repair_part_id: object):
self.repair_part_id = repair_part_id
super().__init__(f"Repair part is already voided: {repair_part_id}")
+93 -1
View File
@@ -1,10 +1,12 @@
"""Typed contracts for the Repairs domain."""
from datetime import datetime
from decimal import Decimal
from typing import Any, Literal
from uuid import UUID
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
from app.components.models import ComponentType
RepairReason = Literal[
@@ -33,6 +35,8 @@ RepairEventType = Literal[
"INITIAL_NOTE",
"REPAIR_COMPLETED",
"CONTROL_BOARD_REPLACED",
"REPAIR_PART_RECORDED",
"REPAIR_PART_VOIDED",
]
RepairEligibleLifecycle = Literal["active", "spare"]
RepairPostLifecycle = Literal["active", "spare"]
@@ -194,3 +198,91 @@ class RepairTimelineResponse(BaseModel):
data: RepairTimeline
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
ConsumableUnit = Literal["each", "gram", "millilitre", "metre", "centimetre"]
RepairPartEntryKind = Literal["consumable", "tracked_component"]
class ConsumableInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
description: str = Field(min_length=1, max_length=255)
quantity: Decimal = Field(gt=0, max_digits=12, decimal_places=3)
unit: ConsumableUnit
@model_validator(mode="after")
def validate_each_quantity(self) -> "ConsumableInput":
if self.unit == "each" and self.quantity != self.quantity.to_integral_value():
raise ValueError("quantity for unit 'each' must be a whole number")
return self
class RepairPartRecordRequest(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
consumable: ConsumableInput | None = None
tracked_component_uuid: UUID | None = None
notes: str | None = Field(default=None, max_length=4000)
@model_validator(mode="after")
def validate_entry_shape(self) -> "RepairPartRecordRequest":
if (self.consumable is None) == (self.tracked_component_uuid is None):
raise ValueError(
"exactly one of consumable and tracked_component_uuid is required"
)
return self
class RepairPartVoidRequest(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
reason: str = Field(min_length=1, max_length=1000)
class RepairPartConsumable(BaseModel):
description: str
quantity: Decimal
unit: ConsumableUnit
class RepairPartComponentSnapshot(BaseModel):
component_type: ComponentType
asset_id: str | None = None
manufacturer: str | None = None
model: str | None = None
manufacturer_serial: str | None = None
class RepairPartDetail(BaseModel):
repair_part_id: UUID
repair_id: UUID
entry_kind: RepairPartEntryKind
consumable: RepairPartConsumable | None = None
component_id: UUID | None = None
component_snapshot: RepairPartComponentSnapshot | None = None
notes: str | None = None
recorded_by: UUID
recorded_at: AwareDatetime
recorded_event_id: UUID
voided_by: UUID | None = None
voided_at: AwareDatetime | None = None
void_reason: str | None = None
void_event_id: UUID | None = None
class RepairPartList(BaseModel):
repair_id: UUID
entries: list[RepairPartDetail] = Field(default_factory=list)
class RepairPartResponse(BaseModel):
data: RepairPartDetail
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class RepairPartListResponse(BaseModel):
data: RepairPartList
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
+178
View File
@@ -597,6 +597,184 @@ class RepairRepository:
row = cur.fetchone()
return dict(row)
def lock_component_for_update(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,
component_type,
asset_id,
manufacturer,
model,
manufacturer_serial
FROM components
WHERE id = %s
FOR UPDATE
""",
(component_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def create_repair_part(
self,
*,
repair_part_id: UUID,
repair_id: UUID,
consumable_description: str | None,
quantity: object | None,
unit: str | None,
component: Mapping[str, Any] | None,
notes: str | None,
recorded_event: Mapping[str, Any],
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO repair_parts (
id,
repair_id,
consumable_description,
quantity,
unit,
component_id,
component_type_snapshot,
component_asset_id_snapshot,
component_manufacturer_snapshot,
component_model_snapshot,
component_manufacturer_serial_snapshot,
notes,
recorded_by,
recorded_at,
recorded_event_id
)
VALUES (
%s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s
)
RETURNING *
""",
(
repair_part_id,
repair_id,
consumable_description,
quantity,
unit,
component.get("id") if component else None,
component.get("component_type") if component else None,
component.get("asset_id") if component else None,
component.get("manufacturer") if component else None,
component.get("model") if component else None,
component.get("manufacturer_serial") if component else None,
notes,
recorded_event["created_by"],
recorded_event["created_at"],
recorded_event["id"],
),
)
return dict(cur.fetchone())
def get_repair_part(
self,
repair_id: UUID,
repair_part_id: UUID,
) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT part.*, event.sequence AS recorded_event_sequence
FROM repair_parts AS part
JOIN repair_events AS event
ON event.id = part.recorded_event_id
WHERE part.repair_id = %s AND part.id = %s
""",
(repair_id, repair_part_id),
)
row = cur.fetchone()
return dict(row) if row else None
def get_repair_parts(self, repair_id: UUID) -> list[dict] | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
repair.id AS existing_repair_id,
part.*,
event.sequence AS recorded_event_sequence
FROM repairs AS repair
LEFT JOIN repair_parts AS part
ON part.repair_id = repair.id
LEFT JOIN repair_events AS event
ON event.id = part.recorded_event_id
WHERE repair.id = %s
ORDER BY event.sequence ASC, part.id ASC
""",
(repair_id,),
)
rows = [dict(row) for row in cur.fetchall()]
if not rows:
return None
return [row for row in rows if row.get("id") is not None]
def lock_repair_part_for_update(
self,
repair_id: UUID,
repair_part_id: UUID,
) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT *
FROM repair_parts
WHERE repair_id = %s AND id = %s
FOR UPDATE
""",
(repair_id, repair_part_id),
)
row = cur.fetchone()
return dict(row) if row else None
def void_repair_part(
self,
repair_id: UUID,
repair_part_id: UUID,
*,
reason: str,
void_event: Mapping[str, Any],
) -> dict | None:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
UPDATE repair_parts
SET voided_by = %s,
voided_at = %s,
void_reason = %s,
void_event_id = %s
WHERE repair_id = %s
AND id = %s
AND voided_at IS NULL
RETURNING *
""",
(
void_event["created_by"],
void_event["created_at"],
reason,
void_event["id"],
repair_id,
repair_part_id,
),
)
row = cur.fetchone()
return dict(row) if row else None
def get_repair_detail(self, repair_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
+111
View File
@@ -15,6 +15,10 @@ from app.repairs.exceptions import (
RepairConflict,
RepairMinerNotFound,
RepairNotFound,
RepairPartActorRequired,
RepairPartComponentNotFound,
RepairPartNotFound,
RepairPartValidationError,
)
from app.repairs.models import (
BeginRepairRequest,
@@ -24,6 +28,10 @@ from app.repairs.models import (
RepairCompleteRequest,
RepairDetailResponse,
RepairTimelineResponse,
RepairPartListResponse,
RepairPartRecordRequest,
RepairPartResponse,
RepairPartVoidRequest,
)
from app.repairs.service import RepairService
@@ -33,6 +41,7 @@ control_board_router = APIRouter(prefix="/api/miners", tags=["miners"])
require_repair_create = require_permission("repairs.create")
require_repair_read = require_permission("repairs.read")
require_repair_complete = require_permission("repairs.complete")
require_repair_parts_manage = require_permission("repairs.parts.manage")
require_control_board_replace = require_permission("miners.replace_control_board")
RepairServiceDependency = Annotated[
@@ -42,6 +51,7 @@ RepairServiceDependency = Annotated[
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
RepairReadUser = Annotated[dict, Depends(require_repair_read)]
RepairCompleteUser = Annotated[dict, Depends(require_repair_complete)]
RepairPartsManageUser = Annotated[dict, Depends(require_repair_parts_manage)]
ControlBoardReplaceUser = Annotated[dict, Depends(require_control_board_replace)]
@@ -149,6 +159,107 @@ def complete_repair(
return RepairDetailResponse(data=repair)
@router.get(
"/{repair_id}/parts",
response_model=RepairPartListResponse,
)
def list_repair_parts(
repair_id: UUID,
service: RepairServiceDependency,
current_user: RepairReadUser,
) -> RepairPartListResponse:
del current_user
try:
parts = service.list_repair_parts(repair_id)
except RepairNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Repair not found",
) from exc
return RepairPartListResponse(data=parts)
@router.post(
"/{repair_id}/parts",
response_model=RepairPartResponse,
status_code=status.HTTP_201_CREATED,
)
def record_repair_part(
repair_id: UUID,
request: RepairPartRecordRequest,
service: RepairServiceDependency,
current_user: RepairPartsManageUser,
) -> RepairPartResponse:
try:
part = service.record_repair_part(
repair_id,
request,
actor_user_id=UUID(str(current_user["id"])),
)
except (RepairNotFound, RepairPartComponentNotFound) as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
except RepairMinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Repair miner no longer exists",
) from exc
except RepairConflict as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except (RepairPartActorRequired, RepairPartValidationError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return RepairPartResponse(data=part)
@router.post(
"/{repair_id}/parts/{repair_part_id}/void",
response_model=RepairPartResponse,
)
def void_repair_part(
repair_id: UUID,
repair_part_id: UUID,
request: RepairPartVoidRequest,
service: RepairServiceDependency,
current_user: RepairPartsManageUser,
) -> RepairPartResponse:
try:
part = service.void_repair_part(
repair_id,
repair_part_id,
request,
actor_user_id=UUID(str(current_user["id"])),
)
except (RepairNotFound, RepairPartNotFound) as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
except RepairMinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Repair miner no longer exists",
) from exc
except RepairConflict as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except (RepairPartActorRequired, RepairPartValidationError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return RepairPartResponse(data=part)
@router.get(
"/{repair_id}",
response_model=RepairDetailResponse,
+277 -1
View File
@@ -1,6 +1,7 @@
"""Business rules and orchestration for repair workflows."""
from uuid import UUID
from decimal import Decimal
from uuid import UUID, uuid4
import re
@@ -19,6 +20,11 @@ from app.repairs.exceptions import (
RepairNotActive,
RepairNotFound,
RepairPersistenceError,
RepairPartActorRequired,
RepairPartAlreadyVoided,
RepairPartComponentNotFound,
RepairPartNotFound,
RepairPartValidationError,
)
from app.repairs.models import (
BeginRepairRequest,
@@ -30,6 +36,12 @@ from app.repairs.models import (
RepairActor,
RepairCompleteRequest,
RepairCompletedPayload,
RepairPartComponentSnapshot,
RepairPartConsumable,
RepairPartDetail,
RepairPartList,
RepairPartRecordRequest,
RepairPartVoidRequest,
RepairDetail,
RepairTimeline,
RepairTimelineEvent,
@@ -378,6 +390,270 @@ class RepairService:
return self._map_repair_detail(detail)
@staticmethod
def _normalize_repair_part_text(
value: str | None,
*,
field_name: str,
max_length: int,
) -> str | None:
if value is None:
return None
normalized = str(value).strip()
if not normalized:
raise RepairPartValidationError(
f"{field_name} must not be blank when supplied"
)
if len(normalized) > max_length:
raise RepairPartValidationError(
f"{field_name} must not exceed {max_length} characters"
)
return normalized
@staticmethod
def _validate_consumable_quantity(quantity: Decimal, unit: str) -> Decimal:
normalized = Decimal(quantity)
if normalized <= 0:
raise RepairPartValidationError("quantity must be greater than zero")
significant = normalized.normalize()
fractional_places = max(-significant.as_tuple().exponent, 0)
if fractional_places > 3 or normalized >= Decimal("1000000000"):
raise RepairPartValidationError(
"quantity must fit NUMERIC(12,3)"
)
if unit == "each" and normalized != normalized.to_integral_value():
raise RepairPartValidationError(
"quantity for unit 'each' must be a whole number"
)
return normalized
@staticmethod
def _map_repair_part(part: dict) -> RepairPartDetail:
if part["consumable_description"] is not None:
consumable = RepairPartConsumable(
description=part["consumable_description"],
quantity=part["quantity"],
unit=part["unit"],
)
component_snapshot = None
entry_kind = "consumable"
else:
consumable = None
component_snapshot = RepairPartComponentSnapshot(
component_type=part["component_type_snapshot"],
asset_id=part["component_asset_id_snapshot"],
manufacturer=part["component_manufacturer_snapshot"],
model=part["component_model_snapshot"],
manufacturer_serial=(
part["component_manufacturer_serial_snapshot"]
),
)
entry_kind = "tracked_component"
return RepairPartDetail(
repair_part_id=part["id"],
repair_id=part["repair_id"],
entry_kind=entry_kind,
consumable=consumable,
component_id=part["component_id"],
component_snapshot=component_snapshot,
notes=part["notes"],
recorded_by=part["recorded_by"],
recorded_at=part["recorded_at"],
recorded_event_id=part["recorded_event_id"],
voided_by=part["voided_by"],
voided_at=part["voided_at"],
void_reason=part["void_reason"],
void_event_id=part["void_event_id"],
)
@staticmethod
def _lock_active_repair(
repository: RepairRepository,
repair_id: UUID,
) -> tuple[dict, dict]:
identity = repository.get_repair_identity(repair_id)
if identity is None:
raise RepairNotFound(repair_id)
miner_id = identity["miner_id"]
miner = repository.lock_miner_for_update(miner_id)
if miner is None:
raise RepairMinerNotFound(miner_id)
repair = repository.lock_repair_for_update(repair_id)
if repair is None:
raise RepairNotFound(repair_id)
if repair["miner_id"] != miner_id:
raise RepairActiveMismatch(repair_id, miner_id)
if repair["status"] not in ACTIVE_REPAIR_STATUSES:
raise RepairNotActive(repair_id, repair["status"])
active_repair = repository.get_active_repair(miner_id)
if active_repair is None or active_repair["id"] != repair_id:
raise RepairActiveMismatch(repair_id, miner_id)
lifecycle_status = str(miner["lifecycle_status"])
if lifecycle_status != REPAIR_LIFECYCLE_STATUS:
raise RepairMinerNotInRepair(miner_id, lifecycle_status)
return miner, repair
def record_repair_part(
self,
repair_id: UUID,
request: RepairPartRecordRequest,
*,
actor_user_id: UUID | None,
) -> RepairPartDetail:
if actor_user_id is None:
raise RepairPartActorRequired()
notes = self._normalize_repair_part_text(
request.notes,
field_name="notes",
max_length=4000,
)
description = None
quantity = None
unit = None
if request.consumable is not None:
description = self._normalize_repair_part_text(
request.consumable.description,
field_name="consumable.description",
max_length=255,
)
quantity = self._validate_consumable_quantity(
request.consumable.quantity,
request.consumable.unit,
)
unit = request.consumable.unit
with self.repository.transaction() as repository:
self._lock_active_repair(repository, repair_id)
component = None
if request.tracked_component_uuid is not None:
component = repository.lock_component_for_update(
request.tracked_component_uuid
)
if component is None:
raise RepairPartComponentNotFound(
request.tracked_component_uuid
)
repair_part_id = uuid4()
next_sequence = repository.get_next_event_sequence(repair_id)
if component is None:
payload = {
"repair_part_id": str(repair_part_id),
"entry_kind": "consumable",
"description": description,
"quantity": format(quantity, "f"),
"unit": unit,
"notes": notes,
}
else:
payload = {
"repair_part_id": str(repair_part_id),
"entry_kind": "tracked_component",
"component_id": str(component["id"]),
"component_type": component["component_type"],
"component_asset_id": component["asset_id"],
"component_manufacturer": component["manufacturer"],
"component_model": component["model"],
"component_manufacturer_serial": (
component["manufacturer_serial"]
),
"notes": notes,
}
event = repository.create_event(
repair_id,
sequence=next_sequence,
event_type="REPAIR_PART_RECORDED",
created_by=actor_user_id,
payload=payload,
)
repository.create_repair_part(
repair_part_id=repair_part_id,
repair_id=repair_id,
consumable_description=description,
quantity=quantity,
unit=unit,
component=component,
notes=notes,
recorded_event=event,
)
part = repository.get_repair_part(repair_id, repair_part_id)
if part is None:
raise RepairPersistenceError(
"Recorded repair part could not be read in its transaction"
)
return self._map_repair_part(part)
def list_repair_parts(self, repair_id: UUID) -> RepairPartList:
parts = self.repository.get_repair_parts(repair_id)
if parts is None:
raise RepairNotFound(repair_id)
return RepairPartList(
repair_id=repair_id,
entries=[self._map_repair_part(part) for part in parts],
)
def void_repair_part(
self,
repair_id: UUID,
repair_part_id: UUID,
request: RepairPartVoidRequest,
*,
actor_user_id: UUID | None,
) -> RepairPartDetail:
if actor_user_id is None:
raise RepairPartActorRequired()
reason = self._normalize_repair_part_text(
request.reason,
field_name="reason",
max_length=1000,
)
assert reason is not None
with self.repository.transaction() as repository:
self._lock_active_repair(repository, repair_id)
part = repository.lock_repair_part_for_update(
repair_id,
repair_part_id,
)
if part is None:
raise RepairPartNotFound(repair_part_id)
if part["voided_at"] is not None:
raise RepairPartAlreadyVoided(repair_part_id)
next_sequence = repository.get_next_event_sequence(repair_id)
event = repository.create_event(
repair_id,
sequence=next_sequence,
event_type="REPAIR_PART_VOIDED",
created_by=actor_user_id,
payload={
"repair_part_id": str(repair_part_id),
"reason": reason,
},
)
voided = repository.void_repair_part(
repair_id,
repair_part_id,
reason=reason,
void_event=event,
)
if voided is None:
raise RepairPartAlreadyVoided(repair_part_id)
part = repository.get_repair_part(repair_id, repair_part_id)
if part is None:
raise RepairPersistenceError(
"Voided repair part could not be read in its transaction"
)
return self._map_repair_part(part)
def get_repair(self, repair_id: UUID) -> RepairDetail:
repair = self.repository.get_repair_detail(repair_id)
if repair is None:
@@ -474,7 +474,9 @@ class ControlBoardReplacementDatabaseTests(unittest.TestCase):
"TRUNCATE miner_control_board_installations, "
"miner_network_identities, miner_mac_ownerships"
)
cur.execute("ALTER TABLE repair_events DISABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repair_events")
cur.execute("ALTER TABLE repair_events ENABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repairs")
cur.execute("DELETE FROM miner_tags")
cur.execute("DELETE FROM miner_asset_id_history")
@@ -500,7 +502,9 @@ class ControlBoardReplacementDatabaseTests(unittest.TestCase):
"TRUNCATE miner_control_board_installations, "
"miner_network_identities, miner_mac_ownerships"
)
cur.execute("ALTER TABLE repair_events DISABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repair_events")
cur.execute("ALTER TABLE repair_events ENABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repairs")
cur.execute("DELETE FROM miner_tags")
cur.execute("DELETE FROM miner_asset_id_history")
+455
View File
@@ -0,0 +1,455 @@
import ast
from contextlib import contextmanager
from copy import deepcopy
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
import unittest
from uuid import uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
from app.components.dependencies import get_component_service
from app.components.exceptions import ComponentValidationError
from app.components.models import ComponentCreateRequest, ComponentDetail
from app.components.router import router as component_router
from app.components.service import ComponentService
from app.identity import get_current_user, required_api_permission
from app.repairs.dependencies import get_repair_service
from app.repairs.exceptions import RepairPartAlreadyVoided
from app.repairs.models import (
ConsumableInput,
RepairPartComponentSnapshot,
RepairPartConsumable,
RepairPartDetail,
RepairPartList,
RepairPartRecordRequest,
RepairPartVoidRequest,
)
from app.repairs.router import router as repair_router
from app.repairs.service import RepairService
NOW = datetime(2026, 8, 2, 12, 0, tzinfo=timezone.utc)
class FakeComponentRepository:
def __init__(self):
self.rows = {}
self.calls = []
@contextmanager
def transaction(self):
self.calls.append("transaction")
yield self
def create_component(self, **values):
component_id = uuid4()
row = {
"component_id": component_id,
"created_at": NOW,
**values,
}
self.rows[component_id] = row
return row
def get_component(self, component_id):
return self.rows.get(component_id)
class ComponentServiceTests(unittest.TestCase):
def test_create_normalizes_metadata_and_uses_authoritative_fields(self):
repository = FakeComponentRepository()
actor = uuid4()
component = ComponentService(repository).create_component(
ComponentCreateRequest(
component_type="hashboard",
asset_id=" HB-0042 ",
manufacturer=" MicroBT ",
),
actor_user_id=actor,
)
self.assertEqual(component.asset_id, "HB-0042")
self.assertEqual(component.manufacturer, "MicroBT")
self.assertEqual(component.created_by, actor)
self.assertIsNotNone(component.component_id)
self.assertEqual(repository.calls, ["transaction"])
def test_component_request_rejects_blank_unknown_and_server_fields(self):
for body, exception in (
({"component_type": "other"}, ValidationError),
({"component_type": "fan", "asset_id": " "}, ComponentValidationError),
({"component_type": "fan", "created_by": str(uuid4())}, ValidationError),
):
with self.subTest(body=body), self.assertRaises(exception):
request = ComponentCreateRequest(**body)
ComponentService(FakeComponentRepository()).create_component(
request, actor_user_id=uuid4()
)
class FakeRepairPartRepository:
def __init__(self):
self.repair_id = uuid4()
self.miner_id = uuid4()
self.actor_id = uuid4()
self.component_id = uuid4()
self.miner = {"id": self.miner_id, "lifecycle_status": "in_repair"}
self.repair = {
"id": self.repair_id,
"miner_id": self.miner_id,
"status": "OPEN",
}
self.component = {
"id": self.component_id,
"component_type": "control_board",
"asset_id": "CB-42",
"manufacturer": "MicroBT",
"model": None,
"manufacturer_serial": "SER-42",
}
self.events = [{"sequence": 1}]
self.parts = {}
self.calls = []
self.fail_at = None
@contextmanager
def transaction(self):
self.calls.append("transaction")
snapshot = deepcopy((self.events, self.parts))
try:
yield self
except Exception:
self.events, self.parts = snapshot
raise
def _call(self, name):
self.calls.append(name)
if self.fail_at == name:
raise RuntimeError(name)
def get_repair_identity(self, repair_id):
self._call("repair_identity")
return {"miner_id": self.miner_id} if repair_id == self.repair_id else None
def lock_miner_for_update(self, miner_id):
self._call("lock_miner")
return self.miner if miner_id == self.miner_id else None
def lock_repair_for_update(self, repair_id):
self._call("lock_repair")
return self.repair if repair_id == self.repair_id else None
def get_active_repair(self, miner_id):
self._call("active_repair")
return self.repair if miner_id == self.miner_id else None
def lock_component_for_update(self, component_id):
self._call("lock_component")
return self.component if component_id == self.component_id else None
def get_next_event_sequence(self, repair_id):
self._call("next_sequence")
return max(event["sequence"] for event in self.events) + 1
def create_event(self, repair_id, **values):
self._call("create_event")
row = {"id": uuid4(), "repair_id": repair_id, "created_at": NOW, **values}
self.events.append(row)
return row
def create_repair_part(self, **values):
self._call("create_part")
component = values["component"]
row = {
"id": values["repair_part_id"],
"repair_id": values["repair_id"],
"consumable_description": values["consumable_description"],
"quantity": values["quantity"],
"unit": values["unit"],
"component_id": component["id"] if component else None,
"component_type_snapshot": component["component_type"] if component else None,
"component_asset_id_snapshot": component["asset_id"] if component else None,
"component_manufacturer_snapshot": component["manufacturer"] if component else None,
"component_model_snapshot": component["model"] if component else None,
"component_manufacturer_serial_snapshot": component["manufacturer_serial"] if component else None,
"notes": values["notes"],
"recorded_by": values["recorded_event"]["created_by"],
"recorded_at": values["recorded_event"]["created_at"],
"recorded_event_id": values["recorded_event"]["id"],
"voided_by": None,
"voided_at": None,
"void_reason": None,
"void_event_id": None,
}
self.parts[row["id"]] = row
return row
def get_repair_part(self, repair_id, repair_part_id):
self._call("get_part")
row = self.parts.get(repair_part_id)
return row if row and row["repair_id"] == repair_id else None
def get_repair_parts(self, repair_id):
rows = [row for row in self.parts.values() if row["repair_id"] == repair_id]
return rows if repair_id == self.repair_id else None
def lock_repair_part_for_update(self, repair_id, repair_part_id):
self._call("lock_part")
return self.get_repair_part(repair_id, repair_part_id)
def void_repair_part(self, repair_id, repair_part_id, *, reason, void_event):
self._call("void_part")
row = self.get_repair_part(repair_id, repair_part_id)
if row is None or row["voided_at"] is not None:
return None
row.update(
voided_by=void_event["created_by"],
voided_at=void_event["created_at"],
void_reason=reason,
void_event_id=void_event["id"],
)
return row
class RepairPartServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeRepairPartRepository()
self.service = RepairService(self.repository)
def test_request_xor_precision_units_and_trim(self):
valid = ConsumableInput(description=" Paste ", quantity="1.250", unit="gram")
self.assertEqual(valid.description, "Paste")
self.assertEqual(valid.quantity, Decimal("1.250"))
invalid = (
{"consumable": None, "tracked_component_uuid": None},
{"consumable": valid, "tracked_component_uuid": uuid4()},
{"consumable": {"description": "x", "quantity": "1.0001", "unit": "gram"}},
{"consumable": {"description": "x", "quantity": "1.5", "unit": "each"}},
{"consumable": {"description": "x", "quantity": "1", "unit": "kg"}},
)
for body in invalid:
with self.subTest(body=body), self.assertRaises(ValidationError):
RepairPartRecordRequest(**body)
def test_consumable_record_is_atomic_and_sequenced(self):
result = self.service.record_repair_part(
self.repository.repair_id,
RepairPartRecordRequest(
consumable={"description": " Paste ", "quantity": "2", "unit": "gram"},
notes=" Used on board ",
),
actor_user_id=self.repository.actor_id,
)
self.assertEqual(result.entry_kind, "consumable")
self.assertEqual(result.consumable.description, "Paste")
event = self.repository.events[-1]
self.assertEqual(event["event_type"], "REPAIR_PART_RECORDED")
self.assertEqual(event["sequence"], 2)
self.assertEqual(event["created_by"], self.repository.actor_id)
self.assertEqual(event["payload"]["quantity"], "2")
self.assertEqual(self.repository.calls[:5], [
"transaction", "repair_identity", "lock_miner", "lock_repair", "active_repair"
])
def test_tracked_component_uses_locked_snapshot_without_mutation(self):
before = deepcopy(self.repository.component)
result = self.service.record_repair_part(
self.repository.repair_id,
RepairPartRecordRequest(tracked_component_uuid=self.repository.component_id),
actor_user_id=self.repository.actor_id,
)
self.assertEqual(result.component_id, self.repository.component_id)
self.assertEqual(result.component_snapshot.asset_id, "CB-42")
self.assertEqual(self.repository.component, before)
self.assertLess(
self.repository.calls.index("lock_repair"),
self.repository.calls.index("lock_component"),
)
def test_void_is_one_way_and_rollbacks_leave_no_partial_event(self):
part = self.service.record_repair_part(
self.repository.repair_id,
RepairPartRecordRequest(
consumable={"description": "Fuse", "quantity": 1, "unit": "each"}
),
actor_user_id=self.repository.actor_id,
)
voided = self.service.void_repair_part(
self.repository.repair_id,
part.repair_part_id,
RepairPartVoidRequest(reason=" Wrong repair "),
actor_user_id=self.repository.actor_id,
)
self.assertEqual(voided.void_reason, "Wrong repair")
with self.assertRaises(RepairPartAlreadyVoided):
self.service.void_repair_part(
self.repository.repair_id,
part.repair_part_id,
RepairPartVoidRequest(reason="Again"),
actor_user_id=self.repository.actor_id,
)
self.assertEqual(
[event.get("event_type") for event in self.repository.events].count("REPAIR_PART_VOIDED"),
1,
)
def test_insert_failure_rolls_back_event_and_part(self):
self.repository.fail_at = "create_part"
with self.assertRaises(RuntimeError):
self.service.record_repair_part(
self.repository.repair_id,
RepairPartRecordRequest(
consumable={"description": "Fuse", "quantity": 1, "unit": "each"}
),
actor_user_id=self.repository.actor_id,
)
self.assertEqual(self.repository.events, [{"sequence": 1}])
self.assertEqual(self.repository.parts, {})
def part_detail(repair_id, actor_id):
return RepairPartDetail(
repair_part_id=uuid4(), repair_id=repair_id, entry_kind="consumable",
consumable=RepairPartConsumable(description="Fuse", quantity=1, unit="each"),
component_id=None, component_snapshot=None, notes=None,
recorded_by=actor_id, recorded_at=NOW, recorded_event_id=uuid4(),
)
class StubComponentService:
def __init__(self, actor):
self.actor = actor
def create_component(self, request, *, actor_user_id):
self.actor_seen = actor_user_id
return ComponentDetail(
component_id=uuid4(), component_type=request.component_type,
created_at=NOW, created_by=actor_user_id,
)
def get_component(self, component_id):
return ComponentDetail(
component_id=component_id, component_type="fan",
created_at=NOW, created_by=self.actor,
)
class StubRepairService:
def __init__(self, repair_id, actor):
self.repair_id = repair_id
self.actor = actor
self.detail = part_detail(repair_id, actor)
def record_repair_part(self, repair_id, request, *, actor_user_id):
self.actor_seen = actor_user_id
return self.detail
def list_repair_parts(self, repair_id):
return RepairPartList(repair_id=repair_id, entries=[self.detail])
def void_repair_part(self, repair_id, repair_part_id, request, *, actor_user_id):
self.actor_seen = actor_user_id
return self.detail.model_copy(update={
"voided_by": actor_user_id, "voided_at": NOW,
"void_reason": request.reason, "void_event_id": uuid4(),
})
class Task005RouterTests(unittest.TestCase):
def setUp(self):
self.actor = uuid4()
self.repair_id = uuid4()
self.component_service = StubComponentService(self.actor)
self.repair_service = StubRepairService(self.repair_id, self.actor)
self.app = FastAPI()
self.app.include_router(component_router)
self.app.include_router(repair_router)
self.app.dependency_overrides[get_component_service] = lambda: self.component_service
self.app.dependency_overrides[get_repair_service] = lambda: self.repair_service
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions, role="Technician"):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(self.actor), "role": role, "permissions": list(permissions)
}
def test_authentication_permissions_and_actor_authority(self):
path = f"/api/repairs/{self.repair_id}/parts"
body = {"consumable": {"description": "Fuse", "quantity": 1, "unit": "each"}}
self.assertEqual(self.client.post(path, json=body).status_code, 401)
self.authenticate(("repairs.read",), role="Read Only")
self.assertEqual(self.client.post(path, json=body).status_code, 403)
self.assertEqual(self.client.get(path).status_code, 200)
self.authenticate(("repairs.parts.manage",))
self.assertEqual(self.client.post(path, json=body).status_code, 201)
self.assertEqual(self.repair_service.actor_seen, self.actor)
def test_component_routes_permissions_and_envelopes(self):
self.authenticate(("components.read",), role="Read Only")
self.assertEqual(self.client.post("/api/components", json={"component_type": "fan"}).status_code, 403)
self.assertEqual(self.client.get(f"/api/components/{uuid4()}").status_code, 200)
self.authenticate(("components.create",))
response = self.client.post("/api/components", json={"component_type": "fan"})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()["meta"], {})
self.assertIsNone(response.json()["error"])
def test_exact_routes_and_permission_mapping(self):
component_routes = {(route.path, tuple(sorted(route.methods))) for route in component_router.routes}
self.assertEqual(component_routes, {
("/api/components", ("POST",)),
("/api/components/{component_id}", ("GET",)),
})
part_routes = {
(route.path, tuple(sorted(route.methods)))
for route in repair_router.routes if "/parts" in route.path
}
self.assertEqual(part_routes, {
("/api/repairs/{repair_id}/parts", ("GET",)),
("/api/repairs/{repair_id}/parts", ("POST",)),
("/api/repairs/{repair_id}/parts/{repair_part_id}/void", ("POST",)),
})
repair_id = uuid4()
part_id = uuid4()
self.assertEqual(required_api_permission("GET", "/api/components"), "components.read")
self.assertEqual(required_api_permission("POST", "/api/components"), "components.create")
self.assertEqual(required_api_permission("GET", f"/api/repairs/{repair_id}/parts"), "repairs.read")
self.assertEqual(required_api_permission("POST", f"/api/repairs/{repair_id}/parts"), "repairs.parts.manage")
self.assertEqual(required_api_permission("POST", f"/api/repairs/{repair_id}/parts/{part_id}/void"), "repairs.parts.manage")
def test_architecture_boundaries(self):
root = Path(__file__).parents[1] / "app"
for relative in ("components/router.py", "repairs/router.py"):
source = (root / relative).read_text(encoding="utf-8")
tree = ast.parse(source)
imports = [
node.module for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom) and node.module
]
self.assertFalse(any(name.startswith(("psycopg", "sqlalchemy")) for name in imports))
self.assertNotIn("SELECT ", source.upper())
self.assertNotIn(".COMMIT(", source.upper())
for relative in (
"components/service.py", "components/repository.py",
"repairs/service.py", "repairs/repository.py",
):
tree = ast.parse((root / relative).read_text(encoding="utf-8"))
imports = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imports.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imports.append(node.module)
self.assertFalse(
any(name == "fastapi" or name.startswith("fastapi.") for name in imports)
)
if __name__ == "__main__":
unittest.main()
+19 -1
View File
@@ -219,7 +219,9 @@ class RepairTimelineRepositoryTests(unittest.TestCase):
self.note_actor = uuid4()
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute("ALTER TABLE repair_events DISABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repair_events")
cur.execute("ALTER TABLE repair_events ENABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repairs")
cur.execute("DELETE FROM miner_tags")
cur.execute("DELETE FROM miner_asset_id_history")
@@ -611,7 +613,7 @@ class RepairTimelineRouterTests(unittest.TestCase):
self.assertEqual(response.status_code, 403)
self.assertFalse(self.service.begin_called)
def test_exactly_two_timeline_get_routes_are_registered(self):
def test_timeline_and_parts_get_routes_are_registered(self):
get_routes = {
route.path
for route in router.routes
@@ -622,6 +624,7 @@ class RepairTimelineRouterTests(unittest.TestCase):
{
"/api/repairs/{repair_id}",
"/api/repairs/{repair_id}/events",
"/api/repairs/{repair_id}/parts",
},
)
@@ -715,6 +718,21 @@ class RepairTimelineArchitectureTests(unittest.TestCase):
"minerops_repair_parts_summary_api",
),
("POST", "/api/repairs", "begin_repair"),
(
"GET",
"/api/repairs/{repair_id}/parts",
"list_repair_parts",
),
(
"POST",
"/api/repairs/{repair_id}/parts",
"record_repair_part",
),
(
"POST",
"/api/repairs/{repair_id}/parts/{repair_part_id}/void",
"void_repair_part",
),
(
"POST",
"/api/repairs/{repair_id}/complete",
+2
View File
@@ -372,7 +372,9 @@ class RepairRepositoryIntegrationTests(unittest.TestCase):
cur.execute(
"DROP FUNCTION IF EXISTS test_fail_repair_lifecycle()"
)
cur.execute("ALTER TABLE repair_events DISABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repair_events")
cur.execute("ALTER TABLE repair_events ENABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repairs")
cur.execute("DELETE FROM miner_tags")
cur.execute("DELETE FROM miner_asset_id_history")
+9
View File
@@ -417,7 +417,9 @@ class ReturnToServiceRepositoryTests(unittest.TestCase):
cur.execute(
"DROP FUNCTION IF EXISTS test_fail_completion_lifecycle()"
)
cur.execute("ALTER TABLE repair_events DISABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repair_events")
cur.execute("ALTER TABLE repair_events ENABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repairs")
cur.execute("DELETE FROM miner_tags")
cur.execute("DELETE FROM miner_asset_id_history")
@@ -586,7 +588,14 @@ class ReturnToServiceRepositoryTests(unittest.TestCase):
_, repair_id = self.begin()
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
if name == "delete":
cur.execute(
"ALTER TABLE repair_events DISABLE TRIGGER "
"trg_protect_repair_event_history"
)
cur.execute(change_sql, (repair_id,))
if name == "delete":
cur.execute("ALTER TABLE repair_events ENABLE TRIGGER trg_protect_repair_event_history")
RepairService(self.repository).complete_repair(
repair_id,
+1 -1
View File
@@ -132,7 +132,7 @@ health_json="$(cat "${health_file}")"
echo "Checking migrations and required schema..."
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
[[ "${current_revision}" == *"0013_control_board_replacement"* ]]
[[ "${current_revision}" == *"0014_repair_parts_tracking"* ]]
missing_tables="$(
"${compose[@]}" exec -T postgres sh -c \
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
project_name="${MINEROPS_REPAIR_PARTS_MIGRATION_PROJECT:-minerops-repair-parts-migration-$$}"
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
cleanup() {
status=$?
if (( status != 0 )); then
"${compose[@]}" logs --no-color --tail 120 postgres || true
fi
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
exit "${status}"
}
trap cleanup EXIT
run_alembic() { "${compose[@]}" run --rm --no-deps backend alembic "$@"; }
psql_db() {
"${compose[@]}" exec -T postgres sh -ec \
'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$@"' sh "$@"
}
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
"${compose[@]}" up --detach postgres
for attempt in $(seq 1 60); do
"${compose[@]}" exec -T postgres sh -ec \
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
sleep 1
done
"${compose[@]}" build backend
echo "Upgrading from Task 004 parent..."
run_alembic upgrade 0013_control_board_replacement
[[ "$(run_alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]]
run_alembic upgrade 0014_repair_parts_tracking
[[ "$(run_alembic current 2>/dev/null)" == *"0014_repair_parts_tracking"* ]]
psql_db <<'SQL'
DO $$
DECLARE component_roles text[];
DECLARE create_roles text[];
DECLARE manage_roles text[];
BEGIN
IF to_regclass('public.components') IS NULL OR to_regclass('public.repair_parts') IS NULL THEN
RAISE EXCEPTION 'Task 005 tables are missing';
END IF;
SELECT array_agg(r.name ORDER BY r.name) INTO component_roles
FROM role_permissions rp JOIN roles r ON r.id = rp.role_id
JOIN permissions p ON p.id = rp.permission_id WHERE p.key = 'components.read';
SELECT array_agg(r.name ORDER BY r.name) INTO create_roles
FROM role_permissions rp JOIN roles r ON r.id = rp.role_id
JOIN permissions p ON p.id = rp.permission_id WHERE p.key = 'components.create';
SELECT array_agg(r.name ORDER BY r.name) INTO manage_roles
FROM role_permissions rp JOIN roles r ON r.id = rp.role_id
JOIN permissions p ON p.id = rp.permission_id WHERE p.key = 'repairs.parts.manage';
IF component_roles <> ARRAY['Admin', 'Manager', 'Read Only', 'Technician'] THEN
RAISE EXCEPTION 'Unexpected components.read roles: %', component_roles;
END IF;
IF create_roles <> ARRAY['Admin', 'Manager', 'Technician']
OR manage_roles <> ARRAY['Admin', 'Manager', 'Technician'] THEN
RAISE EXCEPTION 'Unexpected Task 005 write roles: %, %', create_roles, manage_roles;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ck_repair_parts_entry_xor'
) OR NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_validate_repair_part_history'
) THEN
RAISE EXCEPTION 'Task 005 constraints or triggers are missing';
END IF;
END $$;
SQL
echo "Testing safe downgrade and re-upgrade..."
run_alembic downgrade 0013_control_board_replacement
[[ "$(run_alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]]
absent="$(psql_db -Atc "SELECT to_regclass('public.components') IS NULL AND to_regclass('public.repair_parts') IS NULL")"
[[ "${absent}" == "t" ]]
run_alembic upgrade 0014_repair_parts_tracking
echo "Testing registry, XOR, snapshots, voids, and immutability..."
psql_db <<'SQL'
INSERT INTO users (id, username, display_name, password_hash, role_id)
SELECT '00000000-0000-0000-0000-000000001501', 'repair-parts-migration',
'Repair Parts Migration', 'unused', id FROM roles WHERE name = 'Admin';
INSERT INTO miners (
id, asset_tag, lifecycle_status, status, first_seen_at, last_seen_at
) VALUES (
'00000000-0000-0000-0000-000000001502', 'PARTS-MINER',
'in_repair', 'unknown', NULL, NULL
);
INSERT INTO repairs (id, miner_id, status, reason, opened_by)
VALUES (
'00000000-0000-0000-0000-000000001503',
'00000000-0000-0000-0000-000000001502', 'OPEN', 'OTHER',
'00000000-0000-0000-0000-000000001501'
);
INSERT INTO repair_events (id, repair_id, sequence, event_type, created_by, payload)
VALUES (
'00000000-0000-0000-0000-000000001504',
'00000000-0000-0000-0000-000000001503', 1, 'REPAIR_STARTED',
'00000000-0000-0000-0000-000000001501',
'{"reason":"OTHER","from_lifecycle":"active","to_lifecycle":"in_repair"}'
);
INSERT INTO components (
id, component_type, asset_id, manufacturer, manufacturer_serial, created_by
) VALUES (
'00000000-0000-4000-8000-000000001505', 'hashboard', 'HB-005',
'MicroBT', 'SER-005', '00000000-0000-0000-0000-000000001501'
);
INSERT INTO repair_events (id, repair_id, sequence, event_type, created_by, payload)
VALUES (
'00000000-0000-0000-0000-000000001506',
'00000000-0000-0000-0000-000000001503', 2, 'REPAIR_PART_RECORDED',
'00000000-0000-0000-0000-000000001501',
'{"repair_part_id":"00000000-0000-4000-8000-000000001507","entry_kind":"tracked_component","component_id":"00000000-0000-4000-8000-000000001505","component_type":"hashboard","component_asset_id":"HB-005","component_manufacturer":"MicroBT","component_model":null,"component_manufacturer_serial":"SER-005","notes":null}'
);
INSERT INTO repair_parts (
id, repair_id, component_id, component_type_snapshot,
component_asset_id_snapshot, component_manufacturer_snapshot,
component_model_snapshot, component_manufacturer_serial_snapshot,
recorded_by, recorded_at, recorded_event_id
) SELECT
'00000000-0000-4000-8000-000000001507',
'00000000-0000-0000-0000-000000001503',
'00000000-0000-4000-8000-000000001505', 'hashboard', 'HB-005',
'MicroBT', NULL, 'SER-005', '00000000-0000-0000-0000-000000001501',
created_at, id FROM repair_events
WHERE id = '00000000-0000-0000-0000-000000001506';
UPDATE components SET manufacturer = 'Updated Manufacturer'
WHERE id = '00000000-0000-4000-8000-000000001505';
DO $$
BEGIN
IF (SELECT component_manufacturer_snapshot FROM repair_parts
WHERE id = '00000000-0000-4000-8000-000000001507') <> 'MicroBT' THEN
RAISE EXCEPTION 'Tracked snapshot changed with live metadata';
END IF;
BEGIN
INSERT INTO repair_parts (
repair_id, consumable_description, quantity, unit, component_id,
recorded_by, recorded_event_id
) VALUES (
'00000000-0000-0000-0000-000000001503', 'invalid', 1, 'each',
'00000000-0000-4000-8000-000000001505',
'00000000-0000-0000-0000-000000001501',
'00000000-0000-0000-0000-000000001506'
);
RAISE EXCEPTION 'XOR violation was accepted';
EXCEPTION WHEN check_violation OR raise_exception THEN NULL;
END;
END $$;
INSERT INTO repair_events (id, repair_id, sequence, event_type, created_by, payload)
VALUES (
'00000000-0000-0000-0000-000000001508',
'00000000-0000-0000-0000-000000001503', 3, 'REPAIR_PART_VOIDED',
'00000000-0000-0000-0000-000000001501',
'{"repair_part_id":"00000000-0000-4000-8000-000000001507","reason":"Wrong repair"}'
);
UPDATE repair_parts SET
voided_by = '00000000-0000-0000-0000-000000001501',
voided_at = (SELECT created_at FROM repair_events WHERE id = '00000000-0000-0000-0000-000000001508'),
void_reason = 'Wrong repair',
void_event_id = '00000000-0000-0000-0000-000000001508'
WHERE id = '00000000-0000-4000-8000-000000001507';
DO $$
BEGIN
BEGIN
UPDATE repair_parts SET void_reason = 'Changed'
WHERE id = '00000000-0000-4000-8000-000000001507';
RAISE EXCEPTION 'Second void mutation was accepted';
EXCEPTION WHEN raise_exception THEN NULL;
END;
BEGIN
DELETE FROM repair_parts WHERE id = '00000000-0000-4000-8000-000000001507';
RAISE EXCEPTION 'Repair-part deletion was accepted';
EXCEPTION WHEN raise_exception THEN NULL;
END;
BEGIN
DELETE FROM repair_events WHERE id = '00000000-0000-0000-0000-000000001508';
RAISE EXCEPTION 'Repair-event deletion was accepted';
EXCEPTION WHEN raise_exception THEN NULL;
END;
END $$;
SQL
if downgrade_output="$(run_alembic downgrade 0013_control_board_replacement 2>&1)"; then
echo "Task 005 downgrade unexpectedly destroyed history" >&2
exit 1
fi
[[ "${downgrade_output}" == *"Cannot downgrade Repair Parts Tracking"* ]]
[[ "$(run_alembic current 2>/dev/null)" == *"0014_repair_parts_tracking"* ]]
echo "Testing empty database through head..."
"${compose[@]}" exec -T postgres sh -ec \
'createdb -U "$POSTGRES_USER" minerops_task005_empty'
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_task005_empty \
backend alembic upgrade head
empty_revision="$(
"${compose[@]}" exec -T postgres sh -ec \
'psql -U "$POSTGRES_USER" -d minerops_task005_empty -Atc "SELECT version_num FROM alembic_version"'
)"
[[ "${empty_revision}" == "0014_repair_parts_tracking" ]]
echo "Repair Parts Tracking migration tests passed."
+2
View File
@@ -9,6 +9,8 @@ WITH expected(table_name) AS (
('miner_asset_id_history'),
('repairs'),
('repair_events'),
('components'),
('repair_parts'),
('miner_control_board_installations'),
('miner_mac_ownerships'),
('miner_status_snapshots'),