diff --git a/backend/alembic/versions/0014_repair_parts_tracking.py b/backend/alembic/versions/0014_repair_parts_tracking.py new file mode 100644 index 0000000..1ca5b6b --- /dev/null +++ b/backend/alembic/versions/0014_repair_parts_tracking.py @@ -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'); + """ + ) diff --git a/backend/app/components/__init__.py b/backend/app/components/__init__.py new file mode 100644 index 0000000..5618361 --- /dev/null +++ b/backend/app/components/__init__.py @@ -0,0 +1 @@ +"""Minimal durable component registry for repair-part references.""" diff --git a/backend/app/components/dependencies.py b/backend/app/components/dependencies.py new file mode 100644 index 0000000..03a8dc4 --- /dev/null +++ b/backend/app/components/dependencies.py @@ -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()) diff --git a/backend/app/components/exceptions.py b/backend/app/components/exceptions.py new file mode 100644 index 0000000..9f3c90a --- /dev/null +++ b/backend/app/components/exceptions.py @@ -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") diff --git a/backend/app/components/models.py b/backend/app/components/models.py new file mode 100644 index 0000000..d6513c5 --- /dev/null +++ b/backend/app/components/models.py @@ -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 diff --git a/backend/app/components/repository.py b/backend/app/components/repository.py new file mode 100644 index 0000000..7827848 --- /dev/null +++ b/backend/app/components/repository.py @@ -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 diff --git a/backend/app/components/router.py b/backend/app/components/router.py new file mode 100644 index 0000000..4d9c90e --- /dev/null +++ b/backend/app/components/router.py @@ -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) diff --git a/backend/app/components/service.py b/backend/app/components/service.py new file mode 100644 index 0000000..29d47aa --- /dev/null +++ b/backend/app/components/service.py @@ -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) diff --git a/backend/app/identity.py b/backend/app/identity.py index 4a734e0..c22edae 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -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/"): diff --git a/backend/app/main.py b/backend/app/main.py index e61915d..1d25b4e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/repairs/exceptions.py b/backend/app/repairs/exceptions.py index 03142b4..2e73dd9 100644 --- a/backend/app/repairs/exceptions.py +++ b/backend/app/repairs/exceptions.py @@ -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}") diff --git a/backend/app/repairs/models.py b/backend/app/repairs/models.py index 1d4b990..55e8f8c 100644 --- a/backend/app/repairs/models.py +++ b/backend/app/repairs/models.py @@ -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 diff --git a/backend/app/repairs/repository.py b/backend/app/repairs/repository.py index 3b7f35f..5e695af 100644 --- a/backend/app/repairs/repository.py +++ b/backend/app/repairs/repository.py @@ -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: diff --git a/backend/app/repairs/router.py b/backend/app/repairs/router.py index 7f20261..6da37d1 100644 --- a/backend/app/repairs/router.py +++ b/backend/app/repairs/router.py @@ -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, diff --git a/backend/app/repairs/service.py b/backend/app/repairs/service.py index 3beb1cb..f4eef43 100644 --- a/backend/app/repairs/service.py +++ b/backend/app/repairs/service.py @@ -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: diff --git a/backend/tests/test_control_board_replacement.py b/backend/tests/test_control_board_replacement.py index f571c99..c246f01 100644 --- a/backend/tests/test_control_board_replacement.py +++ b/backend/tests/test_control_board_replacement.py @@ -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") diff --git a/backend/tests/test_repair_parts_tracking.py b/backend/tests/test_repair_parts_tracking.py new file mode 100644 index 0000000..0fc4e4e --- /dev/null +++ b/backend/tests/test_repair_parts_tracking.py @@ -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() diff --git a/backend/tests/test_repair_timeline.py b/backend/tests/test_repair_timeline.py index e0ad5c9..ea23425 100644 --- a/backend/tests/test_repair_timeline.py +++ b/backend/tests/test_repair_timeline.py @@ -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", diff --git a/backend/tests/test_repairs_domain.py b/backend/tests/test_repairs_domain.py index cb472a5..bd2457d 100644 --- a/backend/tests/test_repairs_domain.py +++ b/backend/tests/test_repairs_domain.py @@ -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") diff --git a/backend/tests/test_return_to_service.py b/backend/tests/test_return_to_service.py index ed78dcb..251123c 100644 --- a/backend/tests/test_return_to_service.py +++ b/backend/tests/test_return_to_service.py @@ -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, diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index 323a8a4..832d690 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -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 \ diff --git a/scripts/test-repair-parts-tracking-migration.sh b/scripts/test-repair-parts-tracking-migration.sh new file mode 100755 index 0000000..58a5fc7 --- /dev/null +++ b/scripts/test-repair-parts-tracking-migration.sh @@ -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." diff --git a/sql/verify-development-schema.sql b/sql/verify-development-schema.sql index 5887ce5..f9a9546 100644 --- a/sql/verify-development-schema.sql +++ b/sql/verify-development-schema.sql @@ -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'),