feat: add begin repair workflow
This commit is contained in:
1 parent
d738b8e27e
commit
f6a2512e23
21 files changed
+3108
-5
No files matched your search
@@ -0,0 +1,322 @@
|
|||||||
|
"""add begin repair workflow
|
||||||
|
|
||||||
|
Revision ID: 0010_begin_repair
|
||||||
|
Revises: 0009_inventory_asset_id_history
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0010_begin_repair"
|
||||||
|
down_revision: Union[str, None] = "0009_inventory_asset_id_history"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
REPAIR_STATUSES = (
|
||||||
|
"OPEN",
|
||||||
|
"IN_PROGRESS",
|
||||||
|
"WAITING_FOR_PARTS",
|
||||||
|
"READY_FOR_TEST",
|
||||||
|
"COMPLETED",
|
||||||
|
"CANCELLED",
|
||||||
|
)
|
||||||
|
ACTIVE_REPAIR_STATUSES = (
|
||||||
|
"OPEN",
|
||||||
|
"IN_PROGRESS",
|
||||||
|
"WAITING_FOR_PARTS",
|
||||||
|
"READY_FOR_TEST",
|
||||||
|
)
|
||||||
|
REPAIR_REASONS = (
|
||||||
|
"HARDWARE_FAILURE",
|
||||||
|
"LOW_HASHRATE",
|
||||||
|
"CONTROL_BOARD",
|
||||||
|
"HASHBOARD",
|
||||||
|
"PSU",
|
||||||
|
"FAN",
|
||||||
|
"OVERHEATING",
|
||||||
|
"NETWORKING",
|
||||||
|
"PREVENTIVE_MAINTENANCE",
|
||||||
|
"INSPECTION",
|
||||||
|
"OTHER",
|
||||||
|
)
|
||||||
|
REPAIR_EVENT_TYPES = ("REPAIR_STARTED", "INITIAL_NOTE")
|
||||||
|
|
||||||
|
|
||||||
|
def _quoted(values: Sequence[str]) -> str:
|
||||||
|
return ", ".join(f"'{value}'" for value in values)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
op.drop_constraint("ck_miners_lifecycle_status", "miners", type_="check")
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_miners_lifecycle_status",
|
||||||
|
"miners",
|
||||||
|
"lifecycle_status IN ('active', 'spare', 'retired', 'in_repair')",
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
INSERT INTO permissions (key, description)
|
||||||
|
VALUES ('repairs.create', 'Begin repairs for miner assets')
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET description = EXCLUDED.description
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT roles.id, permissions.id
|
||||||
|
FROM roles
|
||||||
|
JOIN permissions ON permissions.key = 'repairs.create'
|
||||||
|
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"repairs",
|
||||||
|
sa.Column(
|
||||||
|
"id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
server_default=sa.text("gen_random_uuid()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"miner_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("miners.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.Text(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'OPEN'"),
|
||||||
|
),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"opened_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"opened_by",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"completed_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"completed_by",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="RESTRICT"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
f"status IN ({_quoted(REPAIR_STATUSES)})",
|
||||||
|
name="ck_repairs_status",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
f"reason IN ({_quoted(REPAIR_REASONS)})",
|
||||||
|
name="ck_repairs_reason",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"(completed_at IS NULL AND completed_by IS NULL) OR "
|
||||||
|
"(completed_at IS NOT NULL AND completed_by IS NOT NULL)",
|
||||||
|
name="ck_repairs_completion_pair",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"(status = 'COMPLETED') = (completed_at IS NOT NULL)",
|
||||||
|
name="ck_repairs_completed_state",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_repairs_miner_opened_at",
|
||||||
|
"repairs",
|
||||||
|
["miner_id", "opened_at", "id"],
|
||||||
|
)
|
||||||
|
op.create_index("idx_repairs_opened_by", "repairs", ["opened_by"])
|
||||||
|
op.create_index("idx_repairs_completed_by", "repairs", ["completed_by"])
|
||||||
|
op.create_index(
|
||||||
|
"uq_repairs_one_active_per_miner",
|
||||||
|
"repairs",
|
||||||
|
["miner_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text(
|
||||||
|
f"status IN ({_quoted(ACTIVE_REPAIR_STATUSES)})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"repair_events",
|
||||||
|
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("sequence", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("event_type", sa.Text(), nullable=False),
|
||||||
|
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.Column(
|
||||||
|
"payload",
|
||||||
|
postgresql.JSONB(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
f"event_type IN ({_quoted(REPAIR_EVENT_TYPES)})",
|
||||||
|
name="ck_repair_events_type",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"sequence > 0",
|
||||||
|
name="ck_repair_events_sequence_positive",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"jsonb_typeof(payload) = 'object'",
|
||||||
|
name="ck_repair_events_payload_object",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"("
|
||||||
|
"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') <> ''"
|
||||||
|
")",
|
||||||
|
name="ck_repair_events_payload_shape",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"repair_id",
|
||||||
|
"sequence",
|
||||||
|
name="uq_repair_events_repair_sequence",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_repair_events_repair_created",
|
||||||
|
"repair_events",
|
||||||
|
["repair_id", "created_at", "sequence", "id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_repair_events_created_by",
|
||||||
|
"repair_events",
|
||||||
|
["created_by"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
f"""
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
invalid_miner_ids TEXT;
|
||||||
|
BEGIN
|
||||||
|
SELECT string_agg(miner.id::text, ', ' ORDER BY miner.id::text)
|
||||||
|
INTO invalid_miner_ids
|
||||||
|
FROM miners AS miner
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT count(*) AS started_event_count
|
||||||
|
FROM repairs AS repair
|
||||||
|
JOIN repair_events AS event
|
||||||
|
ON event.repair_id = repair.id
|
||||||
|
AND event.event_type = 'REPAIR_STARTED'
|
||||||
|
WHERE repair.miner_id = miner.id
|
||||||
|
AND repair.status IN ({_quoted(ACTIVE_REPAIR_STATUSES)})
|
||||||
|
) AS history ON TRUE
|
||||||
|
WHERE miner.lifecycle_status = 'in_repair'
|
||||||
|
AND history.started_event_count <> 1;
|
||||||
|
|
||||||
|
IF invalid_miner_ids IS NOT NULL THEN
|
||||||
|
RAISE EXCEPTION USING
|
||||||
|
MESSAGE = format(
|
||||||
|
'Cannot downgrade Begin Repair: miners %s do not have exactly one active REPAIR_STARTED event',
|
||||||
|
invalid_miner_ids
|
||||||
|
),
|
||||||
|
HINT = 'Repair the affected lifecycle history before downgrading; MinerOps will not invent a prior lifecycle value.';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
f"""
|
||||||
|
UPDATE miners AS miner
|
||||||
|
SET lifecycle_status = history.from_lifecycle,
|
||||||
|
updated_at = now()
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
repair.miner_id,
|
||||||
|
event.payload ->> 'from_lifecycle' AS from_lifecycle
|
||||||
|
FROM repairs AS repair
|
||||||
|
JOIN repair_events AS event
|
||||||
|
ON event.repair_id = repair.id
|
||||||
|
AND event.event_type = 'REPAIR_STARTED'
|
||||||
|
WHERE repair.status IN ({_quoted(ACTIVE_REPAIR_STATUSES)})
|
||||||
|
) AS history
|
||||||
|
WHERE miner.id = history.miner_id
|
||||||
|
AND miner.lifecycle_status = 'in_repair'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_index("idx_repair_events_created_by", table_name="repair_events")
|
||||||
|
op.drop_index("idx_repair_events_repair_created", table_name="repair_events")
|
||||||
|
op.drop_table("repair_events")
|
||||||
|
|
||||||
|
op.drop_index("uq_repairs_one_active_per_miner", table_name="repairs")
|
||||||
|
op.drop_index("idx_repairs_completed_by", table_name="repairs")
|
||||||
|
op.drop_index("idx_repairs_opened_by", table_name="repairs")
|
||||||
|
op.drop_index("idx_repairs_miner_opened_at", table_name="repairs")
|
||||||
|
op.drop_table("repairs")
|
||||||
|
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
DELETE FROM role_permissions
|
||||||
|
WHERE permission_id = (
|
||||||
|
SELECT id FROM permissions WHERE key = 'repairs.create'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"DELETE FROM permissions WHERE key = 'repairs.create'"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_constraint("ck_miners_lifecycle_status", "miners", type_="check")
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_miners_lifecycle_status",
|
||||||
|
"miners",
|
||||||
|
"lifecycle_status IN ('active', 'spare', 'retired')",
|
||||||
|
)
|
||||||
@@ -674,6 +674,8 @@ def machine_agent_request(path: str) -> bool:
|
|||||||
def required_api_permission(method: str, path: str) -> str | None:
|
def required_api_permission(method: str, path: str) -> str | None:
|
||||||
if path.startswith("/api/admin/"):
|
if path.startswith("/api/admin/"):
|
||||||
return "users.manage"
|
return "users.manage"
|
||||||
|
if method == "POST" and path == "/api/repairs":
|
||||||
|
return "repairs.create"
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
if path.startswith("/api/system/"):
|
if path.startswith("/api/system/"):
|
||||||
return "system_health.view"
|
return "system_health.view"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from uuid import UUID
|
|||||||
from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress
|
from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress
|
||||||
|
|
||||||
|
|
||||||
LifecycleStatus = Literal["active", "spare", "retired"]
|
LifecycleStatus = Literal["active", "spare", "retired", "in_repair"]
|
||||||
|
|
||||||
|
|
||||||
class InventoryTag(BaseModel):
|
class InventoryTag(BaseModel):
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ from app.inventory.models import (
|
|||||||
from app.inventory.repository import InventoryRepository
|
from app.inventory.repository import InventoryRepository
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_LIFECYCLE_STATUSES = frozenset({"active", "spare", "retired"})
|
ALLOWED_LIFECYCLE_STATUSES = frozenset(
|
||||||
|
{"active", "spare", "retired", "in_repair"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InventoryService:
|
class InventoryService:
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ from app.identity import (
|
|||||||
router as identity_router,
|
router as identity_router,
|
||||||
)
|
)
|
||||||
from app.inventory.router import router as inventory_router
|
from app.inventory.router import router as inventory_router
|
||||||
|
from app.repairs.router import router as repairs_router
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title=settings.app_name)
|
app = FastAPI(title=settings.app_name)
|
||||||
app.include_router(identity_router)
|
app.include_router(identity_router)
|
||||||
app.include_router(inventory_router)
|
app.include_router(inventory_router)
|
||||||
|
app.include_router(repairs_router)
|
||||||
install_identity_middleware(app)
|
install_identity_middleware(app)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Repair workflow models, persistence, and business services."""
|
||||||
|
|
||||||
|
from app.repairs.exceptions import (
|
||||||
|
ActiveRepairConflict,
|
||||||
|
IncompatibleMinerLifecycle,
|
||||||
|
InconsistentRepairState,
|
||||||
|
RepairActorRequired,
|
||||||
|
RepairConflict,
|
||||||
|
RepairError,
|
||||||
|
RepairMinerNotFound,
|
||||||
|
RepairPersistenceError,
|
||||||
|
)
|
||||||
|
from app.repairs.models import (
|
||||||
|
BeginRepairRequest,
|
||||||
|
BeginRepairResult,
|
||||||
|
RepairReason,
|
||||||
|
RepairStatus,
|
||||||
|
)
|
||||||
|
from app.repairs.repository import RepairRepository
|
||||||
|
from app.repairs.service import RepairService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ActiveRepairConflict",
|
||||||
|
"BeginRepairRequest",
|
||||||
|
"BeginRepairResult",
|
||||||
|
"IncompatibleMinerLifecycle",
|
||||||
|
"InconsistentRepairState",
|
||||||
|
"RepairActorRequired",
|
||||||
|
"RepairConflict",
|
||||||
|
"RepairError",
|
||||||
|
"RepairMinerNotFound",
|
||||||
|
"RepairPersistenceError",
|
||||||
|
"RepairReason",
|
||||||
|
"RepairRepository",
|
||||||
|
"RepairService",
|
||||||
|
"RepairStatus",
|
||||||
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Dependency construction for the Repairs domain."""
|
||||||
|
|
||||||
|
from app.repairs.repository import RepairRepository
|
||||||
|
from app.repairs.service import RepairService
|
||||||
|
|
||||||
|
|
||||||
|
def get_repair_service() -> RepairService:
|
||||||
|
return RepairService(RepairRepository())
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Domain exceptions for repair workflows."""
|
||||||
|
|
||||||
|
|
||||||
|
class RepairError(Exception):
|
||||||
|
"""Base exception for repair operations."""
|
||||||
|
|
||||||
|
|
||||||
|
class RepairMinerNotFound(RepairError):
|
||||||
|
"""Raised when the requested miner does not exist."""
|
||||||
|
|
||||||
|
def __init__(self, miner_id: object):
|
||||||
|
self.miner_id = miner_id
|
||||||
|
super().__init__(f"Miner not found: {miner_id}")
|
||||||
|
|
||||||
|
|
||||||
|
class RepairConflict(RepairError):
|
||||||
|
"""Base exception for a conflicting repair state."""
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveRepairConflict(RepairConflict):
|
||||||
|
"""Raised when a miner already has an active repair."""
|
||||||
|
|
||||||
|
def __init__(self, miner_id: object):
|
||||||
|
self.miner_id = miner_id
|
||||||
|
super().__init__(f"Miner already has an active repair: {miner_id}")
|
||||||
|
|
||||||
|
|
||||||
|
class IncompatibleMinerLifecycle(RepairConflict):
|
||||||
|
"""Raised when the miner lifecycle cannot enter repair."""
|
||||||
|
|
||||||
|
def __init__(self, miner_id: object, lifecycle_status: object):
|
||||||
|
self.miner_id = miner_id
|
||||||
|
self.lifecycle_status = lifecycle_status
|
||||||
|
super().__init__(
|
||||||
|
"Miner lifecycle is incompatible with beginning repair: "
|
||||||
|
f"{lifecycle_status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InconsistentRepairState(RepairConflict):
|
||||||
|
"""Raised when lifecycle state and repair records disagree."""
|
||||||
|
|
||||||
|
def __init__(self, miner_id: object):
|
||||||
|
self.miner_id = miner_id
|
||||||
|
super().__init__(
|
||||||
|
"Miner is marked in repair but has no active repair record"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RepairActorRequired(RepairError):
|
||||||
|
"""Raised when an operational workflow lacks an authenticated actor."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("An authenticated actor is required to begin repair")
|
||||||
|
|
||||||
|
|
||||||
|
class RepairPersistenceError(RepairError):
|
||||||
|
"""Raised when a required repair persistence operation fails."""
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Typed contracts for the Repairs domain."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
RepairReason = Literal[
|
||||||
|
"HARDWARE_FAILURE",
|
||||||
|
"LOW_HASHRATE",
|
||||||
|
"CONTROL_BOARD",
|
||||||
|
"HASHBOARD",
|
||||||
|
"PSU",
|
||||||
|
"FAN",
|
||||||
|
"OVERHEATING",
|
||||||
|
"NETWORKING",
|
||||||
|
"PREVENTIVE_MAINTENANCE",
|
||||||
|
"INSPECTION",
|
||||||
|
"OTHER",
|
||||||
|
]
|
||||||
|
RepairStatus = Literal[
|
||||||
|
"OPEN",
|
||||||
|
"IN_PROGRESS",
|
||||||
|
"WAITING_FOR_PARTS",
|
||||||
|
"READY_FOR_TEST",
|
||||||
|
"COMPLETED",
|
||||||
|
"CANCELLED",
|
||||||
|
]
|
||||||
|
RepairEventType = Literal["REPAIR_STARTED", "INITIAL_NOTE"]
|
||||||
|
RepairEligibleLifecycle = Literal["active", "spare"]
|
||||||
|
|
||||||
|
|
||||||
|
class BeginRepairRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
miner_id: UUID
|
||||||
|
reason: RepairReason
|
||||||
|
notes: str | None = Field(default=None, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class RepairStartedPayload(BaseModel):
|
||||||
|
reason: RepairReason
|
||||||
|
from_lifecycle: RepairEligibleLifecycle
|
||||||
|
to_lifecycle: Literal["in_repair"] = "in_repair"
|
||||||
|
|
||||||
|
|
||||||
|
class InitialRepairNotePayload(BaseModel):
|
||||||
|
note: str = Field(min_length=1, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class BeginRepairResult(BaseModel):
|
||||||
|
repair_id: UUID
|
||||||
|
miner_id: UUID
|
||||||
|
status: Literal["OPEN"]
|
||||||
|
reason: RepairReason
|
||||||
|
opened_at: datetime
|
||||||
|
opened_by: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class BeginRepairResponse(BaseModel):
|
||||||
|
data: BeginRepairResult
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""PostgreSQL persistence for repair workflows."""
|
||||||
|
|
||||||
|
from collections.abc import Callable, Iterator, Mapping
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.errors import UniqueViolation
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.repairs.exceptions import (
|
||||||
|
ActiveRepairConflict,
|
||||||
|
RepairPersistenceError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||||
|
ACTIVE_REPAIR_STATUSES = (
|
||||||
|
"OPEN",
|
||||||
|
"IN_PROGRESS",
|
||||||
|
"WAITING_FOR_PARTS",
|
||||||
|
"READY_FOR_TEST",
|
||||||
|
)
|
||||||
|
ACTIVE_REPAIR_CONSTRAINT = "uq_repairs_one_active_per_miner"
|
||||||
|
|
||||||
|
|
||||||
|
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 RepairRepository:
|
||||||
|
"""Persistence boundary for the atomic Begin Repair workflow."""
|
||||||
|
|
||||||
|
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["RepairRepository"]:
|
||||||
|
if self._connection is not None:
|
||||||
|
yield self
|
||||||
|
return
|
||||||
|
|
||||||
|
connection = self._connection_factory()
|
||||||
|
transaction_repository = RepairRepository(
|
||||||
|
self._connection_factory,
|
||||||
|
connection=connection,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield transaction_repository
|
||||||
|
connection.commit()
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _connection_scope(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
write: bool = False,
|
||||||
|
) -> Iterator[psycopg.Connection]:
|
||||||
|
if self._connection is not None:
|
||||||
|
yield self._connection
|
||||||
|
return
|
||||||
|
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
yield connection
|
||||||
|
if write:
|
||||||
|
connection.commit()
|
||||||
|
except Exception:
|
||||||
|
if write:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def lock_miner_for_update(self, miner_id: UUID) -> dict | None:
|
||||||
|
with self._connection_scope() as connection:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, lifecycle_status
|
||||||
|
FROM miners
|
||||||
|
WHERE id = %s
|
||||||
|
FOR UPDATE
|
||||||
|
""",
|
||||||
|
(miner_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_active_repair(self, miner_id: UUID) -> dict | None:
|
||||||
|
with self._connection_scope() as connection:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
miner_id,
|
||||||
|
status,
|
||||||
|
reason,
|
||||||
|
opened_at,
|
||||||
|
opened_by,
|
||||||
|
completed_at,
|
||||||
|
completed_by
|
||||||
|
FROM repairs
|
||||||
|
WHERE miner_id = %s
|
||||||
|
AND status = ANY(%s)
|
||||||
|
ORDER BY opened_at, id
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(miner_id, list(ACTIVE_REPAIR_STATUSES)),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def create_repair(
|
||||||
|
self,
|
||||||
|
miner_id: UUID,
|
||||||
|
reason: str,
|
||||||
|
opened_by: UUID,
|
||||||
|
) -> dict:
|
||||||
|
with self._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO repairs (miner_id, reason, opened_by)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
miner_id,
|
||||||
|
status,
|
||||||
|
reason,
|
||||||
|
opened_at,
|
||||||
|
opened_by,
|
||||||
|
completed_at,
|
||||||
|
completed_by
|
||||||
|
""",
|
||||||
|
(miner_id, reason, opened_by),
|
||||||
|
)
|
||||||
|
except UniqueViolation as exc:
|
||||||
|
if exc.diag.constraint_name == ACTIVE_REPAIR_CONSTRAINT:
|
||||||
|
raise ActiveRepairConflict(miner_id) from exc
|
||||||
|
raise
|
||||||
|
return dict(cur.fetchone())
|
||||||
|
|
||||||
|
def create_event(
|
||||||
|
self,
|
||||||
|
repair_id: UUID,
|
||||||
|
*,
|
||||||
|
sequence: int,
|
||||||
|
event_type: str,
|
||||||
|
created_by: UUID,
|
||||||
|
payload: 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_events (
|
||||||
|
repair_id,
|
||||||
|
sequence,
|
||||||
|
event_type,
|
||||||
|
created_by,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
repair_id,
|
||||||
|
sequence,
|
||||||
|
event_type,
|
||||||
|
created_at,
|
||||||
|
created_by,
|
||||||
|
payload
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
repair_id,
|
||||||
|
sequence,
|
||||||
|
event_type,
|
||||||
|
created_by,
|
||||||
|
Jsonb(dict(payload)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return dict(cur.fetchone())
|
||||||
|
|
||||||
|
def set_miner_lifecycle(
|
||||||
|
self,
|
||||||
|
miner_id: UUID,
|
||||||
|
lifecycle_status: str,
|
||||||
|
) -> None:
|
||||||
|
with self._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE miners
|
||||||
|
SET lifecycle_status = %s,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(lifecycle_status, miner_id),
|
||||||
|
)
|
||||||
|
if cur.rowcount != 1:
|
||||||
|
raise RepairPersistenceError(
|
||||||
|
"Miner lifecycle transition did not update one record"
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""HTTP mapping for explicit repair workflows."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from app.identity import require_permission
|
||||||
|
from app.repairs.dependencies import get_repair_service
|
||||||
|
from app.repairs.exceptions import (
|
||||||
|
RepairActorRequired,
|
||||||
|
RepairConflict,
|
||||||
|
RepairMinerNotFound,
|
||||||
|
)
|
||||||
|
from app.repairs.models import BeginRepairRequest, BeginRepairResponse
|
||||||
|
from app.repairs.service import RepairService
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/repairs", tags=["repairs"])
|
||||||
|
require_repair_create = require_permission("repairs.create")
|
||||||
|
|
||||||
|
RepairServiceDependency = Annotated[
|
||||||
|
RepairService,
|
||||||
|
Depends(get_repair_service),
|
||||||
|
]
|
||||||
|
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=BeginRepairResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def begin_repair(
|
||||||
|
request: BeginRepairRequest,
|
||||||
|
service: RepairServiceDependency,
|
||||||
|
current_user: RepairCreateUser,
|
||||||
|
) -> BeginRepairResponse:
|
||||||
|
try:
|
||||||
|
repair = service.begin_repair(
|
||||||
|
request,
|
||||||
|
actor_user_id=UUID(str(current_user["id"])),
|
||||||
|
)
|
||||||
|
except RepairMinerNotFound as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Miner not found",
|
||||||
|
) from exc
|
||||||
|
except RepairConflict as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
except RepairActorRequired as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return BeginRepairResponse(data=repair)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Business rules and orchestration for repair workflows."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.repairs.exceptions import (
|
||||||
|
ActiveRepairConflict,
|
||||||
|
IncompatibleMinerLifecycle,
|
||||||
|
InconsistentRepairState,
|
||||||
|
RepairActorRequired,
|
||||||
|
RepairMinerNotFound,
|
||||||
|
)
|
||||||
|
from app.repairs.models import (
|
||||||
|
BeginRepairRequest,
|
||||||
|
BeginRepairResult,
|
||||||
|
InitialRepairNotePayload,
|
||||||
|
RepairStartedPayload,
|
||||||
|
)
|
||||||
|
from app.repairs.repository import RepairRepository
|
||||||
|
|
||||||
|
|
||||||
|
ELIGIBLE_LIFECYCLE_STATUSES = frozenset({"active", "spare"})
|
||||||
|
REPAIR_LIFECYCLE_STATUS = "in_repair"
|
||||||
|
|
||||||
|
|
||||||
|
class RepairService:
|
||||||
|
"""Explicit operational repair workflows independent of FastAPI."""
|
||||||
|
|
||||||
|
def __init__(self, repository: RepairRepository):
|
||||||
|
self.repository = repository
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_note(note: str | None) -> str | None:
|
||||||
|
if note is None:
|
||||||
|
return None
|
||||||
|
normalized = str(note).strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
def begin_repair(
|
||||||
|
self,
|
||||||
|
request: BeginRepairRequest,
|
||||||
|
*,
|
||||||
|
actor_user_id: UUID | None,
|
||||||
|
) -> BeginRepairResult:
|
||||||
|
if actor_user_id is None:
|
||||||
|
raise RepairActorRequired()
|
||||||
|
|
||||||
|
note = self.normalize_note(request.notes)
|
||||||
|
|
||||||
|
with self.repository.transaction() as repository:
|
||||||
|
miner = repository.lock_miner_for_update(request.miner_id)
|
||||||
|
if not miner:
|
||||||
|
raise RepairMinerNotFound(request.miner_id)
|
||||||
|
|
||||||
|
lifecycle_status = str(miner["lifecycle_status"])
|
||||||
|
active_repair = repository.get_active_repair(request.miner_id)
|
||||||
|
if active_repair:
|
||||||
|
raise ActiveRepairConflict(request.miner_id)
|
||||||
|
if lifecycle_status == REPAIR_LIFECYCLE_STATUS:
|
||||||
|
raise InconsistentRepairState(request.miner_id)
|
||||||
|
if lifecycle_status not in ELIGIBLE_LIFECYCLE_STATUSES:
|
||||||
|
raise IncompatibleMinerLifecycle(
|
||||||
|
request.miner_id,
|
||||||
|
lifecycle_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
repair = repository.create_repair(
|
||||||
|
request.miner_id,
|
||||||
|
request.reason,
|
||||||
|
actor_user_id,
|
||||||
|
)
|
||||||
|
started_payload = RepairStartedPayload(
|
||||||
|
reason=request.reason,
|
||||||
|
from_lifecycle=lifecycle_status,
|
||||||
|
)
|
||||||
|
repository.create_event(
|
||||||
|
repair["id"],
|
||||||
|
sequence=1,
|
||||||
|
event_type="REPAIR_STARTED",
|
||||||
|
created_by=actor_user_id,
|
||||||
|
payload=started_payload.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
if note is not None:
|
||||||
|
note_payload = InitialRepairNotePayload(note=note)
|
||||||
|
repository.create_event(
|
||||||
|
repair["id"],
|
||||||
|
sequence=2,
|
||||||
|
event_type="INITIAL_NOTE",
|
||||||
|
created_by=actor_user_id,
|
||||||
|
payload=note_payload.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
repository.set_miner_lifecycle(
|
||||||
|
request.miner_id,
|
||||||
|
REPAIR_LIFECYCLE_STATUS,
|
||||||
|
)
|
||||||
|
|
||||||
|
return BeginRepairResult(
|
||||||
|
repair_id=repair["id"],
|
||||||
|
miner_id=repair["miner_id"],
|
||||||
|
status=repair["status"],
|
||||||
|
reason=repair["reason"],
|
||||||
|
opened_at=repair["opened_at"],
|
||||||
|
opened_by=repair["opened_by"],
|
||||||
|
)
|
||||||
@@ -0,0 +1,737 @@
|
|||||||
|
import ast
|
||||||
|
import unittest
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Barrier
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from psycopg.errors import RaiseException
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from app.repairs import (
|
||||||
|
ActiveRepairConflict,
|
||||||
|
BeginRepairRequest,
|
||||||
|
IncompatibleMinerLifecycle,
|
||||||
|
InconsistentRepairState,
|
||||||
|
RepairActorRequired,
|
||||||
|
RepairMinerNotFound,
|
||||||
|
RepairRepository,
|
||||||
|
RepairService,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def repair_row(miner_id, actor_user_id, reason="CONTROL_BOARD"):
|
||||||
|
return {
|
||||||
|
"id": uuid4(),
|
||||||
|
"miner_id": miner_id,
|
||||||
|
"status": "OPEN",
|
||||||
|
"reason": reason,
|
||||||
|
"opened_at": datetime.now(timezone.utc),
|
||||||
|
"opened_by": actor_user_id,
|
||||||
|
"completed_at": None,
|
||||||
|
"completed_by": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRepairRepository:
|
||||||
|
def __init__(self):
|
||||||
|
self.miners = {}
|
||||||
|
self.repairs = []
|
||||||
|
self.events = []
|
||||||
|
self.calls = []
|
||||||
|
self.create_error = None
|
||||||
|
self.event_error = None
|
||||||
|
self.lifecycle_error = None
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def transaction(self):
|
||||||
|
self.calls.append("transaction")
|
||||||
|
snapshot = deepcopy((self.miners, self.repairs, self.events))
|
||||||
|
try:
|
||||||
|
yield self
|
||||||
|
except Exception:
|
||||||
|
self.miners, self.repairs, self.events = snapshot
|
||||||
|
raise
|
||||||
|
|
||||||
|
def lock_miner_for_update(self, miner_id):
|
||||||
|
self.calls.append("lock_miner")
|
||||||
|
return self.miners.get(miner_id)
|
||||||
|
|
||||||
|
def get_active_repair(self, miner_id):
|
||||||
|
self.calls.append("get_active_repair")
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
repair
|
||||||
|
for repair in self.repairs
|
||||||
|
if repair["miner_id"] == miner_id
|
||||||
|
and repair["status"]
|
||||||
|
in {
|
||||||
|
"OPEN",
|
||||||
|
"IN_PROGRESS",
|
||||||
|
"WAITING_FOR_PARTS",
|
||||||
|
"READY_FOR_TEST",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_repair(self, miner_id, reason, opened_by):
|
||||||
|
self.calls.append("create_repair")
|
||||||
|
if self.create_error is not None:
|
||||||
|
raise self.create_error
|
||||||
|
row = repair_row(miner_id, opened_by, reason)
|
||||||
|
self.repairs.append(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def create_event(
|
||||||
|
self,
|
||||||
|
repair_id,
|
||||||
|
*,
|
||||||
|
sequence,
|
||||||
|
event_type,
|
||||||
|
created_by,
|
||||||
|
payload,
|
||||||
|
):
|
||||||
|
self.calls.append(f"create_event:{event_type}")
|
||||||
|
if self.event_error is not None:
|
||||||
|
raise self.event_error
|
||||||
|
event = {
|
||||||
|
"id": uuid4(),
|
||||||
|
"repair_id": repair_id,
|
||||||
|
"sequence": sequence,
|
||||||
|
"event_type": event_type,
|
||||||
|
"created_at": datetime.now(timezone.utc),
|
||||||
|
"created_by": created_by,
|
||||||
|
"payload": dict(payload),
|
||||||
|
}
|
||||||
|
self.events.append(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
def set_miner_lifecycle(self, miner_id, lifecycle_status):
|
||||||
|
self.calls.append("set_miner_lifecycle")
|
||||||
|
if self.lifecycle_error is not None:
|
||||||
|
raise self.lifecycle_error
|
||||||
|
self.miners[miner_id]["lifecycle_status"] = lifecycle_status
|
||||||
|
|
||||||
|
|
||||||
|
class RepairServiceTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.repository = FakeRepairRepository()
|
||||||
|
self.service = RepairService(self.repository)
|
||||||
|
self.actor_user_id = uuid4()
|
||||||
|
|
||||||
|
def add_miner(self, lifecycle_status="active"):
|
||||||
|
miner_id = uuid4()
|
||||||
|
self.repository.miners[miner_id] = {
|
||||||
|
"id": miner_id,
|
||||||
|
"lifecycle_status": lifecycle_status,
|
||||||
|
}
|
||||||
|
return miner_id
|
||||||
|
|
||||||
|
def test_begin_repair_creates_record_event_and_lifecycle_atomically(self):
|
||||||
|
miner_id = self.add_miner()
|
||||||
|
|
||||||
|
result = self.service.begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=miner_id,
|
||||||
|
reason="CONTROL_BOARD",
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.miner_id, miner_id)
|
||||||
|
self.assertEqual(result.status, "OPEN")
|
||||||
|
self.assertEqual(result.reason, "CONTROL_BOARD")
|
||||||
|
self.assertEqual(result.opened_by, self.actor_user_id)
|
||||||
|
self.assertEqual(len(self.repository.repairs), 1)
|
||||||
|
self.assertEqual(len(self.repository.events), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.events[0]["event_type"],
|
||||||
|
"REPAIR_STARTED",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.events[0]["payload"],
|
||||||
|
{
|
||||||
|
"reason": "CONTROL_BOARD",
|
||||||
|
"from_lifecycle": "active",
|
||||||
|
"to_lifecycle": "in_repair",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.miners[miner_id]["lifecycle_status"],
|
||||||
|
"in_repair",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.calls,
|
||||||
|
[
|
||||||
|
"transaction",
|
||||||
|
"lock_miner",
|
||||||
|
"get_active_repair",
|
||||||
|
"create_repair",
|
||||||
|
"create_event:REPAIR_STARTED",
|
||||||
|
"set_miner_lifecycle",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_begin_repair_normalizes_and_records_initial_note(self):
|
||||||
|
miner_id = self.add_miner("spare")
|
||||||
|
|
||||||
|
self.service.begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=miner_id,
|
||||||
|
reason="INSPECTION",
|
||||||
|
notes=" Check fan harness. ",
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[event["event_type"] for event in self.repository.events],
|
||||||
|
["REPAIR_STARTED", "INITIAL_NOTE"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[event["sequence"] for event in self.repository.events],
|
||||||
|
[1, 2],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.events[0]["payload"],
|
||||||
|
{
|
||||||
|
"reason": "INSPECTION",
|
||||||
|
"from_lifecycle": "spare",
|
||||||
|
"to_lifecycle": "in_repair",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.events[1]["payload"],
|
||||||
|
{"note": "Check fan harness."},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_absent_and_blank_notes_create_no_note_event(self):
|
||||||
|
for note in (None, " \t\n "):
|
||||||
|
with self.subTest(note=note):
|
||||||
|
repository = FakeRepairRepository()
|
||||||
|
miner_id = uuid4()
|
||||||
|
repository.miners[miner_id] = {
|
||||||
|
"id": miner_id,
|
||||||
|
"lifecycle_status": "active",
|
||||||
|
}
|
||||||
|
RepairService(repository).begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=miner_id,
|
||||||
|
reason="OTHER",
|
||||||
|
notes=note,
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(repository.events), 1)
|
||||||
|
|
||||||
|
def test_unknown_miner_raises_not_found_without_writes(self):
|
||||||
|
with self.assertRaises(RepairMinerNotFound):
|
||||||
|
self.service.begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=uuid4(),
|
||||||
|
reason="OTHER",
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(self.repository.repairs, [])
|
||||||
|
self.assertEqual(self.repository.events, [])
|
||||||
|
|
||||||
|
def test_active_repair_conflict_prevents_partial_changes(self):
|
||||||
|
miner_id = self.add_miner()
|
||||||
|
self.repository.repairs.append(
|
||||||
|
repair_row(miner_id, self.actor_user_id)
|
||||||
|
)
|
||||||
|
snapshot = deepcopy(self.repository.repairs)
|
||||||
|
|
||||||
|
with self.assertRaises(ActiveRepairConflict):
|
||||||
|
self.service.begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=miner_id,
|
||||||
|
reason="PSU",
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(self.repository.repairs, snapshot)
|
||||||
|
self.assertEqual(self.repository.events, [])
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.miners[miner_id]["lifecycle_status"],
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_in_repair_without_active_record_is_inconsistent(self):
|
||||||
|
miner_id = self.add_miner("in_repair")
|
||||||
|
with self.assertRaises(InconsistentRepairState):
|
||||||
|
self.service.begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=miner_id,
|
||||||
|
reason="OTHER",
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_incompatible_lifecycle_is_rejected(self):
|
||||||
|
miner_id = self.add_miner("retired")
|
||||||
|
with self.assertRaises(IncompatibleMinerLifecycle):
|
||||||
|
self.service.begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=miner_id,
|
||||||
|
reason="OTHER",
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_actor_is_required_before_transaction_starts(self):
|
||||||
|
miner_id = self.add_miner()
|
||||||
|
with self.assertRaises(RepairActorRequired):
|
||||||
|
self.service.begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=miner_id,
|
||||||
|
reason="OTHER",
|
||||||
|
),
|
||||||
|
actor_user_id=None,
|
||||||
|
)
|
||||||
|
self.assertEqual(self.repository.calls, [])
|
||||||
|
|
||||||
|
def test_repository_domain_conflict_propagates(self):
|
||||||
|
miner_id = self.add_miner()
|
||||||
|
self.repository.create_error = ActiveRepairConflict(miner_id)
|
||||||
|
with self.assertRaises(ActiveRepairConflict):
|
||||||
|
self.service.begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=miner_id,
|
||||||
|
reason="OTHER",
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_event_failure_rolls_back_every_change(self):
|
||||||
|
miner_id = self.add_miner()
|
||||||
|
self.repository.event_error = RuntimeError("event failed")
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "event failed"):
|
||||||
|
self.service.begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=miner_id,
|
||||||
|
reason="OTHER",
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(self.repository.repairs, [])
|
||||||
|
self.assertEqual(self.repository.events, [])
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.miners[miner_id]["lifecycle_status"],
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_lifecycle_failure_rolls_back_repair_and_events(self):
|
||||||
|
miner_id = self.add_miner()
|
||||||
|
self.repository.lifecycle_error = RuntimeError("lifecycle failed")
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "lifecycle failed"):
|
||||||
|
self.service.begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=miner_id,
|
||||||
|
reason="OTHER",
|
||||||
|
notes="Initial note",
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(self.repository.repairs, [])
|
||||||
|
self.assertEqual(self.repository.events, [])
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.miners[miner_id]["lifecycle_status"],
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RepairRepositoryIntegrationTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.repository = RepairRepository()
|
||||||
|
cls.actor_user_id = UUID(
|
||||||
|
"00000000-0000-0000-0000-000000001101"
|
||||||
|
)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DROP TRIGGER IF EXISTS test_repair_event_failure "
|
||||||
|
"ON repair_events"
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"DROP TRIGGER IF EXISTS test_repair_lifecycle_failure "
|
||||||
|
"ON miners"
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"DROP FUNCTION IF EXISTS test_fail_repair_event()"
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"DROP FUNCTION IF EXISTS test_fail_repair_lifecycle()"
|
||||||
|
)
|
||||||
|
cur.execute("DELETE FROM repair_events")
|
||||||
|
cur.execute("DELETE FROM repairs")
|
||||||
|
cur.execute("DELETE FROM miner_tags")
|
||||||
|
cur.execute("DELETE FROM miner_asset_id_history")
|
||||||
|
cur.execute("DELETE FROM miners")
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM users "
|
||||||
|
"WHERE username = 'repair-domain-test'"
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (
|
||||||
|
id, username, display_name, password_hash, role_id
|
||||||
|
)
|
||||||
|
SELECT %s, %s, %s, %s, id
|
||||||
|
FROM roles
|
||||||
|
WHERE name = 'Admin'
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
self.actor_user_id,
|
||||||
|
"repair-domain-test",
|
||||||
|
"Repair Domain Test",
|
||||||
|
"not-used",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_miner(self, lifecycle_status="active"):
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO miners (
|
||||||
|
asset_tag,
|
||||||
|
status,
|
||||||
|
lifecycle_status,
|
||||||
|
first_seen_at,
|
||||||
|
last_seen_at
|
||||||
|
)
|
||||||
|
VALUES (%s, 'unknown', %s, NULL, NULL)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(f"REPAIR-{uuid4()}", lifecycle_status),
|
||||||
|
)
|
||||||
|
return cur.fetchone()["id"]
|
||||||
|
|
||||||
|
def fetch_events(self, repair_id):
|
||||||
|
with self.repository._connection_scope() as connection:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM repair_events
|
||||||
|
WHERE repair_id = %s
|
||||||
|
ORDER BY sequence
|
||||||
|
""",
|
||||||
|
(repair_id,),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
def count_repairs(self, miner_id):
|
||||||
|
with self.repository._connection_scope() as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) FROM repairs WHERE miner_id = %s",
|
||||||
|
(miner_id,),
|
||||||
|
)
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
|
||||||
|
def lifecycle(self, miner_id):
|
||||||
|
with self.repository._connection_scope() as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT lifecycle_status FROM miners WHERE id = %s",
|
||||||
|
(miner_id,),
|
||||||
|
)
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
|
||||||
|
def test_begin_repair_persists_actor_reason_timestamp_and_started_event(self):
|
||||||
|
miner_id = self.create_miner()
|
||||||
|
before = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
result = RepairService(self.repository).begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=miner_id,
|
||||||
|
reason="HASHBOARD",
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertGreaterEqual(result.opened_at, before)
|
||||||
|
self.assertEqual(result.opened_by, self.actor_user_id)
|
||||||
|
self.assertEqual(result.reason, "HASHBOARD")
|
||||||
|
self.assertEqual(result.status, "OPEN")
|
||||||
|
self.assertEqual(self.lifecycle(miner_id), "in_repair")
|
||||||
|
active = self.repository.get_active_repair(miner_id)
|
||||||
|
self.assertEqual(active["id"], result.repair_id)
|
||||||
|
events = self.fetch_events(result.repair_id)
|
||||||
|
self.assertEqual(len(events), 1)
|
||||||
|
self.assertEqual(events[0]["event_type"], "REPAIR_STARTED")
|
||||||
|
self.assertEqual(events[0]["sequence"], 1)
|
||||||
|
self.assertEqual(events[0]["created_by"], self.actor_user_id)
|
||||||
|
self.assertIsNotNone(events[0]["created_at"])
|
||||||
|
self.assertEqual(
|
||||||
|
events[0]["payload"],
|
||||||
|
{
|
||||||
|
"reason": "HASHBOARD",
|
||||||
|
"from_lifecycle": "active",
|
||||||
|
"to_lifecycle": "in_repair",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_initial_note_is_normalized_and_ordered_after_started_event(self):
|
||||||
|
miner_id = self.create_miner("spare")
|
||||||
|
result = RepairService(self.repository).begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=miner_id,
|
||||||
|
reason="INSPECTION",
|
||||||
|
notes=" Inspect harness. ",
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
events = self.fetch_events(result.repair_id)
|
||||||
|
self.assertEqual(
|
||||||
|
[(event["sequence"], event["event_type"]) for event in events],
|
||||||
|
[(1, "REPAIR_STARTED"), (2, "INITIAL_NOTE")],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
events[0]["payload"],
|
||||||
|
{
|
||||||
|
"reason": "INSPECTION",
|
||||||
|
"from_lifecycle": "spare",
|
||||||
|
"to_lifecycle": "in_repair",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(events[1]["payload"], {"note": "Inspect harness."})
|
||||||
|
|
||||||
|
def test_database_rejects_duplicate_active_repair(self):
|
||||||
|
miner_id = self.create_miner()
|
||||||
|
service = RepairService(self.repository)
|
||||||
|
service.begin_repair(
|
||||||
|
BeginRepairRequest(miner_id=miner_id, reason="FAN"),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ActiveRepairConflict):
|
||||||
|
self.repository.create_repair(
|
||||||
|
miner_id,
|
||||||
|
"PSU",
|
||||||
|
self.actor_user_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(self.count_repairs(miner_id), 1)
|
||||||
|
|
||||||
|
def test_repairs_are_isolated_by_miner(self):
|
||||||
|
first_miner = self.create_miner()
|
||||||
|
second_miner = self.create_miner()
|
||||||
|
service = RepairService(self.repository)
|
||||||
|
|
||||||
|
first = service.begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=first_miner,
|
||||||
|
reason="LOW_HASHRATE",
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
second = service.begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=second_miner,
|
||||||
|
reason="NETWORKING",
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotEqual(first.repair_id, second.repair_id)
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.get_active_repair(first_miner)["id"],
|
||||||
|
first.repair_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.get_active_repair(second_miner)["id"],
|
||||||
|
second.repair_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_event_insertion_failure_rolls_back_repair_and_lifecycle(self):
|
||||||
|
miner_id = self.create_miner()
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE FUNCTION test_fail_repair_event()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'forced repair event failure';
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER test_repair_event_failure
|
||||||
|
BEFORE INSERT ON repair_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION test_fail_repair_event()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(RaiseException):
|
||||||
|
RepairService(self.repository).begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=miner_id,
|
||||||
|
reason="OTHER",
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(self.count_repairs(miner_id), 0)
|
||||||
|
self.assertEqual(self.lifecycle(miner_id), "active")
|
||||||
|
|
||||||
|
def test_lifecycle_failure_rolls_back_repair_and_events(self):
|
||||||
|
miner_id = self.create_miner()
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE FUNCTION test_fail_repair_lifecycle()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.lifecycle_status = 'in_repair' THEN
|
||||||
|
RAISE EXCEPTION 'forced lifecycle failure';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER test_repair_lifecycle_failure
|
||||||
|
BEFORE UPDATE ON miners
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION test_fail_repair_lifecycle()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(RaiseException):
|
||||||
|
RepairService(self.repository).begin_repair(
|
||||||
|
BeginRepairRequest(
|
||||||
|
miner_id=miner_id,
|
||||||
|
reason="OTHER",
|
||||||
|
notes="Must roll back.",
|
||||||
|
),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(self.count_repairs(miner_id), 0)
|
||||||
|
self.assertEqual(self.lifecycle(miner_id), "active")
|
||||||
|
|
||||||
|
def test_simultaneous_requests_create_only_one_active_repair(self):
|
||||||
|
miner_id = self.create_miner()
|
||||||
|
barrier = Barrier(2)
|
||||||
|
|
||||||
|
def begin(reason):
|
||||||
|
barrier.wait()
|
||||||
|
return RepairService(RepairRepository()).begin_repair(
|
||||||
|
BeginRepairRequest(miner_id=miner_id, reason=reason),
|
||||||
|
actor_user_id=self.actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
outcomes = []
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
futures = [
|
||||||
|
executor.submit(begin, "PSU"),
|
||||||
|
executor.submit(begin, "FAN"),
|
||||||
|
]
|
||||||
|
for future in futures:
|
||||||
|
try:
|
||||||
|
outcomes.append(future.result(timeout=10))
|
||||||
|
except ActiveRepairConflict as exc:
|
||||||
|
outcomes.append(exc)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
sum(not isinstance(item, Exception) for item in outcomes),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sum(isinstance(item, ActiveRepairConflict) for item in outcomes),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
self.assertEqual(self.count_repairs(miner_id), 1)
|
||||||
|
self.assertEqual(self.lifecycle(miner_id), "in_repair")
|
||||||
|
|
||||||
|
def test_repairs_create_permission_matrix_and_idempotent_seed(self):
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO permissions (key, description)
|
||||||
|
VALUES (
|
||||||
|
'repairs.create',
|
||||||
|
'Begin repairs for miner assets'
|
||||||
|
)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET description = EXCLUDED.description
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT roles.id, permissions.id
|
||||||
|
FROM roles
|
||||||
|
JOIN permissions
|
||||||
|
ON permissions.key = 'repairs.create'
|
||||||
|
WHERE roles.name IN (
|
||||||
|
'Admin', 'Manager', 'Technician'
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT roles.name
|
||||||
|
FROM role_permissions
|
||||||
|
JOIN roles ON roles.id = role_permissions.role_id
|
||||||
|
JOIN permissions
|
||||||
|
ON permissions.id = role_permissions.permission_id
|
||||||
|
WHERE permissions.key = 'repairs.create'
|
||||||
|
ORDER BY roles.name
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
role_names = [row[0] for row in cur.fetchall()]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
role_names,
|
||||||
|
["Admin", "Manager", "Technician"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RepairArchitectureTests(unittest.TestCase):
|
||||||
|
def test_service_and_repository_have_no_fastapi_dependency(self):
|
||||||
|
repairs_path = Path(__file__).parents[1] / "app" / "repairs"
|
||||||
|
for filename in ("service.py", "repository.py"):
|
||||||
|
tree = ast.parse(
|
||||||
|
(repairs_path / filename).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
imported_modules = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
imported_modules.extend(
|
||||||
|
alias.name for alias in node.names
|
||||||
|
)
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||||
|
imported_modules.append(node.module)
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
module == "fastapi"
|
||||||
|
or module.startswith("fastapi.")
|
||||||
|
for module in imported_modules
|
||||||
|
),
|
||||||
|
filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import ast
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.identity import get_current_user
|
||||||
|
from app.repairs.dependencies import get_repair_service
|
||||||
|
from app.repairs.exceptions import (
|
||||||
|
ActiveRepairConflict,
|
||||||
|
IncompatibleMinerLifecycle,
|
||||||
|
InconsistentRepairState,
|
||||||
|
RepairMinerNotFound,
|
||||||
|
)
|
||||||
|
from app.repairs.models import (
|
||||||
|
BeginRepairRequest,
|
||||||
|
BeginRepairResult,
|
||||||
|
)
|
||||||
|
from app.repairs.router import router
|
||||||
|
|
||||||
|
|
||||||
|
class StubRepairService:
|
||||||
|
def __init__(self):
|
||||||
|
self.result = None
|
||||||
|
self.error = None
|
||||||
|
self.request = None
|
||||||
|
self.actor_user_id = None
|
||||||
|
|
||||||
|
def begin_repair(
|
||||||
|
self,
|
||||||
|
request: BeginRepairRequest,
|
||||||
|
*,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
) -> BeginRepairResult:
|
||||||
|
self.request = request
|
||||||
|
self.actor_user_id = actor_user_id
|
||||||
|
if self.error is not None:
|
||||||
|
raise self.error
|
||||||
|
if self.result is None:
|
||||||
|
raise AssertionError("result was not configured")
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
|
||||||
|
def repair_result(
|
||||||
|
miner_id=None,
|
||||||
|
actor_user_id=None,
|
||||||
|
) -> BeginRepairResult:
|
||||||
|
return BeginRepairResult(
|
||||||
|
repair_id=uuid4(),
|
||||||
|
miner_id=miner_id or uuid4(),
|
||||||
|
status="OPEN",
|
||||||
|
reason="CONTROL_BOARD",
|
||||||
|
opened_at=datetime(2026, 7, 29, 12, 0, tzinfo=timezone.utc),
|
||||||
|
opened_by=actor_user_id or uuid4(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RepairRouterTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.service = StubRepairService()
|
||||||
|
self.app = FastAPI()
|
||||||
|
self.app.include_router(router)
|
||||||
|
self.app.dependency_overrides[get_repair_service] = (
|
||||||
|
lambda: self.service
|
||||||
|
)
|
||||||
|
self.client = TestClient(self.app)
|
||||||
|
self.actor_user_id = uuid4()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
def authenticate(self, permissions=("repairs.create",)):
|
||||||
|
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||||
|
"id": str(self.actor_user_id),
|
||||||
|
"username": "technician",
|
||||||
|
"role": "Technician",
|
||||||
|
"permissions": list(permissions),
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_begin_repair_requires_authentication(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/repairs",
|
||||||
|
json={
|
||||||
|
"miner_id": str(uuid4()),
|
||||||
|
"reason": "CONTROL_BOARD",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
self.assertEqual(response.json()["detail"], "Login required")
|
||||||
|
|
||||||
|
def test_begin_repair_requires_repairs_create(self):
|
||||||
|
self.authenticate(permissions=("repairs.view", "repairs.manage"))
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/repairs",
|
||||||
|
json={
|
||||||
|
"miner_id": str(uuid4()),
|
||||||
|
"reason": "CONTROL_BOARD",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertIsNone(self.service.request)
|
||||||
|
|
||||||
|
def test_malformed_miner_uuid_returns_validation_error(self):
|
||||||
|
self.authenticate()
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/repairs",
|
||||||
|
json={
|
||||||
|
"miner_id": "not-a-uuid",
|
||||||
|
"reason": "CONTROL_BOARD",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
self.assertIsNone(self.service.request)
|
||||||
|
|
||||||
|
def test_invalid_reason_returns_validation_error(self):
|
||||||
|
self.authenticate()
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/repairs",
|
||||||
|
json={
|
||||||
|
"miner_id": str(uuid4()),
|
||||||
|
"reason": "FREE_FORM_REASON",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
self.assertIsNone(self.service.request)
|
||||||
|
|
||||||
|
def test_unknown_miner_returns_not_found(self):
|
||||||
|
self.authenticate()
|
||||||
|
miner_id = uuid4()
|
||||||
|
self.service.error = RepairMinerNotFound(miner_id)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/repairs",
|
||||||
|
json={
|
||||||
|
"miner_id": str(miner_id),
|
||||||
|
"reason": "OTHER",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertEqual(response.json()["detail"], "Miner not found")
|
||||||
|
|
||||||
|
def test_active_repair_returns_conflict(self):
|
||||||
|
self.authenticate()
|
||||||
|
miner_id = uuid4()
|
||||||
|
self.service.error = ActiveRepairConflict(miner_id)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/repairs",
|
||||||
|
json={
|
||||||
|
"miner_id": str(miner_id),
|
||||||
|
"reason": "PSU",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
self.assertIn("active repair", response.json()["detail"])
|
||||||
|
|
||||||
|
def test_lifecycle_conflicts_return_conflict(self):
|
||||||
|
self.authenticate()
|
||||||
|
miner_id = uuid4()
|
||||||
|
for error in (
|
||||||
|
IncompatibleMinerLifecycle(miner_id, "retired"),
|
||||||
|
InconsistentRepairState(miner_id),
|
||||||
|
):
|
||||||
|
with self.subTest(error=error):
|
||||||
|
self.service.error = error
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/repairs",
|
||||||
|
json={
|
||||||
|
"miner_id": str(miner_id),
|
||||||
|
"reason": "OTHER",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
|
||||||
|
def test_success_returns_typed_envelope_and_authenticated_actor(self):
|
||||||
|
self.authenticate()
|
||||||
|
miner_id = uuid4()
|
||||||
|
expected = repair_result(miner_id, self.actor_user_id)
|
||||||
|
self.service.result = expected
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/repairs",
|
||||||
|
json={
|
||||||
|
"miner_id": str(miner_id),
|
||||||
|
"reason": "CONTROL_BOARD",
|
||||||
|
"notes": "Miner will not boot.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual(body["data"]["repair_id"], str(expected.repair_id))
|
||||||
|
self.assertEqual(body["data"]["miner_id"], str(miner_id))
|
||||||
|
self.assertEqual(body["data"]["status"], "OPEN")
|
||||||
|
self.assertEqual(body["data"]["reason"], "CONTROL_BOARD")
|
||||||
|
self.assertEqual(body["data"]["opened_by"], str(self.actor_user_id))
|
||||||
|
self.assertEqual(
|
||||||
|
body["data"]["opened_at"],
|
||||||
|
"2026-07-29T12:00:00Z",
|
||||||
|
)
|
||||||
|
self.assertEqual(body["meta"], {})
|
||||||
|
self.assertIsNone(body["error"])
|
||||||
|
self.assertEqual(self.service.actor_user_id, self.actor_user_id)
|
||||||
|
self.assertEqual(
|
||||||
|
self.service.request.notes,
|
||||||
|
"Miner will not boot.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_client_cannot_override_server_fields_or_actor(self):
|
||||||
|
self.authenticate()
|
||||||
|
base = {
|
||||||
|
"miner_id": str(uuid4()),
|
||||||
|
"reason": "OTHER",
|
||||||
|
}
|
||||||
|
forbidden = {
|
||||||
|
"repair_id": str(uuid4()),
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"opened_at": "2026-01-01T00:00:00Z",
|
||||||
|
"opened_by": str(uuid4()),
|
||||||
|
"event_id": str(uuid4()),
|
||||||
|
"event_timestamp": "2026-01-01T00:00:00Z",
|
||||||
|
"event_actor": str(uuid4()),
|
||||||
|
"lifecycle_status": "retired",
|
||||||
|
}
|
||||||
|
for field, value in forbidden.items():
|
||||||
|
with self.subTest(field=field):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/repairs",
|
||||||
|
json={**base, field: value},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
self.assertIsNone(self.service.request)
|
||||||
|
|
||||||
|
def test_router_registers_exactly_one_post_route(self):
|
||||||
|
routes = [
|
||||||
|
(route.path, route.methods)
|
||||||
|
for route in router.routes
|
||||||
|
if hasattr(route, "methods")
|
||||||
|
]
|
||||||
|
self.assertEqual(routes, [("/api/repairs", {"POST"})])
|
||||||
|
|
||||||
|
def test_router_has_no_sql_driver_or_transaction_management(self):
|
||||||
|
router_path = (
|
||||||
|
Path(__file__).parents[1] / "app" / "repairs" / "router.py"
|
||||||
|
)
|
||||||
|
source = router_path.read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
imported_modules = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
imported_modules.extend(alias.name for alias in node.names)
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||||
|
imported_modules.append(node.module)
|
||||||
|
|
||||||
|
database_modules = ("psycopg", "asyncpg", "sqlalchemy")
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
module == name or module.startswith(f"{name}.")
|
||||||
|
for module in imported_modules
|
||||||
|
for name in database_modules
|
||||||
|
)
|
||||||
|
)
|
||||||
|
upper_source = source.upper()
|
||||||
|
for keyword in (
|
||||||
|
"SELECT ",
|
||||||
|
"INSERT ",
|
||||||
|
"UPDATE ",
|
||||||
|
"DELETE ",
|
||||||
|
".COMMIT(",
|
||||||
|
".ROLLBACK(",
|
||||||
|
):
|
||||||
|
self.assertNotIn(keyword, upper_source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+6
-1
@@ -92,13 +92,17 @@ location changes.
|
|||||||
|
|
||||||
### Phase 3: Operations
|
### Phase 3: Operations
|
||||||
|
|
||||||
**Status: Planned direction.**
|
**Status: In progress.**
|
||||||
|
|
||||||
This phase develops day-to-day technician and operations workflows around the
|
This phase develops day-to-day technician and operations workflows around the
|
||||||
durable asset record. Repairs, maintenance, work orders, parts usage, equipment
|
durable asset record. Repairs, maintenance, work orders, parts usage, equipment
|
||||||
movement, testing, and return-to-service history become coherent operational
|
movement, testing, and return-to-service history become coherent operational
|
||||||
records rather than disconnected actions.
|
records rather than disconnected actions.
|
||||||
|
|
||||||
|
The Begin Repair workflow is complete. An authorized actor can open a repair
|
||||||
|
against a durable miner UUID, record sequenced starting events, and move the
|
||||||
|
miner into repair atomically. Later repair lifecycle workflows remain planned.
|
||||||
|
|
||||||
Technician experience is a priority. Responsive and QR-assisted workflows may
|
Technician experience is a priority. Responsive and QR-assisted workflows may
|
||||||
reduce lookup and data-entry effort where they reinforce the same asset record.
|
reduce lookup and data-entry effort where they reinforce the same asset record.
|
||||||
Existing repair and location behavior should move incrementally into clear
|
Existing repair and location behavior should move incrementally into clear
|
||||||
@@ -209,6 +213,7 @@ Major completed foundations include:
|
|||||||
- Authentication, login gating, and role foundation
|
- Authentication, login gating, and role foundation
|
||||||
- Manual Inventory schema and backend domain foundation
|
- Manual Inventory schema and backend domain foundation
|
||||||
- Focused Inventory and migration test foundations
|
- Focused Inventory and migration test foundations
|
||||||
|
- Begin Repair operational workflow
|
||||||
- MinerOps branding package and project README
|
- MinerOps branding package and project README
|
||||||
- Architecture guide
|
- Architecture guide
|
||||||
- Product guide
|
- Product guide
|
||||||
|
|||||||
@@ -0,0 +1,544 @@
|
|||||||
|
# Task 001: Begin Repair
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Ready for implementation
|
||||||
|
|
||||||
|
## Phase
|
||||||
|
|
||||||
|
Phase 3: Operations
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement the first explicit operational workflow in MinerOps: beginning a repair for a miner.
|
||||||
|
|
||||||
|
This workflow creates permanent repair history. It is not generic CRUD and must not be implemented as a generic miner update.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/repairs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permission
|
||||||
|
|
||||||
|
Introduce and require:
|
||||||
|
|
||||||
|
```text
|
||||||
|
repairs.create
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not reuse `miners.update`.
|
||||||
|
|
||||||
|
Register the permission using the repository's existing permission and role-seeding conventions.
|
||||||
|
|
||||||
|
At minimum, administrators must retain access. Do not grant this permission to viewer or auditor roles.
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
Use a typed request model equivalent to:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"miner_id": "UUID",
|
||||||
|
"reason": "CONTROL_BOARD",
|
||||||
|
"notes": "Miner not booting after power outage."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
#### `miner_id`
|
||||||
|
|
||||||
|
- Required
|
||||||
|
- Durable internal miner UUID
|
||||||
|
- Must reference an existing miner
|
||||||
|
|
||||||
|
#### `reason`
|
||||||
|
|
||||||
|
- Required
|
||||||
|
- Must use the constrained repair-reason values below
|
||||||
|
|
||||||
|
#### `notes`
|
||||||
|
|
||||||
|
- Optional
|
||||||
|
- Trim surrounding whitespace using existing normalization conventions
|
||||||
|
- Treat whitespace-only notes as absent unless established project validation conventions require rejection
|
||||||
|
- Store the initial note as a repair timeline event, not as a mutable column on the repair record
|
||||||
|
|
||||||
|
Do not accept client-provided values for:
|
||||||
|
|
||||||
|
- repair ID
|
||||||
|
- repair status
|
||||||
|
- opened or started timestamp
|
||||||
|
- opened or started actor
|
||||||
|
- miner lifecycle
|
||||||
|
- event IDs
|
||||||
|
- event timestamps
|
||||||
|
- event actors
|
||||||
|
|
||||||
|
## Repair Reasons
|
||||||
|
|
||||||
|
Support exactly these initial values:
|
||||||
|
|
||||||
|
- `HARDWARE_FAILURE`
|
||||||
|
- `LOW_HASHRATE`
|
||||||
|
- `CONTROL_BOARD`
|
||||||
|
- `HASHBOARD`
|
||||||
|
- `PSU`
|
||||||
|
- `FAN`
|
||||||
|
- `OVERHEATING`
|
||||||
|
- `NETWORKING`
|
||||||
|
- `PREVENTIVE_MAINTENANCE`
|
||||||
|
- `INSPECTION`
|
||||||
|
- `OTHER`
|
||||||
|
|
||||||
|
Use the project's existing enum or check-constraint conventions. Do not substitute a free-form reason.
|
||||||
|
|
||||||
|
## Repair Statuses
|
||||||
|
|
||||||
|
The schema must anticipate:
|
||||||
|
|
||||||
|
- `OPEN`
|
||||||
|
- `IN_PROGRESS`
|
||||||
|
- `WAITING_FOR_PARTS`
|
||||||
|
- `READY_FOR_TEST`
|
||||||
|
- `COMPLETED`
|
||||||
|
- `CANCELLED`
|
||||||
|
|
||||||
|
This endpoint creates repairs only with status:
|
||||||
|
|
||||||
|
```text
|
||||||
|
OPEN
|
||||||
|
```
|
||||||
|
|
||||||
|
Active repair statuses are:
|
||||||
|
|
||||||
|
- `OPEN`
|
||||||
|
- `IN_PROGRESS`
|
||||||
|
- `WAITING_FOR_PARTS`
|
||||||
|
- `READY_FOR_TEST`
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
Beginning a repair must atomically:
|
||||||
|
|
||||||
|
1. Confirm the miner exists.
|
||||||
|
2. Lock the miner row.
|
||||||
|
3. Confirm the miner is eligible to enter repair.
|
||||||
|
4. Confirm the miner does not already have an active repair.
|
||||||
|
5. Create the repair record.
|
||||||
|
6. Create a `REPAIR_STARTED` timeline event.
|
||||||
|
7. Create an `INITIAL_NOTE` timeline event when a non-empty note was supplied.
|
||||||
|
8. Change the miner lifecycle to `IN_REPAIR`.
|
||||||
|
9. Commit all changes together.
|
||||||
|
|
||||||
|
If any step fails, none of the changes may persist.
|
||||||
|
|
||||||
|
## Business Rules
|
||||||
|
|
||||||
|
### One active repair
|
||||||
|
|
||||||
|
A miner may have no more than one active repair.
|
||||||
|
|
||||||
|
This rule must be protected by the database against concurrent requests. Do not rely only on a service-level pre-check.
|
||||||
|
|
||||||
|
A likely implementation is a PostgreSQL partial unique index on `miner_id` for active statuses, but inspect existing migration and database conventions before choosing the mechanism.
|
||||||
|
|
||||||
|
Duplicate active-repair attempts return:
|
||||||
|
|
||||||
|
```text
|
||||||
|
409 Conflict
|
||||||
|
```
|
||||||
|
|
||||||
|
### Miner lifecycle
|
||||||
|
|
||||||
|
Beginning a repair moves the miner lifecycle to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IN_REPAIR
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect the current lifecycle values and transition conventions before implementing.
|
||||||
|
|
||||||
|
Reject incompatible or terminal lifecycle states with a domain-specific conflict mapped to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
409 Conflict
|
||||||
|
```
|
||||||
|
|
||||||
|
If a miner is already `IN_REPAIR` but has no active repair, treat that as inconsistent state. Reject it rather than silently creating ambiguous history.
|
||||||
|
|
||||||
|
Do not add another repair-status field to the miner.
|
||||||
|
|
||||||
|
### Actor attribution
|
||||||
|
|
||||||
|
The authenticated user's UUID is mandatory.
|
||||||
|
|
||||||
|
Pass the actor UUID explicitly from the router to the service.
|
||||||
|
|
||||||
|
Do not:
|
||||||
|
|
||||||
|
- derive the actor from request data
|
||||||
|
- permit actorless repair creation
|
||||||
|
- allow the client to override the actor
|
||||||
|
|
||||||
|
### Timestamps
|
||||||
|
|
||||||
|
All timestamps must be generated by the server or database.
|
||||||
|
|
||||||
|
Do not accept client timestamps.
|
||||||
|
|
||||||
|
Initial events must have deterministic ordering. If timestamps can match, use a stable secondary order consistent with repository conventions.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
Create one Alembic migration after the current head.
|
||||||
|
|
||||||
|
Use Alembic only. Do not use runtime table creation or ORM auto-create behaviour.
|
||||||
|
|
||||||
|
### `repairs`
|
||||||
|
|
||||||
|
Required logical fields:
|
||||||
|
|
||||||
|
- `id`: UUID primary key
|
||||||
|
- `miner_id`: foreign key to miners
|
||||||
|
- `status`
|
||||||
|
- `reason`
|
||||||
|
- `opened_at` or `started_at`
|
||||||
|
- `opened_by` or `started_by`
|
||||||
|
- `completed_at`: nullable
|
||||||
|
- `completed_by`: nullable
|
||||||
|
|
||||||
|
Inspect existing naming conventions and choose either `opened_*` or `started_*`. Use the chosen terminology consistently across schema, models, service, API, and tests.
|
||||||
|
|
||||||
|
Add appropriate:
|
||||||
|
|
||||||
|
- foreign keys
|
||||||
|
- actor references
|
||||||
|
- indexes
|
||||||
|
- status constraints
|
||||||
|
- reason constraints
|
||||||
|
- server-generated timestamps
|
||||||
|
- database-backed active-repair uniqueness
|
||||||
|
|
||||||
|
### `repair_events`
|
||||||
|
|
||||||
|
Required logical fields:
|
||||||
|
|
||||||
|
- `id`: UUID primary key
|
||||||
|
- `repair_id`: foreign key to repairs
|
||||||
|
- `event_type`
|
||||||
|
- `created_at`
|
||||||
|
- `created_by`
|
||||||
|
- `payload`
|
||||||
|
|
||||||
|
Initial supported event types:
|
||||||
|
|
||||||
|
- `REPAIR_STARTED`
|
||||||
|
- `INITIAL_NOTE`
|
||||||
|
|
||||||
|
#### `REPAIR_STARTED` payload
|
||||||
|
|
||||||
|
Preserve relevant starting context, including at least:
|
||||||
|
|
||||||
|
- repair reason
|
||||||
|
- miner lifecycle transition where practical
|
||||||
|
|
||||||
|
#### `INITIAL_NOTE` payload
|
||||||
|
|
||||||
|
Preserve the normalized submitted note.
|
||||||
|
|
||||||
|
Use JSON only if it fits existing repository conventions or is clearly the best design. Do not store unvalidated, inconsistent payloads merely for convenience.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Preserve:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Router
|
||||||
|
↓
|
||||||
|
RepairService
|
||||||
|
↓
|
||||||
|
RepairRepository
|
||||||
|
↓
|
||||||
|
PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
The service may coordinate inventory persistence through an existing repository abstraction or a clean shared transaction boundary.
|
||||||
|
|
||||||
|
Do not bypass repository boundaries merely to simplify the transaction.
|
||||||
|
|
||||||
|
### Router responsibilities
|
||||||
|
|
||||||
|
- authentication
|
||||||
|
- authorization
|
||||||
|
- request validation
|
||||||
|
- HTTP status mapping
|
||||||
|
- response envelope
|
||||||
|
|
||||||
|
The router must contain:
|
||||||
|
|
||||||
|
- no SQL
|
||||||
|
- no database-driver imports
|
||||||
|
- no transaction management
|
||||||
|
- no business rules
|
||||||
|
|
||||||
|
### Service responsibilities
|
||||||
|
|
||||||
|
- workflow orchestration
|
||||||
|
- business rules
|
||||||
|
- transaction coordination
|
||||||
|
- domain exception propagation or translation
|
||||||
|
- no FastAPI imports
|
||||||
|
|
||||||
|
### Repository responsibilities
|
||||||
|
|
||||||
|
- persistence
|
||||||
|
- row locking
|
||||||
|
- parameterized SQL
|
||||||
|
- no FastAPI imports
|
||||||
|
|
||||||
|
Individual repository operations must not commit in a way that breaks atomic workflow orchestration.
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
```text
|
||||||
|
201 Created
|
||||||
|
```
|
||||||
|
|
||||||
|
Reuse the existing typed response envelope.
|
||||||
|
|
||||||
|
The response must contain at least:
|
||||||
|
|
||||||
|
- `repair_id`
|
||||||
|
- `miner_id`
|
||||||
|
- `status`
|
||||||
|
- `reason`
|
||||||
|
- `opened_at` or `started_at`
|
||||||
|
- `opened_by` or `started_by`
|
||||||
|
|
||||||
|
Use terminology consistent with the schema.
|
||||||
|
|
||||||
|
Do not include the full repair timeline in this response.
|
||||||
|
|
||||||
|
## Error Behaviour
|
||||||
|
|
||||||
|
Use existing response-envelope and error conventions.
|
||||||
|
|
||||||
|
### `401 Unauthorized`
|
||||||
|
|
||||||
|
- user is not authenticated
|
||||||
|
|
||||||
|
### `403 Forbidden`
|
||||||
|
|
||||||
|
- user lacks `repairs.create`
|
||||||
|
|
||||||
|
### `404 Not Found`
|
||||||
|
|
||||||
|
- miner does not exist
|
||||||
|
|
||||||
|
### `409 Conflict`
|
||||||
|
|
||||||
|
- miner already has an active repair
|
||||||
|
- miner lifecycle is incompatible with beginning repair
|
||||||
|
- miner is in an inconsistent repair state
|
||||||
|
|
||||||
|
### `422 Unprocessable Entity`
|
||||||
|
|
||||||
|
- malformed UUID
|
||||||
|
- invalid reason
|
||||||
|
- invalid request body
|
||||||
|
|
||||||
|
Do not expose raw database errors.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
Do not implement:
|
||||||
|
|
||||||
|
- repair retrieval
|
||||||
|
- repair listing
|
||||||
|
- repair editing
|
||||||
|
- repair deletion
|
||||||
|
- repair cancellation
|
||||||
|
- repair completion
|
||||||
|
- return to service
|
||||||
|
- adding later notes
|
||||||
|
- diagnostics
|
||||||
|
- parts
|
||||||
|
- photos
|
||||||
|
- attachments
|
||||||
|
- uploads
|
||||||
|
- testing workflows
|
||||||
|
- control-board replacement
|
||||||
|
- notifications
|
||||||
|
- generic repair PATCH endpoints
|
||||||
|
- alert pausing unless an existing clean abstraction makes it unavoidable
|
||||||
|
|
||||||
|
Exactly one new API route is permitted in this task.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Follow existing test conventions.
|
||||||
|
|
||||||
|
### Migration tests
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
|
||||||
|
- upgrade from previous head
|
||||||
|
- downgrade
|
||||||
|
- re-upgrade
|
||||||
|
- migration at head
|
||||||
|
- empty-database migration
|
||||||
|
- table constraints
|
||||||
|
- foreign keys
|
||||||
|
- indexes
|
||||||
|
- active-repair uniqueness
|
||||||
|
- supported status values
|
||||||
|
- supported reason values
|
||||||
|
- supported event types
|
||||||
|
- nullable completion fields
|
||||||
|
- actor requirements
|
||||||
|
|
||||||
|
### Repository tests
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
|
||||||
|
- repair creation
|
||||||
|
- `REPAIR_STARTED` event creation
|
||||||
|
- optional `INITIAL_NOTE` event creation
|
||||||
|
- no note event for absent or normalized-empty notes
|
||||||
|
- miner lifecycle transition
|
||||||
|
- actor persistence
|
||||||
|
- reason persistence
|
||||||
|
- timestamp persistence
|
||||||
|
- active-repair detection
|
||||||
|
- database uniqueness under duplicate active-repair attempts
|
||||||
|
- miner isolation
|
||||||
|
- rollback when event insertion fails
|
||||||
|
- rollback when lifecycle update fails
|
||||||
|
- incompatible lifecycle rejection
|
||||||
|
- concurrency or row-lock protection where practical
|
||||||
|
|
||||||
|
### Service tests
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
|
||||||
|
- successful Begin Repair
|
||||||
|
- successful Begin Repair with an initial note
|
||||||
|
- unknown miner
|
||||||
|
- active-repair conflict
|
||||||
|
- incompatible lifecycle conflict
|
||||||
|
- actor required
|
||||||
|
- orchestration behaviour
|
||||||
|
- domain exception propagation
|
||||||
|
- no partial persistence when orchestration fails
|
||||||
|
|
||||||
|
### Router tests
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
|
||||||
|
- authentication required
|
||||||
|
- `repairs.create` required
|
||||||
|
- malformed miner UUID
|
||||||
|
- invalid reason
|
||||||
|
- unknown miner
|
||||||
|
- active-repair conflict
|
||||||
|
- lifecycle conflict
|
||||||
|
- successful creation returns `201`
|
||||||
|
- response envelope
|
||||||
|
- typed response fields
|
||||||
|
- actor comes from authenticated principal
|
||||||
|
- client cannot override actor
|
||||||
|
- client cannot override status or timestamps
|
||||||
|
- router contains no SQL
|
||||||
|
- router contains no database-driver imports
|
||||||
|
|
||||||
|
### Permission tests
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
|
||||||
|
- `repairs.create` registration
|
||||||
|
- administrator access
|
||||||
|
- unauthorized roles are not granted access accidentally
|
||||||
|
- idempotent permission seeding
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
After implementation and validation pass:
|
||||||
|
|
||||||
|
- Update `docs/ROADMAP.md` to mark Begin Repair complete.
|
||||||
|
- Update architecture or decision documentation only when the implementation introduces a genuinely new architectural decision.
|
||||||
|
|
||||||
|
If a new ADR is warranted, explain concisely:
|
||||||
|
|
||||||
|
- why repair is an operational entity
|
||||||
|
- why active-repair uniqueness is database-enforced
|
||||||
|
- why lifecycle transition and timeline creation are atomic
|
||||||
|
|
||||||
|
Avoid documentation churn for principles already documented.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- Python compilation
|
||||||
|
- shell syntax validation
|
||||||
|
- migration upgrade from previous head
|
||||||
|
- migration downgrade
|
||||||
|
- migration re-upgrade
|
||||||
|
- migration head verification
|
||||||
|
- empty-database migration
|
||||||
|
- focused repair suite
|
||||||
|
- focused inventory regression suite
|
||||||
|
- permission and role tests
|
||||||
|
- duplicate active-repair or concurrency tests
|
||||||
|
- full disposable development baseline
|
||||||
|
- router SQL and database-driver audit
|
||||||
|
- service and repository FastAPI import audit
|
||||||
|
- route-count audit
|
||||||
|
- `git diff --check`
|
||||||
|
- `git status --short`
|
||||||
|
|
||||||
|
## Final Review Checklist
|
||||||
|
|
||||||
|
Confirm:
|
||||||
|
|
||||||
|
- exactly one endpoint was added
|
||||||
|
- no generic repair CRUD was added
|
||||||
|
- active-repair uniqueness is enforced by the database
|
||||||
|
- transaction boundaries prevent partial persistence
|
||||||
|
- authenticated actor UUID is explicit and mandatory
|
||||||
|
- timestamps are server-controlled
|
||||||
|
- initial notes are timeline events
|
||||||
|
- no unrelated changes are present
|
||||||
|
|
||||||
|
## Implementation Report
|
||||||
|
|
||||||
|
Report:
|
||||||
|
|
||||||
|
- implementation summary
|
||||||
|
- files created
|
||||||
|
- files modified
|
||||||
|
- migration revision and parent revision
|
||||||
|
- tables and constraints
|
||||||
|
- endpoint
|
||||||
|
- permission
|
||||||
|
- request model
|
||||||
|
- response model
|
||||||
|
- repair reasons
|
||||||
|
- repair statuses
|
||||||
|
- event types
|
||||||
|
- lifecycle behaviour
|
||||||
|
- actor attribution
|
||||||
|
- transaction design
|
||||||
|
- concurrency protection
|
||||||
|
- tests added
|
||||||
|
- validation results
|
||||||
|
- git diff summary
|
||||||
|
- `git status --short`
|
||||||
|
|
||||||
|
Do not commit.
|
||||||
|
Do not push.
|
||||||
Executable
+556
@@ -0,0 +1,556 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
|
||||||
|
project_name="${MINEROPS_REPAIR_MIGRATION_PROJECT:-minerops-repair-migration-$$}"
|
||||||
|
empty_database="minerops_repair_empty"
|
||||||
|
compose=(
|
||||||
|
docker compose
|
||||||
|
--project-name "${project_name}"
|
||||||
|
--env-file .env.dev
|
||||||
|
-f compose.dev.yaml
|
||||||
|
)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
echo
|
||||||
|
echo "Begin Repair migration test failed. Recent PostgreSQL logs:"
|
||||||
|
"${compose[@]}" logs --no-color --tail 120 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
if [[ ! -f .env.dev ]]; then
|
||||||
|
echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_alembic() {
|
||||||
|
"${compose[@]}" run --rm --no-deps backend alembic "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Starting disposable PostgreSQL..."
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
if "${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
>/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if (( attempt == 60 )); then
|
||||||
|
echo "PostgreSQL did not become ready." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Building migration image..."
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
|
||||||
|
echo "Upgrading the previous head to Begin Repair..."
|
||||||
|
run_alembic upgrade 0009_inventory_asset_id_history
|
||||||
|
previous_revision="$(run_alembic current 2>/dev/null)"
|
||||||
|
[[ "${previous_revision}" == *"0009_inventory_asset_id_history"* ]]
|
||||||
|
run_alembic upgrade head
|
||||||
|
current_revision="$(run_alembic current 2>/dev/null)"
|
||||||
|
[[ "${current_revision}" == *"0010_begin_repair"* ]]
|
||||||
|
|
||||||
|
echo "Checking repair schema, constraints, permissions, and lifecycle..."
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
<<'SQL'
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_actor UUID := '00000000-0000-0000-0000-000000001001';
|
||||||
|
v_miner UUID := '00000000-0000-0000-0000-000000001002';
|
||||||
|
v_other_miner UUID := '00000000-0000-0000-0000-000000001003';
|
||||||
|
v_invalid_miner UUID := '00000000-0000-0000-0000-000000001004';
|
||||||
|
v_repair UUID;
|
||||||
|
v_other_repair UUID;
|
||||||
|
v_role_id UUID;
|
||||||
|
v_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('public.repairs') IS NULL
|
||||||
|
OR to_regclass('public.repair_events') IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Repair workflow tables were not created';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'repairs'
|
||||||
|
AND column_name IN (
|
||||||
|
'id', 'miner_id', 'status', 'reason', 'opened_at', 'opened_by'
|
||||||
|
)
|
||||||
|
AND is_nullable <> 'NO'
|
||||||
|
) OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'repairs'
|
||||||
|
AND column_name IN ('completed_at', 'completed_by')
|
||||||
|
AND is_nullable <> 'YES'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Repair column nullability is incorrect';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
AND tablename = 'repairs'
|
||||||
|
AND indexname = 'uq_repairs_one_active_per_miner'
|
||||||
|
AND indexdef LIKE '%UNIQUE%'
|
||||||
|
AND indexdef LIKE '%WHERE (status = ANY%'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Active repair partial unique index is missing';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
AND tablename = 'repair_events'
|
||||||
|
AND indexname = 'idx_repair_events_repair_created'
|
||||||
|
AND indexdef LIKE '%repair_id, created_at, sequence, id%'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Repair event chronological index is missing';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF (SELECT COUNT(*) FROM permissions WHERE key = 'repairs.create') <> 1 THEN
|
||||||
|
RAISE EXCEPTION 'repairs.create permission was not registered once';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_count
|
||||||
|
FROM role_permissions
|
||||||
|
JOIN roles ON roles.id = role_permissions.role_id
|
||||||
|
JOIN permissions ON permissions.id = role_permissions.permission_id
|
||||||
|
WHERE permissions.key = 'repairs.create'
|
||||||
|
AND roles.name IN ('Admin', 'Manager', 'Technician');
|
||||||
|
IF v_count <> 3 THEN
|
||||||
|
RAISE EXCEPTION 'Expected Admin, Manager, and Technician repair access';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM role_permissions
|
||||||
|
JOIN roles ON roles.id = role_permissions.role_id
|
||||||
|
JOIN permissions ON permissions.id = role_permissions.permission_id
|
||||||
|
WHERE permissions.key = 'repairs.create'
|
||||||
|
AND roles.name IN ('Read Only', 'Viewer', 'Auditor')
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'A read-only role received repairs.create';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO permissions (key, description)
|
||||||
|
VALUES ('repairs.create', 'Begin repairs for miner assets')
|
||||||
|
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 = 'repairs.create'
|
||||||
|
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
IF (SELECT COUNT(*) FROM permissions WHERE key = 'repairs.create') <> 1 THEN
|
||||||
|
RAISE EXCEPTION 'Permission seeding is not idempotent';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT id INTO v_role_id FROM roles WHERE name = 'Admin';
|
||||||
|
INSERT INTO users (
|
||||||
|
id, username, display_name, password_hash, role_id
|
||||||
|
) VALUES (
|
||||||
|
v_actor,
|
||||||
|
'begin-repair-migration-test',
|
||||||
|
'Begin Repair Migration Test',
|
||||||
|
'not-used',
|
||||||
|
v_role_id
|
||||||
|
);
|
||||||
|
INSERT INTO miners (id, asset_tag, status, lifecycle_status)
|
||||||
|
VALUES
|
||||||
|
(v_miner, 'REPAIR-MIGRATION-001', 'unknown', 'active'),
|
||||||
|
(v_other_miner, 'REPAIR-MIGRATION-002', 'unknown', 'spare'),
|
||||||
|
(v_invalid_miner, 'REPAIR-MIGRATION-003', 'unknown', 'active');
|
||||||
|
|
||||||
|
INSERT INTO repairs (miner_id, reason, opened_by)
|
||||||
|
VALUES (v_miner, 'CONTROL_BOARD', v_actor)
|
||||||
|
RETURNING id INTO v_repair;
|
||||||
|
INSERT INTO repair_events (
|
||||||
|
repair_id, sequence, event_type, created_by, payload
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
v_repair,
|
||||||
|
1,
|
||||||
|
'REPAIR_STARTED',
|
||||||
|
v_actor,
|
||||||
|
'{"reason":"CONTROL_BOARD","from_lifecycle":"active","to_lifecycle":"in_repair"}'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
v_repair,
|
||||||
|
2,
|
||||||
|
'INITIAL_NOTE',
|
||||||
|
v_actor,
|
||||||
|
'{"note":"Migration test note."}'
|
||||||
|
);
|
||||||
|
INSERT INTO repairs (miner_id, reason, opened_by)
|
||||||
|
VALUES (v_other_miner, 'FAN', v_actor)
|
||||||
|
RETURNING id INTO v_other_repair;
|
||||||
|
INSERT INTO repair_events (
|
||||||
|
repair_id, sequence, event_type, created_by, payload
|
||||||
|
) VALUES (
|
||||||
|
v_other_repair,
|
||||||
|
1,
|
||||||
|
'REPAIR_STARTED',
|
||||||
|
v_actor,
|
||||||
|
'{"reason":"FAN","from_lifecycle":"spare","to_lifecycle":"in_repair"}'
|
||||||
|
);
|
||||||
|
UPDATE miners
|
||||||
|
SET lifecycle_status = 'in_repair'
|
||||||
|
WHERE id IN (v_miner, v_other_miner);
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM repairs
|
||||||
|
WHERE id = v_repair
|
||||||
|
AND (
|
||||||
|
status <> 'OPEN'
|
||||||
|
OR completed_at IS NOT NULL
|
||||||
|
OR completed_by IS NOT NULL
|
||||||
|
OR opened_at IS NULL
|
||||||
|
)
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'New repair defaults or completion fields are invalid';
|
||||||
|
END IF;
|
||||||
|
IF (
|
||||||
|
SELECT array_agg(event_type ORDER BY sequence)
|
||||||
|
FROM repair_events
|
||||||
|
WHERE repair_id = v_repair
|
||||||
|
) <> ARRAY['REPAIR_STARTED', 'INITIAL_NOTE'] THEN
|
||||||
|
RAISE EXCEPTION 'Initial event ordering is incorrect';
|
||||||
|
END IF;
|
||||||
|
IF (
|
||||||
|
SELECT payload ->> 'from_lifecycle'
|
||||||
|
FROM repair_events
|
||||||
|
WHERE repair_id = v_other_repair
|
||||||
|
AND event_type = 'REPAIR_STARTED'
|
||||||
|
) <> 'spare' THEN
|
||||||
|
RAISE EXCEPTION 'Spare lifecycle history was not preserved';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO repairs (miner_id, reason, opened_by)
|
||||||
|
VALUES (v_miner, 'PSU', v_actor);
|
||||||
|
RAISE EXCEPTION 'A duplicate active repair was accepted';
|
||||||
|
EXCEPTION WHEN unique_violation THEN
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO repairs (miner_id, status, reason, opened_by)
|
||||||
|
VALUES (v_invalid_miner, 'BROKEN', 'OTHER', v_actor);
|
||||||
|
RAISE EXCEPTION 'Unsupported repair status was accepted';
|
||||||
|
EXCEPTION WHEN check_violation THEN
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO repairs (miner_id, reason, opened_by)
|
||||||
|
VALUES (v_invalid_miner, 'NOT_A_REASON', v_actor);
|
||||||
|
RAISE EXCEPTION 'Unsupported repair reason was accepted';
|
||||||
|
EXCEPTION WHEN check_violation THEN
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO repair_events (
|
||||||
|
repair_id, sequence, event_type, created_by, payload
|
||||||
|
) VALUES (
|
||||||
|
v_repair, 3, 'UNKNOWN_EVENT', v_actor, '{}'::jsonb
|
||||||
|
);
|
||||||
|
RAISE EXCEPTION 'Unsupported repair event type was accepted';
|
||||||
|
EXCEPTION WHEN check_violation THEN
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO repair_events (
|
||||||
|
repair_id, sequence, event_type, created_by, payload
|
||||||
|
) VALUES (
|
||||||
|
v_repair, 3, 'INITIAL_NOTE', v_actor, '{"note":" "}'::jsonb
|
||||||
|
);
|
||||||
|
RAISE EXCEPTION 'Blank initial note payload was accepted';
|
||||||
|
EXCEPTION WHEN check_violation THEN
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO repair_events (
|
||||||
|
repair_id, sequence, event_type, created_by, payload
|
||||||
|
) VALUES (
|
||||||
|
v_repair, 2, 'INITIAL_NOTE', v_actor, '{"note":"duplicate"}'::jsonb
|
||||||
|
);
|
||||||
|
RAISE EXCEPTION 'Duplicate event sequence was accepted';
|
||||||
|
EXCEPTION WHEN unique_violation THEN
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO repairs (miner_id, reason, opened_by)
|
||||||
|
VALUES (gen_random_uuid(), 'OTHER', v_actor);
|
||||||
|
RAISE EXCEPTION 'Unknown miner repair was accepted';
|
||||||
|
EXCEPTION WHEN foreign_key_violation THEN
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO repairs (miner_id, reason, opened_by)
|
||||||
|
VALUES (v_invalid_miner, 'OTHER', gen_random_uuid());
|
||||||
|
RAISE EXCEPTION 'Repair with an unknown actor was accepted';
|
||||||
|
EXCEPTION WHEN foreign_key_violation THEN
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO repairs (miner_id, reason, opened_by)
|
||||||
|
VALUES (v_invalid_miner, 'OTHER', NULL);
|
||||||
|
RAISE EXCEPTION 'Repair without an actor was accepted';
|
||||||
|
EXCEPTION WHEN not_null_violation THEN
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO repair_events (
|
||||||
|
repair_id, sequence, event_type, created_by, payload
|
||||||
|
) VALUES (
|
||||||
|
v_repair,
|
||||||
|
3,
|
||||||
|
'INITIAL_NOTE',
|
||||||
|
NULL,
|
||||||
|
'{"note":"missing actor"}'::jsonb
|
||||||
|
);
|
||||||
|
RAISE EXCEPTION 'Repair event without an actor was accepted';
|
||||||
|
EXCEPTION WHEN not_null_violation THEN
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO repair_events (
|
||||||
|
repair_id, sequence, event_type, created_by, payload
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(),
|
||||||
|
1,
|
||||||
|
'INITIAL_NOTE',
|
||||||
|
v_actor,
|
||||||
|
'{"note":"unknown repair"}'::jsonb
|
||||||
|
);
|
||||||
|
RAISE EXCEPTION 'Event for an unknown repair was accepted';
|
||||||
|
EXCEPTION WHEN foreign_key_violation THEN
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO repair_events (
|
||||||
|
repair_id, sequence, event_type, created_by, payload
|
||||||
|
) VALUES (
|
||||||
|
v_repair,
|
||||||
|
3,
|
||||||
|
'INITIAL_NOTE',
|
||||||
|
gen_random_uuid(),
|
||||||
|
'{"note":"unknown actor"}'::jsonb
|
||||||
|
);
|
||||||
|
RAISE EXCEPTION 'Repair event with an unknown actor was accepted';
|
||||||
|
EXCEPTION WHEN foreign_key_violation THEN
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
UPDATE miners SET lifecycle_status = 'unknown' WHERE id = v_miner;
|
||||||
|
RAISE EXCEPTION 'Unsupported miner lifecycle was accepted';
|
||||||
|
EXCEPTION WHEN check_violation THEN
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
INSERT INTO miners (id, asset_tag, status, lifecycle_status)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
'REPAIR-STATUS-' || status,
|
||||||
|
'unknown',
|
||||||
|
'active'
|
||||||
|
FROM (
|
||||||
|
VALUES
|
||||||
|
('OPEN'),
|
||||||
|
('IN_PROGRESS'),
|
||||||
|
('WAITING_FOR_PARTS'),
|
||||||
|
('READY_FOR_TEST'),
|
||||||
|
('COMPLETED'),
|
||||||
|
('CANCELLED')
|
||||||
|
) AS supported(status);
|
||||||
|
|
||||||
|
INSERT INTO repairs (
|
||||||
|
miner_id,
|
||||||
|
status,
|
||||||
|
reason,
|
||||||
|
opened_by,
|
||||||
|
completed_at,
|
||||||
|
completed_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
miners.id,
|
||||||
|
supported.status,
|
||||||
|
'OTHER',
|
||||||
|
actor.id,
|
||||||
|
CASE WHEN supported.status = 'COMPLETED' THEN now() END,
|
||||||
|
CASE WHEN supported.status = 'COMPLETED' THEN actor.id END
|
||||||
|
FROM (
|
||||||
|
VALUES
|
||||||
|
('OPEN'),
|
||||||
|
('IN_PROGRESS'),
|
||||||
|
('WAITING_FOR_PARTS'),
|
||||||
|
('READY_FOR_TEST'),
|
||||||
|
('COMPLETED'),
|
||||||
|
('CANCELLED')
|
||||||
|
) AS supported(status)
|
||||||
|
JOIN miners ON miners.asset_tag = 'REPAIR-STATUS-' || supported.status
|
||||||
|
CROSS JOIN (
|
||||||
|
SELECT id FROM users WHERE username = 'begin-repair-migration-test'
|
||||||
|
) actor;
|
||||||
|
|
||||||
|
INSERT INTO miners (id, asset_tag, status, lifecycle_status)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
'REPAIR-REASON-' || reason,
|
||||||
|
'unknown',
|
||||||
|
'active'
|
||||||
|
FROM (
|
||||||
|
VALUES
|
||||||
|
('HARDWARE_FAILURE'),
|
||||||
|
('LOW_HASHRATE'),
|
||||||
|
('CONTROL_BOARD'),
|
||||||
|
('HASHBOARD'),
|
||||||
|
('PSU'),
|
||||||
|
('FAN'),
|
||||||
|
('OVERHEATING'),
|
||||||
|
('NETWORKING'),
|
||||||
|
('PREVENTIVE_MAINTENANCE'),
|
||||||
|
('INSPECTION'),
|
||||||
|
('OTHER')
|
||||||
|
) AS supported(reason);
|
||||||
|
|
||||||
|
INSERT INTO repairs (
|
||||||
|
miner_id,
|
||||||
|
status,
|
||||||
|
reason,
|
||||||
|
opened_by,
|
||||||
|
completed_at,
|
||||||
|
completed_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
miners.id,
|
||||||
|
'COMPLETED',
|
||||||
|
supported.reason,
|
||||||
|
actor.id,
|
||||||
|
now(),
|
||||||
|
actor.id
|
||||||
|
FROM (
|
||||||
|
VALUES
|
||||||
|
('HARDWARE_FAILURE'),
|
||||||
|
('LOW_HASHRATE'),
|
||||||
|
('CONTROL_BOARD'),
|
||||||
|
('HASHBOARD'),
|
||||||
|
('PSU'),
|
||||||
|
('FAN'),
|
||||||
|
('OVERHEATING'),
|
||||||
|
('NETWORKING'),
|
||||||
|
('PREVENTIVE_MAINTENANCE'),
|
||||||
|
('INSPECTION'),
|
||||||
|
('OTHER')
|
||||||
|
) AS supported(reason)
|
||||||
|
JOIN miners ON miners.asset_tag = 'REPAIR-REASON-' || supported.reason
|
||||||
|
CROSS JOIN (
|
||||||
|
SELECT id FROM users WHERE username = 'begin-repair-migration-test'
|
||||||
|
) actor;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
|
||||||
|
echo "Checking downgrade refuses missing lifecycle history..."
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
INSERT INTO miners (id, asset_tag, status, lifecycle_status)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000001999',
|
||||||
|
'REPAIR-MIGRATION-INCONSISTENT',
|
||||||
|
'unknown',
|
||||||
|
'in_repair'
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
if downgrade_error="$(run_alembic downgrade 0009_inventory_asset_id_history 2>&1)"; then
|
||||||
|
echo "Downgrade accepted missing lifecycle history." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ "${downgrade_error}" == *"do not have exactly one active REPAIR_STARTED event"* ]]
|
||||||
|
failed_revision="$(run_alembic current 2>/dev/null)"
|
||||||
|
[[ "${failed_revision}" == *"0010_begin_repair"* ]]
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
DELETE FROM miners
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000001999';
|
||||||
|
SQL
|
||||||
|
echo "Downgrading and re-upgrading Begin Repair..."
|
||||||
|
run_alembic downgrade 0009_inventory_asset_id_history
|
||||||
|
downgrade_state="$(
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' \
|
||||||
|
<<'SQL'
|
||||||
|
SELECT
|
||||||
|
to_regclass('public.repairs') IS NULL
|
||||||
|
AND to_regclass('public.repair_events') IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM permissions WHERE key = 'repairs.create'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM miners WHERE lifecycle_status = 'in_repair'
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
SELECT lifecycle_status
|
||||||
|
FROM miners
|
||||||
|
WHERE asset_tag = 'REPAIR-MIGRATION-001'
|
||||||
|
) = 'active'
|
||||||
|
AND (
|
||||||
|
SELECT lifecycle_status
|
||||||
|
FROM miners
|
||||||
|
WHERE asset_tag = 'REPAIR-MIGRATION-002'
|
||||||
|
) = 'spare';
|
||||||
|
SQL
|
||||||
|
)"
|
||||||
|
[[ "${downgrade_state}" == "t" ]]
|
||||||
|
run_alembic upgrade head
|
||||||
|
reupgraded_revision="$(run_alembic current 2>/dev/null)"
|
||||||
|
[[ "${reupgraded_revision}" == *"0010_begin_repair"* ]]
|
||||||
|
|
||||||
|
echo "Migrating an empty database from base through head..."
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \
|
||||||
|
-c "CREATE DATABASE minerops_repair_empty"' >/dev/null
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
-e "POSTGRES_DB=${empty_database}" \
|
||||||
|
backend alembic upgrade head
|
||||||
|
|
||||||
|
empty_revision="$(
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
-e "POSTGRES_DB=${empty_database}" \
|
||||||
|
backend alembic current 2>/dev/null
|
||||||
|
)"
|
||||||
|
[[ "${empty_revision}" == *"0010_begin_repair"* ]]
|
||||||
|
|
||||||
|
echo "Begin Repair migration test passed."
|
||||||
@@ -88,7 +88,7 @@ health_json="$(cat "${health_file}")"
|
|||||||
|
|
||||||
echo "Checking migrations and required schema..."
|
echo "Checking migrations and required schema..."
|
||||||
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
||||||
[[ "${current_revision}" == *"0009_inventory_asset_id_history"* ]]
|
[[ "${current_revision}" == *"0010_begin_repair"* ]]
|
||||||
|
|
||||||
missing_tables="$(
|
missing_tables="$(
|
||||||
"${compose[@]}" exec -T postgres sh -c \
|
"${compose[@]}" exec -T postgres sh -c \
|
||||||
@@ -162,6 +162,41 @@ done
|
|||||||
[[ "$(status_for "${readonly_cookie}" GET /locations)" == "200" ]]
|
[[ "$(status_for "${readonly_cookie}" GET /locations)" == "200" ]]
|
||||||
[[ "$(status_for "${readonly_cookie}" POST /api/rooms '{"name":"Must Not Be Created"}')" == "403" ]]
|
[[ "$(status_for "${readonly_cookie}" POST /api/rooms '{"name":"Must Not Be Created"}')" == "403" ]]
|
||||||
|
|
||||||
|
echo "Checking Begin Repair permission and workflow..."
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
INSERT INTO miners (
|
||||||
|
id,
|
||||||
|
asset_tag,
|
||||||
|
status,
|
||||||
|
lifecycle_status,
|
||||||
|
first_seen_at,
|
||||||
|
last_seen_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000981',
|
||||||
|
'SMOKE-REPAIR-001',
|
||||||
|
'unknown',
|
||||||
|
'active',
|
||||||
|
NULL,
|
||||||
|
NULL
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
[[ "$(
|
||||||
|
status_for "${readonly_cookie}" POST /api/repairs \
|
||||||
|
'{"miner_id":"00000000-0000-0000-0000-000000000981","reason":"CONTROL_BOARD"}'
|
||||||
|
)" == "403" ]]
|
||||||
|
begin_repair_response="$(curl --fail --silent -b "${technician_cookie}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data '{"miner_id":"00000000-0000-0000-0000-000000000981","reason":"CONTROL_BOARD","notes":"Smoke test repair."}' \
|
||||||
|
"${base_url}/api/repairs")"
|
||||||
|
[[ "${begin_repair_response}" == *'"status":"OPEN"'* ]]
|
||||||
|
[[ "${begin_repair_response}" == *'"reason":"CONTROL_BOARD"'* ]]
|
||||||
|
[[ "$(
|
||||||
|
status_for "${technician_cookie}" POST /api/repairs \
|
||||||
|
'{"miner_id":"00000000-0000-0000-0000-000000000981","reason":"CONTROL_BOARD"}'
|
||||||
|
)" == "409" ]]
|
||||||
|
|
||||||
room_status="$(status_for "${technician_cookie}" POST /api/rooms '{"name":"Role Matrix Test Room"}')"
|
room_status="$(status_for "${technician_cookie}" POST /api/rooms '{"name":"Role Matrix Test Room"}')"
|
||||||
[[ "${room_status}" == "200" || "${room_status}" == "201" ]]
|
[[ "${room_status}" == "200" || "${room_status}" == "201" ]]
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ echo "Migrating disposable database..."
|
|||||||
echo "Running inventory repository and service tests..."
|
echo "Running inventory repository and service tests..."
|
||||||
"${compose[@]}" run --rm --no-deps \
|
"${compose[@]}" run --rm --no-deps \
|
||||||
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
backend python -m unittest discover -s /app/tests -v
|
backend python -m unittest -v \
|
||||||
|
tests.test_inventory_domain \
|
||||||
|
tests.test_inventory_router
|
||||||
|
|
||||||
echo "Inventory domain tests passed."
|
echo "Inventory domain tests passed."
|
||||||
Executable
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
|
||||||
|
project_name="${MINEROPS_REPAIR_TEST_PROJECT:-minerops-repair-domain-$$}"
|
||||||
|
compose=(
|
||||||
|
docker compose
|
||||||
|
--project-name "${project_name}"
|
||||||
|
--env-file .env.dev
|
||||||
|
-f compose.dev.yaml
|
||||||
|
)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
echo
|
||||||
|
echo "Repairs domain tests failed. Recent PostgreSQL logs:"
|
||||||
|
"${compose[@]}" logs --no-color --tail 120 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
if [[ ! -f .env.dev ]]; then
|
||||||
|
echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting disposable Repairs database..."
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
if "${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
>/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if (( attempt == 60 )); then
|
||||||
|
echo "PostgreSQL did not become ready." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Building Repairs test image..."
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
|
||||||
|
echo "Migrating disposable database..."
|
||||||
|
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||||
|
|
||||||
|
echo "Running Repairs repository, service, and router tests..."
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
|
backend python -m unittest -v \
|
||||||
|
tests.test_repairs_domain \
|
||||||
|
tests.test_repairs_router
|
||||||
|
|
||||||
|
echo "Repairs domain tests passed."
|
||||||
@@ -7,6 +7,8 @@ WITH expected(table_name) AS (
|
|||||||
('tags'),
|
('tags'),
|
||||||
('miner_tags'),
|
('miner_tags'),
|
||||||
('miner_asset_id_history'),
|
('miner_asset_id_history'),
|
||||||
|
('repairs'),
|
||||||
|
('repair_events'),
|
||||||
('miner_status_snapshots'),
|
('miner_status_snapshots'),
|
||||||
('miner_status_hourly_rollups'),
|
('miner_status_hourly_rollups'),
|
||||||
('miner_status_daily_rollups'),
|
('miner_status_daily_rollups'),
|
||||||
|
|||||||
Reference in new issue
Block a user