feat: add return to service workflow
This commit is contained in:
1 parent
72ad19a071
commit
f2b736b08e
19 files changed
+2883
-46
No files matched your search
@@ -0,0 +1,253 @@
|
||||
"""add return to service workflow
|
||||
|
||||
Revision ID: 0012_return_to_service
|
||||
Revises: 0011_repair_timeline
|
||||
Create Date: 2026-07-29
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0012_return_to_service"
|
||||
down_revision: Union[str, None] = "0011_repair_timeline"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
REPAIR_REASONS = (
|
||||
"HARDWARE_FAILURE",
|
||||
"LOW_HASHRATE",
|
||||
"CONTROL_BOARD",
|
||||
"HASHBOARD",
|
||||
"PSU",
|
||||
"FAN",
|
||||
"OVERHEATING",
|
||||
"NETWORKING",
|
||||
"PREVENTIVE_MAINTENANCE",
|
||||
"INSPECTION",
|
||||
"OTHER",
|
||||
)
|
||||
|
||||
|
||||
def _quoted(values: Sequence[str]) -> str:
|
||||
return ", ".join(f"'{value}'" for value in values)
|
||||
|
||||
|
||||
def _create_event_constraints() -> None:
|
||||
op.create_check_constraint(
|
||||
"ck_repair_events_type",
|
||||
"repair_events",
|
||||
"event_type IN ('REPAIR_STARTED', 'INITIAL_NOTE', "
|
||||
"'REPAIR_COMPLETED')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_repair_events_payload_shape",
|
||||
"repair_events",
|
||||
"("
|
||||
"event_type = 'REPAIR_STARTED' "
|
||||
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
|
||||
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
|
||||
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
|
||||
"AND payload ->> 'to_lifecycle' = 'in_repair'"
|
||||
") OR ("
|
||||
"event_type = 'INITIAL_NOTE' "
|
||||
"AND jsonb_typeof(payload -> 'note') = 'string' "
|
||||
"AND trim(payload ->> 'note') <> ''"
|
||||
") OR ("
|
||||
"event_type = 'REPAIR_COMPLETED' "
|
||||
"AND payload ?& ARRAY["
|
||||
"'previous_miner_lifecycle', 'resulting_miner_lifecycle'"
|
||||
"] "
|
||||
"AND payload ->> 'previous_miner_lifecycle' = 'in_repair' "
|
||||
"AND payload ->> 'resulting_miner_lifecycle' "
|
||||
"IN ('active', 'spare') "
|
||||
"AND (NOT payload ? 'pre_repair_lifecycle' "
|
||||
"OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) "
|
||||
"AND (NOT payload ? 'completion_summary' OR ("
|
||||
"jsonb_typeof(payload -> 'completion_summary') = 'string' "
|
||||
"AND trim(payload ->> 'completion_summary') <> '' "
|
||||
"AND char_length(payload ->> 'completion_summary') <= 4000"
|
||||
"))"
|
||||
")",
|
||||
)
|
||||
|
||||
|
||||
def _create_previous_event_constraints() -> None:
|
||||
op.create_check_constraint(
|
||||
"ck_repair_events_type",
|
||||
"repair_events",
|
||||
"event_type IN ('REPAIR_STARTED', 'INITIAL_NOTE')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_repair_events_payload_shape",
|
||||
"repair_events",
|
||||
"("
|
||||
"event_type = 'REPAIR_STARTED' "
|
||||
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
|
||||
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
|
||||
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
|
||||
"AND payload ->> 'to_lifecycle' = 'in_repair'"
|
||||
") OR ("
|
||||
"event_type = 'INITIAL_NOTE' "
|
||||
"AND jsonb_typeof(payload -> 'note') = 'string' "
|
||||
"AND trim(payload ->> 'note') <> ''"
|
||||
")",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO permissions (key, description)
|
||||
VALUES ('repairs.complete', 'Return repaired miners to service')
|
||||
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.complete'
|
||||
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"repairs",
|
||||
sa.Column("completion_summary", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"repairs",
|
||||
sa.Column("resulting_lifecycle", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_repairs_resulting_lifecycle",
|
||||
"repairs",
|
||||
"resulting_lifecycle IS NULL "
|
||||
"OR resulting_lifecycle IN ('active', 'spare')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_repairs_completion_summary",
|
||||
"repairs",
|
||||
"completion_summary IS NULL OR ("
|
||||
"trim(completion_summary) <> '' "
|
||||
"AND char_length(completion_summary) <= 4000"
|
||||
")",
|
||||
)
|
||||
# NOT VALID preserves truthful legacy COMPLETED rows whose destination was
|
||||
# never recorded, while PostgreSQL still enforces the check for every new
|
||||
# or updated row after this migration.
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
ALTER TABLE repairs
|
||||
ADD CONSTRAINT ck_repairs_completion_metadata_state
|
||||
CHECK (
|
||||
(
|
||||
status = 'COMPLETED'
|
||||
AND resulting_lifecycle IS NOT NULL
|
||||
)
|
||||
OR (
|
||||
status <> 'COMPLETED'
|
||||
AND completion_summary IS NULL
|
||||
AND resulting_lifecycle IS NULL
|
||||
)
|
||||
)
|
||||
NOT VALID
|
||||
"""
|
||||
)
|
||||
|
||||
op.drop_constraint(
|
||||
"ck_repair_events_payload_shape",
|
||||
"repair_events",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_repair_events_type",
|
||||
"repair_events",
|
||||
type_="check",
|
||||
)
|
||||
_create_event_constraints()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
DO $$
|
||||
DECLARE
|
||||
completion_count BIGINT;
|
||||
BEGIN
|
||||
SELECT count(*)
|
||||
INTO completion_count
|
||||
FROM repairs
|
||||
WHERE resulting_lifecycle IS NOT NULL
|
||||
OR completion_summary IS NOT NULL;
|
||||
|
||||
completion_count := completion_count + (
|
||||
SELECT count(*)
|
||||
FROM repair_events
|
||||
WHERE event_type = 'REPAIR_COMPLETED'
|
||||
);
|
||||
|
||||
IF completion_count > 0 THEN
|
||||
RAISE EXCEPTION USING
|
||||
MESSAGE = format(
|
||||
'Cannot downgrade Return to Service: %s completion records or events would lose authoritative history',
|
||||
completion_count
|
||||
),
|
||||
HINT = 'Preserve or explicitly migrate Return to Service history before downgrading; MinerOps will not invent or discard lifecycle, actor, summary, or event values.';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
op.drop_constraint(
|
||||
"ck_repair_events_payload_shape",
|
||||
"repair_events",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_repair_events_type",
|
||||
"repair_events",
|
||||
type_="check",
|
||||
)
|
||||
_create_previous_event_constraints()
|
||||
|
||||
op.drop_constraint(
|
||||
"ck_repairs_completion_metadata_state",
|
||||
"repairs",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_repairs_completion_summary",
|
||||
"repairs",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_repairs_resulting_lifecycle",
|
||||
"repairs",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_column("repairs", "resulting_lifecycle")
|
||||
op.drop_column("repairs", "completion_summary")
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id = (
|
||||
SELECT id FROM permissions WHERE key = 'repairs.complete'
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"DELETE FROM permissions WHERE key = 'repairs.complete'"
|
||||
)
|
||||
@@ -682,11 +682,22 @@ def repair_timeline_request(path: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def repair_completion_request(path: str) -> bool:
|
||||
parts = path.split("/")
|
||||
return (
|
||||
len(parts) == 5
|
||||
and parts[:3] == ["", "api", "repairs"]
|
||||
and parts[4] == "complete"
|
||||
)
|
||||
|
||||
|
||||
def required_api_permission(method: str, path: str) -> str | None:
|
||||
if path.startswith("/api/admin/"):
|
||||
return "users.manage"
|
||||
if method == "POST" and path == "/api/repairs":
|
||||
return "repairs.create"
|
||||
if method == "POST" and repair_completion_request(path):
|
||||
return "repairs.complete"
|
||||
if method == "GET" and repair_timeline_request(path):
|
||||
return "repairs.read"
|
||||
if method == "GET":
|
||||
|
||||
@@ -4,10 +4,14 @@ from app.repairs.exceptions import (
|
||||
ActiveRepairConflict,
|
||||
IncompatibleMinerLifecycle,
|
||||
InconsistentRepairState,
|
||||
RepairActiveMismatch,
|
||||
RepairActorRequired,
|
||||
RepairCompletionActorRequired,
|
||||
RepairConflict,
|
||||
RepairError,
|
||||
RepairMinerNotFound,
|
||||
RepairMinerNotInRepair,
|
||||
RepairNotActive,
|
||||
RepairNotFound,
|
||||
RepairPersistenceError,
|
||||
)
|
||||
@@ -15,7 +19,10 @@ from app.repairs.models import (
|
||||
BeginRepairRequest,
|
||||
BeginRepairResult,
|
||||
RepairActor,
|
||||
RepairCompleteRequest,
|
||||
RepairCompletedPayload,
|
||||
RepairDetail,
|
||||
RepairPostLifecycle,
|
||||
RepairReason,
|
||||
RepairStatus,
|
||||
RepairTimeline,
|
||||
@@ -30,14 +37,21 @@ __all__ = [
|
||||
"BeginRepairResult",
|
||||
"IncompatibleMinerLifecycle",
|
||||
"InconsistentRepairState",
|
||||
"RepairActiveMismatch",
|
||||
"RepairActor",
|
||||
"RepairActorRequired",
|
||||
"RepairCompleteRequest",
|
||||
"RepairCompletedPayload",
|
||||
"RepairCompletionActorRequired",
|
||||
"RepairConflict",
|
||||
"RepairDetail",
|
||||
"RepairError",
|
||||
"RepairMinerNotFound",
|
||||
"RepairMinerNotInRepair",
|
||||
"RepairNotActive",
|
||||
"RepairNotFound",
|
||||
"RepairPersistenceError",
|
||||
"RepairPostLifecycle",
|
||||
"RepairReason",
|
||||
"RepairRepository",
|
||||
"RepairService",
|
||||
|
||||
@@ -62,5 +62,48 @@ class RepairActorRequired(RepairError):
|
||||
super().__init__("An authenticated actor is required to begin repair")
|
||||
|
||||
|
||||
class RepairCompletionActorRequired(RepairError):
|
||||
"""Raised when Return to Service lacks an authenticated actor."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
"An authenticated actor is required to return a miner to service"
|
||||
)
|
||||
|
||||
|
||||
class RepairNotActive(RepairConflict):
|
||||
"""Raised when a repair is no longer active."""
|
||||
|
||||
def __init__(self, repair_id: object, repair_status: object):
|
||||
self.repair_id = repair_id
|
||||
self.repair_status = repair_status
|
||||
super().__init__(
|
||||
f"Repair is not active: {repair_id} ({repair_status})"
|
||||
)
|
||||
|
||||
|
||||
class RepairMinerNotInRepair(RepairConflict):
|
||||
"""Raised when a repair's miner has already left repair lifecycle."""
|
||||
|
||||
def __init__(self, miner_id: object, lifecycle_status: object):
|
||||
self.miner_id = miner_id
|
||||
self.lifecycle_status = lifecycle_status
|
||||
super().__init__(
|
||||
"Miner is not in repair lifecycle: "
|
||||
f"{miner_id} ({lifecycle_status})"
|
||||
)
|
||||
|
||||
|
||||
class RepairActiveMismatch(RepairConflict):
|
||||
"""Raised when the requested repair is not the miner's active repair."""
|
||||
|
||||
def __init__(self, repair_id: object, miner_id: object):
|
||||
self.repair_id = repair_id
|
||||
self.miner_id = miner_id
|
||||
super().__init__(
|
||||
f"Repair is not the active repair for miner: {repair_id}"
|
||||
)
|
||||
|
||||
|
||||
class RepairPersistenceError(RepairError):
|
||||
"""Raised when a required repair persistence operation fails."""
|
||||
@@ -28,8 +28,13 @@ RepairStatus = Literal[
|
||||
"COMPLETED",
|
||||
"CANCELLED",
|
||||
]
|
||||
RepairEventType = Literal["REPAIR_STARTED", "INITIAL_NOTE"]
|
||||
RepairEventType = Literal[
|
||||
"REPAIR_STARTED",
|
||||
"INITIAL_NOTE",
|
||||
"REPAIR_COMPLETED",
|
||||
]
|
||||
RepairEligibleLifecycle = Literal["active", "spare"]
|
||||
RepairPostLifecycle = Literal["active", "spare"]
|
||||
RepairMinerLifecycle = Literal["active", "spare", "retired", "in_repair"]
|
||||
|
||||
|
||||
@@ -41,6 +46,13 @@ class BeginRepairRequest(BaseModel):
|
||||
notes: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class RepairCompleteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
target_lifecycle: RepairPostLifecycle
|
||||
completion_summary: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class RepairStartedPayload(BaseModel):
|
||||
reason: RepairReason
|
||||
from_lifecycle: RepairEligibleLifecycle
|
||||
@@ -51,6 +63,13 @@ class InitialRepairNotePayload(BaseModel):
|
||||
note: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
class RepairCompletedPayload(BaseModel):
|
||||
previous_miner_lifecycle: Literal["in_repair"] = "in_repair"
|
||||
resulting_miner_lifecycle: RepairPostLifecycle
|
||||
pre_repair_lifecycle: RepairEligibleLifecycle | None = None
|
||||
completion_summary: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class BeginRepairResult(BaseModel):
|
||||
repair_id: UUID
|
||||
miner_id: UUID
|
||||
|
||||
@@ -39,7 +39,7 @@ def default_connection_factory() -> psycopg.Connection:
|
||||
|
||||
|
||||
class RepairRepository:
|
||||
"""Persistence boundary for the atomic Begin Repair workflow."""
|
||||
"""Persistence boundary for explicit atomic repair workflows."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -224,6 +224,124 @@ class RepairRepository:
|
||||
"Miner lifecycle transition did not update one record"
|
||||
)
|
||||
|
||||
def get_repair_identity(self, repair_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
|
||||
FROM repairs
|
||||
WHERE id = %s
|
||||
""",
|
||||
(repair_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def lock_repair_for_update(self, repair_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,
|
||||
completion_summary,
|
||||
resulting_lifecycle
|
||||
FROM repairs
|
||||
WHERE id = %s
|
||||
FOR UPDATE
|
||||
""",
|
||||
(repair_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_next_event_sequence(self, repair_id: UUID) -> int:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(max(sequence), 0) + 1
|
||||
FROM repair_events
|
||||
WHERE repair_id = %s
|
||||
""",
|
||||
(repair_id,),
|
||||
)
|
||||
return int(cur.fetchone()[0])
|
||||
|
||||
def get_pre_repair_lifecycle(self, repair_id: UUID) -> str | None:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT event.payload ->> 'from_lifecycle'
|
||||
FROM repair_events AS event
|
||||
WHERE event.repair_id = %s
|
||||
AND event.event_type = 'REPAIR_STARTED'
|
||||
ORDER BY event.sequence
|
||||
LIMIT 2
|
||||
""",
|
||||
(repair_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if len(rows) != 1:
|
||||
return None
|
||||
lifecycle_status = rows[0][0]
|
||||
if lifecycle_status not in {"active", "spare"}:
|
||||
return None
|
||||
return str(lifecycle_status)
|
||||
|
||||
def complete_repair(
|
||||
self,
|
||||
repair_id: UUID,
|
||||
*,
|
||||
completed_by: UUID,
|
||||
completion_summary: str | None,
|
||||
resulting_lifecycle: str,
|
||||
) -> dict | None:
|
||||
with self._connection_scope(write=True) as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE repairs
|
||||
SET status = 'COMPLETED',
|
||||
completed_at = now(),
|
||||
completed_by = %s,
|
||||
completion_summary = %s,
|
||||
resulting_lifecycle = %s
|
||||
WHERE id = %s
|
||||
AND status = ANY(%s)
|
||||
RETURNING
|
||||
id,
|
||||
miner_id,
|
||||
status,
|
||||
reason,
|
||||
opened_at,
|
||||
opened_by,
|
||||
completed_at,
|
||||
completed_by,
|
||||
completion_summary,
|
||||
resulting_lifecycle
|
||||
""",
|
||||
(
|
||||
completed_by,
|
||||
completion_summary,
|
||||
resulting_lifecycle,
|
||||
repair_id,
|
||||
list(ACTIVE_REPAIR_STATUSES),
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_repair_detail(self, repair_id: UUID) -> dict | None:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.identity import require_permission
|
||||
from app.repairs.dependencies import get_repair_service
|
||||
from app.repairs.exceptions import (
|
||||
RepairActorRequired,
|
||||
RepairCompletionActorRequired,
|
||||
RepairConflict,
|
||||
RepairMinerNotFound,
|
||||
RepairNotFound,
|
||||
@@ -16,6 +17,7 @@ from app.repairs.exceptions import (
|
||||
from app.repairs.models import (
|
||||
BeginRepairRequest,
|
||||
BeginRepairResponse,
|
||||
RepairCompleteRequest,
|
||||
RepairDetailResponse,
|
||||
RepairTimelineResponse,
|
||||
)
|
||||
@@ -25,6 +27,7 @@ from app.repairs.service import RepairService
|
||||
router = APIRouter(prefix="/api/repairs", tags=["repairs"])
|
||||
require_repair_create = require_permission("repairs.create")
|
||||
require_repair_read = require_permission("repairs.read")
|
||||
require_repair_complete = require_permission("repairs.complete")
|
||||
|
||||
RepairServiceDependency = Annotated[
|
||||
RepairService,
|
||||
@@ -32,6 +35,7 @@ RepairServiceDependency = Annotated[
|
||||
]
|
||||
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
|
||||
RepairReadUser = Annotated[dict, Depends(require_repair_read)]
|
||||
RepairCompleteUser = Annotated[dict, Depends(require_repair_complete)]
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -68,6 +72,46 @@ def begin_repair(
|
||||
return BeginRepairResponse(data=repair)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{repair_id}/complete",
|
||||
response_model=RepairDetailResponse,
|
||||
)
|
||||
def complete_repair(
|
||||
repair_id: UUID,
|
||||
request: RepairCompleteRequest,
|
||||
service: RepairServiceDependency,
|
||||
current_user: RepairCompleteUser,
|
||||
) -> RepairDetailResponse:
|
||||
try:
|
||||
repair = service.complete_repair(
|
||||
repair_id,
|
||||
request,
|
||||
actor_user_id=UUID(str(current_user["id"])),
|
||||
)
|
||||
except RepairNotFound as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Repair not found",
|
||||
) from exc
|
||||
except RepairMinerNotFound as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Repair miner no longer exists",
|
||||
) from exc
|
||||
except RepairConflict as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except RepairCompletionActorRequired as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
return RepairDetailResponse(data=repair)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{repair_id}",
|
||||
response_model=RepairDetailResponse,
|
||||
|
||||
+115
-24
@@ -6,15 +6,22 @@ from app.repairs.exceptions import (
|
||||
ActiveRepairConflict,
|
||||
IncompatibleMinerLifecycle,
|
||||
InconsistentRepairState,
|
||||
RepairActiveMismatch,
|
||||
RepairActorRequired,
|
||||
RepairCompletionActorRequired,
|
||||
RepairMinerNotFound,
|
||||
RepairMinerNotInRepair,
|
||||
RepairNotActive,
|
||||
RepairNotFound,
|
||||
RepairPersistenceError,
|
||||
)
|
||||
from app.repairs.models import (
|
||||
BeginRepairRequest,
|
||||
BeginRepairResult,
|
||||
InitialRepairNotePayload,
|
||||
RepairActor,
|
||||
RepairCompleteRequest,
|
||||
RepairCompletedPayload,
|
||||
RepairDetail,
|
||||
RepairTimeline,
|
||||
RepairTimelineEvent,
|
||||
@@ -24,6 +31,9 @@ from app.repairs.repository import RepairRepository
|
||||
|
||||
|
||||
ELIGIBLE_LIFECYCLE_STATUSES = frozenset({"active", "spare"})
|
||||
ACTIVE_REPAIR_STATUSES = frozenset(
|
||||
{"OPEN", "IN_PROGRESS", "WAITING_FOR_PARTS", "READY_FOR_TEST"}
|
||||
)
|
||||
REPAIR_LIFECYCLE_STATUS = "in_repair"
|
||||
|
||||
|
||||
@@ -40,6 +50,32 @@ class RepairService:
|
||||
normalized = str(note).strip()
|
||||
return normalized or None
|
||||
|
||||
@staticmethod
|
||||
def _map_repair_detail(repair: dict) -> RepairDetail:
|
||||
closed_by = None
|
||||
if repair["closed_by_user_id"] is not None:
|
||||
closed_by = RepairActor(
|
||||
user_id=repair["closed_by_user_id"],
|
||||
display_name=repair["closed_by_display_name"],
|
||||
)
|
||||
|
||||
return RepairDetail(
|
||||
repair_id=repair["repair_id"],
|
||||
miner_id=repair["miner_id"],
|
||||
miner_asset_id=repair["miner_asset_id"],
|
||||
status=repair["status"],
|
||||
reason=repair["reason"],
|
||||
opened_at=repair["opened_at"],
|
||||
opened_by=RepairActor(
|
||||
user_id=repair["opened_by_user_id"],
|
||||
display_name=repair["opened_by_display_name"],
|
||||
),
|
||||
closed_at=repair["closed_at"],
|
||||
closed_by=closed_by,
|
||||
miner_lifecycle=repair["miner_lifecycle"],
|
||||
latest_event_sequence=repair["latest_event_sequence"],
|
||||
)
|
||||
|
||||
def begin_repair(
|
||||
self,
|
||||
request: BeginRepairRequest,
|
||||
@@ -107,34 +143,89 @@ class RepairService:
|
||||
opened_by=repair["opened_by"],
|
||||
)
|
||||
|
||||
def complete_repair(
|
||||
self,
|
||||
repair_id: UUID,
|
||||
request: RepairCompleteRequest,
|
||||
*,
|
||||
actor_user_id: UUID | None,
|
||||
) -> RepairDetail:
|
||||
if actor_user_id is None:
|
||||
raise RepairCompletionActorRequired()
|
||||
|
||||
completion_summary = self.normalize_note(request.completion_summary)
|
||||
|
||||
with self.repository.transaction() as repository:
|
||||
repair_identity = repository.get_repair_identity(repair_id)
|
||||
if repair_identity is None:
|
||||
raise RepairNotFound(repair_id)
|
||||
|
||||
miner_id = repair_identity["miner_id"]
|
||||
# Begin Repair also locks the miner first. Keeping this order makes
|
||||
# competing lifecycle workflows serialize on the durable asset.
|
||||
miner = repository.lock_miner_for_update(miner_id)
|
||||
if miner is None:
|
||||
raise RepairMinerNotFound(miner_id)
|
||||
|
||||
repair = repository.lock_repair_for_update(repair_id)
|
||||
if repair is None:
|
||||
raise RepairNotFound(repair_id)
|
||||
if repair["miner_id"] != miner_id:
|
||||
raise RepairActiveMismatch(repair_id, miner_id)
|
||||
if repair["status"] not in ACTIVE_REPAIR_STATUSES:
|
||||
raise RepairNotActive(repair_id, repair["status"])
|
||||
|
||||
active_repair = repository.get_active_repair(miner_id)
|
||||
if active_repair is None or active_repair["id"] != repair_id:
|
||||
raise RepairActiveMismatch(repair_id, miner_id)
|
||||
|
||||
lifecycle_status = str(miner["lifecycle_status"])
|
||||
if lifecycle_status != REPAIR_LIFECYCLE_STATUS:
|
||||
raise RepairMinerNotInRepair(miner_id, lifecycle_status)
|
||||
|
||||
next_sequence = repository.get_next_event_sequence(repair_id)
|
||||
pre_repair_lifecycle = repository.get_pre_repair_lifecycle(
|
||||
repair_id
|
||||
)
|
||||
completed = repository.complete_repair(
|
||||
repair_id,
|
||||
completed_by=actor_user_id,
|
||||
completion_summary=completion_summary,
|
||||
resulting_lifecycle=request.target_lifecycle,
|
||||
)
|
||||
if completed is None:
|
||||
raise RepairNotActive(repair_id, repair["status"])
|
||||
|
||||
repository.set_miner_lifecycle(
|
||||
miner_id,
|
||||
request.target_lifecycle,
|
||||
)
|
||||
payload = RepairCompletedPayload(
|
||||
resulting_miner_lifecycle=request.target_lifecycle,
|
||||
pre_repair_lifecycle=pre_repair_lifecycle,
|
||||
completion_summary=completion_summary,
|
||||
)
|
||||
repository.create_event(
|
||||
repair_id,
|
||||
sequence=next_sequence,
|
||||
event_type="REPAIR_COMPLETED",
|
||||
created_by=actor_user_id,
|
||||
payload=payload.model_dump(mode="json", exclude_none=True),
|
||||
)
|
||||
|
||||
detail = repository.get_repair_detail(repair_id)
|
||||
if detail is None:
|
||||
raise RepairPersistenceError(
|
||||
"Completed repair could not be read in its transaction"
|
||||
)
|
||||
|
||||
return self._map_repair_detail(detail)
|
||||
|
||||
def get_repair(self, repair_id: UUID) -> RepairDetail:
|
||||
repair = self.repository.get_repair_detail(repair_id)
|
||||
if repair is None:
|
||||
raise RepairNotFound(repair_id)
|
||||
|
||||
closed_by = None
|
||||
if repair["closed_by_user_id"] is not None:
|
||||
closed_by = RepairActor(
|
||||
user_id=repair["closed_by_user_id"],
|
||||
display_name=repair["closed_by_display_name"],
|
||||
)
|
||||
|
||||
return RepairDetail(
|
||||
repair_id=repair["repair_id"],
|
||||
miner_id=repair["miner_id"],
|
||||
miner_asset_id=repair["miner_asset_id"],
|
||||
status=repair["status"],
|
||||
reason=repair["reason"],
|
||||
opened_at=repair["opened_at"],
|
||||
opened_by=RepairActor(
|
||||
user_id=repair["opened_by_user_id"],
|
||||
display_name=repair["opened_by_display_name"],
|
||||
),
|
||||
closed_at=repair["closed_at"],
|
||||
closed_by=closed_by,
|
||||
miner_lifecycle=repair["miner_lifecycle"],
|
||||
latest_event_sequence=repair["latest_event_sequence"],
|
||||
)
|
||||
return self._map_repair_detail(repair)
|
||||
|
||||
def get_repair_timeline(self, repair_id: UUID) -> RepairTimeline:
|
||||
events = self.repository.get_repair_events(repair_id)
|
||||
|
||||
@@ -715,6 +715,11 @@ class RepairTimelineArchitectureTests(unittest.TestCase):
|
||||
"minerops_repair_parts_summary_api",
|
||||
),
|
||||
("POST", "/api/repairs", "begin_repair"),
|
||||
(
|
||||
"POST",
|
||||
"/api/repairs/{repair_id}/complete",
|
||||
"complete_repair",
|
||||
),
|
||||
("GET", "/api/repairs/{repair_id}", "get_repair"),
|
||||
(
|
||||
"GET",
|
||||
|
||||
@@ -232,11 +232,13 @@ class RepairRouterTests(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertIsNone(self.service.request)
|
||||
|
||||
def test_router_registers_exactly_one_post_route(self):
|
||||
def test_router_registers_exact_begin_repair_route(self):
|
||||
routes = [
|
||||
(route.path, route.methods)
|
||||
for route in router.routes
|
||||
if hasattr(route, "methods") and "POST" in route.methods
|
||||
if hasattr(route, "methods")
|
||||
and "POST" in route.methods
|
||||
and route.path == "/api/repairs"
|
||||
]
|
||||
self.assertEqual(routes, [("/api/repairs", {"POST"})])
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+5
-5
@@ -99,11 +99,11 @@ durable asset record. Repairs, maintenance, work orders, parts usage, equipment
|
||||
movement, testing, and return-to-service history become coherent operational
|
||||
records rather than disconnected actions.
|
||||
|
||||
The Begin Repair and read-only Repair Timeline workflows are complete. An
|
||||
authorized actor can open a repair against a durable miner UUID, record
|
||||
sequenced starting events, and move the miner into repair atomically. Authorized
|
||||
readers can inspect the repair and its ordered immutable events. Later repair
|
||||
lifecycle workflows remain planned.
|
||||
The Begin Repair, read-only Repair Timeline, and Return to Service workflows are
|
||||
complete. An authorized actor can open a repair against a durable miner UUID,
|
||||
record sequenced events, and move the miner into or out of repair atomically.
|
||||
Authorized readers can inspect the repair and its ordered immutable events.
|
||||
Later repair lifecycle workflows remain planned.
|
||||
|
||||
Technician experience is a priority. Responsive and QR-assisted workflows may
|
||||
reduce lookup and data-entry effort where they reinforce the same asset record.
|
||||
|
||||
@@ -15,10 +15,10 @@ and immutable history.
|
||||
|
||||
- [x] Begin Repair (`POST /api/repairs`)
|
||||
- [x] Repair detail and timeline read APIs
|
||||
- [x] Return to Service (`POST /api/repairs/{repair_id}/complete`)
|
||||
|
||||
## Planned
|
||||
|
||||
- [ ] Return to Service
|
||||
- [ ] Control Board Replacement
|
||||
- [ ] Repair Parts Tracking
|
||||
|
||||
@@ -41,9 +41,8 @@ not make these workflows complete.
|
||||
|
||||
## Candidate Task Order
|
||||
|
||||
1. `003-return-to-service.md`
|
||||
2. `004-control-board-replacement.md`
|
||||
3. `005-repair-parts-tracking.md`
|
||||
1. `004-control-board-replacement.md`
|
||||
2. `005-repair-parts-tracking.md`
|
||||
|
||||
These are proposed future files under `docs/tasks/`. They do not exist yet and
|
||||
must not be treated as approved specifications until they are created and
|
||||
|
||||
@@ -0,0 +1,814 @@
|
||||
# Task 003: Return to Service
|
||||
|
||||
## Status
|
||||
|
||||
Ready for implementation.
|
||||
|
||||
## Objective
|
||||
|
||||
Complete the MinerOps repair lifecycle by adding an explicit **Return to Service** operation that closes an active repair, records an immutable completion event, and transitions the miner from `in_repair` to an eligible post-repair lifecycle state.
|
||||
|
||||
The operation must be atomic, permission-controlled, auditable, safe under concurrent requests, and compatible with the existing Begin Repair and Repair Timeline implementations.
|
||||
|
||||
## Product Intent
|
||||
|
||||
A repair should not remain open indefinitely after work is complete.
|
||||
|
||||
An authorized operator must be able to:
|
||||
|
||||
1. identify the active repair,
|
||||
2. confirm the miner is ready to leave repair,
|
||||
3. select or confirm the resulting lifecycle state,
|
||||
4. optionally record a concise completion summary,
|
||||
5. close the repair,
|
||||
6. transition the miner out of `in_repair`,
|
||||
7. preserve immutable operational history.
|
||||
|
||||
This is an operational command, not generic repair editing.
|
||||
|
||||
## Background
|
||||
|
||||
Task 001 introduced the Begin Repair workflow.
|
||||
|
||||
Task 002 introduced repair detail and immutable repair timeline read APIs.
|
||||
|
||||
The current repair model supports:
|
||||
|
||||
- creating an active repair,
|
||||
- moving a miner into `in_repair`,
|
||||
- preserving the miner's pre-repair lifecycle in the repair-start event payload,
|
||||
- enforcing one active repair per miner,
|
||||
- reading repair details,
|
||||
- reading ordered repair events.
|
||||
|
||||
Task 003 completes that lifecycle.
|
||||
|
||||
The implementation must inspect the repository and reuse current names, models, exception conventions, migration patterns, event sequencing, and transaction infrastructure rather than inventing parallel abstractions.
|
||||
|
||||
## Scope
|
||||
|
||||
This task includes:
|
||||
|
||||
- a new `repairs.complete` permission,
|
||||
- a new explicit Return to Service API,
|
||||
- request and response models,
|
||||
- service-layer completion workflow,
|
||||
- focused repository methods,
|
||||
- repair completion persistence,
|
||||
- miner lifecycle transition,
|
||||
- immutable completion event,
|
||||
- concurrency protection,
|
||||
- migration and permission assignment,
|
||||
- full authorization, domain, migration, routing, and regression tests,
|
||||
- release-document update after implementation passes.
|
||||
|
||||
## Explicitly Out of Scope
|
||||
|
||||
Do not implement:
|
||||
|
||||
- generic repair update or patch endpoints,
|
||||
- reopening a completed repair,
|
||||
- cancelling a repair,
|
||||
- abandoning a repair,
|
||||
- repair diagnosis fields,
|
||||
- repair photos,
|
||||
- repair parts consumption,
|
||||
- technician assignment,
|
||||
- work orders,
|
||||
- control board replacement,
|
||||
- MAC transfer,
|
||||
- automatic monitoring confirmation,
|
||||
- automated burn-in or test workflows,
|
||||
- bulk Return to Service,
|
||||
- notifications,
|
||||
- UI changes unless the repository already requires generated API client updates,
|
||||
- changes to unrelated legacy repair routes.
|
||||
|
||||
Those belong in later tasks.
|
||||
|
||||
## User and Operator Workflow
|
||||
|
||||
A typical flow is:
|
||||
|
||||
1. A technician opens the active repair.
|
||||
2. The technician completes physical work and any required validation.
|
||||
3. The technician invokes Return to Service.
|
||||
4. The technician chooses the miner's destination lifecycle:
|
||||
- the original pre-repair lifecycle, when eligible,
|
||||
- or another explicitly allowed lifecycle.
|
||||
5. The technician may enter a completion summary.
|
||||
6. MinerOps atomically:
|
||||
- verifies the repair is active,
|
||||
- verifies the miner is still in repair,
|
||||
- validates the destination lifecycle,
|
||||
- closes the repair,
|
||||
- transitions the miner,
|
||||
- appends a completion event.
|
||||
7. The repair becomes read-only historical data.
|
||||
|
||||
## API
|
||||
|
||||
Add one explicit action route.
|
||||
|
||||
```http
|
||||
POST /api/repairs/{repair_id}/complete
|
||||
```
|
||||
|
||||
Use `complete` unless existing route naming conventions make `return-to-service` materially more consistent. Do not add both aliases.
|
||||
|
||||
### Authentication
|
||||
|
||||
Required.
|
||||
|
||||
### Permission
|
||||
|
||||
```text
|
||||
repairs.complete
|
||||
```
|
||||
|
||||
### Path Parameter
|
||||
|
||||
- `repair_id`: UUID
|
||||
|
||||
### Request Body
|
||||
|
||||
The request should contain:
|
||||
|
||||
```json
|
||||
{
|
||||
"target_lifecycle": "active",
|
||||
"completion_summary": "Replaced failed fan and completed load test."
|
||||
}
|
||||
```
|
||||
|
||||
Use repository-established lifecycle values and naming.
|
||||
|
||||
#### `target_lifecycle`
|
||||
|
||||
Required unless the service can unambiguously restore the original lifecycle under existing product rules.
|
||||
|
||||
Preferred design: require an explicit target lifecycle in the request so the operator confirms the resulting operational state.
|
||||
|
||||
Allowed target values must be restricted to valid non-repair lifecycle states.
|
||||
|
||||
At minimum:
|
||||
|
||||
- `active`
|
||||
- `inactive`
|
||||
|
||||
Also allow other existing non-terminal lifecycle states only if they are already legitimate post-repair states in the current domain model.
|
||||
|
||||
Never allow:
|
||||
|
||||
- `in_repair`
|
||||
- unknown values
|
||||
- deprecated values
|
||||
- terminal or destructive states that are not valid Return to Service destinations
|
||||
|
||||
Do not invent new lifecycle states.
|
||||
|
||||
#### `completion_summary`
|
||||
|
||||
Optional.
|
||||
|
||||
Requirements:
|
||||
|
||||
- trim leading and trailing whitespace,
|
||||
- empty after trimming becomes `null`,
|
||||
- enforce a sensible maximum length using current project conventions,
|
||||
- preserve text exactly after normalization,
|
||||
- do not interpret it as structured diagnosis or parts data.
|
||||
|
||||
### Successful Response
|
||||
|
||||
Return the completed repair using the existing repair-detail response model where practical.
|
||||
|
||||
The response must allow the client to determine:
|
||||
|
||||
- repair UUID,
|
||||
- miner UUID,
|
||||
- completed status,
|
||||
- completion timestamp,
|
||||
- completing actor where exposed by current conventions,
|
||||
- resulting miner lifecycle,
|
||||
- timeline or event count only if already part of the established response.
|
||||
|
||||
Do not expand the response with unrelated miner details.
|
||||
|
||||
### Status Codes
|
||||
|
||||
#### `200 OK`
|
||||
|
||||
Preferred when returning the completed repair representation.
|
||||
|
||||
Use `201` only if existing operational-command conventions clearly require it. Do not use `204` if the existing repair response can be returned cleanly.
|
||||
|
||||
#### `401 Unauthorized`
|
||||
|
||||
Unauthenticated request.
|
||||
|
||||
#### `403 Forbidden`
|
||||
|
||||
Authenticated actor lacks `repairs.complete`.
|
||||
|
||||
#### `404 Not Found`
|
||||
|
||||
The repair UUID does not exist.
|
||||
|
||||
Do not reveal authorization-sensitive information beyond existing project behavior.
|
||||
|
||||
#### `409 Conflict`
|
||||
|
||||
Use for valid requests that conflict with current domain state, including:
|
||||
|
||||
- repair is already completed,
|
||||
- repair is otherwise not active,
|
||||
- miner is no longer in `in_repair`,
|
||||
- repair does not match the miner's active repair,
|
||||
- target lifecycle is known but not eligible for Return to Service,
|
||||
- a concurrent request completed the repair first.
|
||||
|
||||
#### `422 Unprocessable Entity`
|
||||
|
||||
Use for malformed UUIDs, invalid JSON, missing required fields, unknown lifecycle values, excessive summary length, or other request-validation errors.
|
||||
|
||||
## Permissions
|
||||
|
||||
Add:
|
||||
|
||||
```text
|
||||
repairs.complete
|
||||
```
|
||||
|
||||
### Intended Role Assignment
|
||||
|
||||
Follow current role conventions and migrations.
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- Administrator: allowed
|
||||
- Technician: allowed
|
||||
- Read Only / Viewer: denied
|
||||
- Auditor: denied unless current policy explicitly grants operational actions
|
||||
- Unauthenticated: denied
|
||||
|
||||
Do not grant the permission to every role by default.
|
||||
|
||||
Do not overload `repairs.create`.
|
||||
|
||||
## Domain Rules
|
||||
|
||||
### Active Repair Requirement
|
||||
|
||||
Only an active repair can be completed.
|
||||
|
||||
A completed repair cannot be completed again.
|
||||
|
||||
### Miner Lifecycle Requirement
|
||||
|
||||
The associated miner must currently be in `in_repair`.
|
||||
|
||||
If it is not, return a domain conflict and do not modify anything.
|
||||
|
||||
### Destination Lifecycle
|
||||
|
||||
The destination must:
|
||||
|
||||
- exist in the current lifecycle domain,
|
||||
- not equal `in_repair`,
|
||||
- be explicitly allowed as a post-repair state,
|
||||
- be applied only after all validation succeeds.
|
||||
|
||||
### Original Lifecycle
|
||||
|
||||
The Begin Repair workflow preserves the previous lifecycle in the `REPAIR_STARTED` event payload.
|
||||
|
||||
The service may use this value for:
|
||||
|
||||
- validation,
|
||||
- response context,
|
||||
- defaulting only if the API contract and existing model conventions support it safely.
|
||||
|
||||
The implementation must not assume every miner should return to `active`.
|
||||
|
||||
If restoration of the previous lifecycle is supported, preserve exact historical meaning. Do not infer or fabricate it when missing.
|
||||
|
||||
### Completion Is Final
|
||||
|
||||
Once completed:
|
||||
|
||||
- the repair is historical,
|
||||
- the completion event is immutable,
|
||||
- the active-repair constraint is released,
|
||||
- a later repair must be a new repair record.
|
||||
|
||||
### No Silent Repair Mutation
|
||||
|
||||
Do not edit prior events.
|
||||
|
||||
Do not replace the start event.
|
||||
|
||||
Do not overwrite historical lifecycle values.
|
||||
|
||||
## Architecture
|
||||
|
||||
Preserve:
|
||||
|
||||
```text
|
||||
Router -> Service -> Repository -> PostgreSQL
|
||||
```
|
||||
|
||||
### Router Responsibilities
|
||||
|
||||
The router should:
|
||||
|
||||
- define the action route,
|
||||
- validate the UUID and request model,
|
||||
- enforce authentication and `repairs.complete`,
|
||||
- call the service,
|
||||
- map established domain exceptions to HTTP responses,
|
||||
- return the established response model.
|
||||
|
||||
The router must not:
|
||||
|
||||
- contain SQL,
|
||||
- manage commits or rollbacks,
|
||||
- choose lifecycle transitions,
|
||||
- construct event sequence values,
|
||||
- read actor UUIDs from the request body.
|
||||
|
||||
### Service Responsibilities
|
||||
|
||||
The service should:
|
||||
|
||||
- load and lock the repair,
|
||||
- verify it exists and is active,
|
||||
- load and lock the associated miner when required,
|
||||
- verify the miner is in repair,
|
||||
- validate target lifecycle,
|
||||
- normalize summary,
|
||||
- close the repair,
|
||||
- update miner lifecycle,
|
||||
- append the completion event,
|
||||
- return the completed domain result,
|
||||
- ensure the operation is atomic.
|
||||
|
||||
### Repository Responsibilities
|
||||
|
||||
Repositories should provide focused methods for:
|
||||
|
||||
- retrieving an active repair for update,
|
||||
- retrieving the associated miner for update,
|
||||
- marking a repair completed,
|
||||
- updating lifecycle,
|
||||
- obtaining or allocating the next repair event sequence,
|
||||
- appending a repair event,
|
||||
- reading the completed repair representation efficiently.
|
||||
|
||||
Do not introduce a broad generic repair-update method.
|
||||
|
||||
## Database and Migration
|
||||
|
||||
A migration is required if the current schema does not already support all required completion data and permission assignment.
|
||||
|
||||
Inspect the existing repair table before adding columns.
|
||||
|
||||
### Preferred Repair Completion Fields
|
||||
|
||||
If not already present, add only the fields needed to represent completion authoritatively:
|
||||
|
||||
- `completed_at`: timezone-aware timestamp, nullable while active
|
||||
- `completed_by_user_id`: UUID foreign key, nullable while active
|
||||
- `completion_summary`: nullable text
|
||||
- `resulting_lifecycle`: constrained lifecycle value, nullable while active
|
||||
|
||||
Use existing naming conventions if equivalent columns already exist.
|
||||
|
||||
### Status Representation
|
||||
|
||||
Do not add redundant mutable status data if active versus completed can be derived safely from established fields.
|
||||
|
||||
If the current repair schema already uses a status column, reuse and constrain it rather than introducing a second status mechanism.
|
||||
|
||||
### Constraints
|
||||
|
||||
Enforce critical invariants in PostgreSQL where practical.
|
||||
|
||||
Examples:
|
||||
|
||||
- active repair has no completion timestamp,
|
||||
- completed repair has a completion timestamp,
|
||||
- completed repair has a completing actor,
|
||||
- resulting lifecycle is null while active and present when completed,
|
||||
- resulting lifecycle cannot equal `in_repair`,
|
||||
- completion summary length is constrained if the project uses database length checks,
|
||||
- one active repair per miner remains enforced.
|
||||
|
||||
Adapt the exact checks to the existing schema.
|
||||
|
||||
### Foreign Keys
|
||||
|
||||
`completed_by_user_id` should reference the established user table.
|
||||
|
||||
Use the project's existing historical-record deletion policy. Operational history should not silently disappear.
|
||||
|
||||
### Indexes
|
||||
|
||||
Add only indexes justified by actual read patterns.
|
||||
|
||||
Likely useful:
|
||||
|
||||
- existing active-repair partial uniqueness,
|
||||
- lookup by miner,
|
||||
- lookup by completion timestamp only if current repair queue/history queries need it.
|
||||
|
||||
Avoid speculative indexes.
|
||||
|
||||
### Migration Data Handling
|
||||
|
||||
Existing active repairs must remain active.
|
||||
|
||||
Existing completed repairs, if legacy data exists, must be handled without inventing actor IDs, summaries, or lifecycle states.
|
||||
|
||||
If new non-null completion invariants cannot be truthfully applied to legacy completed rows:
|
||||
|
||||
- use nullable historical fields with conditional checks,
|
||||
- preserve legacy data,
|
||||
- document the limitation,
|
||||
- do not fabricate values.
|
||||
|
||||
### Downgrade
|
||||
|
||||
The downgrade must restore the previous schema safely.
|
||||
|
||||
Before dropping completion columns or constraints, restore miner lifecycle only if the downgrade actually reverses newly created completion data and there is a truthful historical source.
|
||||
|
||||
Do not map every historical completed repair to `active`.
|
||||
|
||||
Do not invent original lifecycle values.
|
||||
|
||||
If a fully safe downgrade is impossible once real completion records exist, use the project's established explicit-refusal pattern rather than destructive guessing.
|
||||
|
||||
## Events and History
|
||||
|
||||
Append one immutable completion event.
|
||||
|
||||
Use the existing repair event naming conventions. Preferred semantic name:
|
||||
|
||||
```text
|
||||
REPAIR_COMPLETED
|
||||
```
|
||||
|
||||
Reuse an existing equivalent event type if already defined.
|
||||
|
||||
### Event Fields
|
||||
|
||||
The event must include:
|
||||
|
||||
- event UUID,
|
||||
- repair UUID,
|
||||
- next explicit sequence number,
|
||||
- authenticated actor UUID,
|
||||
- server/database timestamp,
|
||||
- structured payload.
|
||||
|
||||
### Payload
|
||||
|
||||
Include only durable, meaningful values.
|
||||
|
||||
Recommended payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"previous_miner_lifecycle": "in_repair",
|
||||
"resulting_miner_lifecycle": "active",
|
||||
"pre_repair_lifecycle": "inactive",
|
||||
"completion_summary": "Replaced failed fan and completed load test."
|
||||
}
|
||||
```
|
||||
|
||||
`pre_repair_lifecycle` may be omitted if the existing event model or service cannot retrieve it reliably without ambiguity.
|
||||
|
||||
Do not duplicate arbitrary repair fields in the payload.
|
||||
|
||||
Do not store display names as authoritative actor identity.
|
||||
|
||||
### Event Ordering
|
||||
|
||||
Use the established explicit sequence mechanism.
|
||||
|
||||
Do not calculate order from timestamps alone.
|
||||
|
||||
Concurrent event insertion must not produce duplicate sequence values.
|
||||
|
||||
## Models and Validation
|
||||
|
||||
Create or extend focused models.
|
||||
|
||||
Suggested names, adapted to current conventions:
|
||||
|
||||
- `RepairCompleteRequest`
|
||||
- `RepairDetailResponse` or existing equivalent
|
||||
- internal completion result model if needed
|
||||
|
||||
### Validation Requirements
|
||||
|
||||
- UUID path validation,
|
||||
- finite lifecycle validation,
|
||||
- summary trimming,
|
||||
- empty summary normalization,
|
||||
- maximum summary length,
|
||||
- no client-supplied actor,
|
||||
- no client-supplied completion timestamp,
|
||||
- no arbitrary event payload supplied by the client.
|
||||
|
||||
## Concurrency and Atomicity
|
||||
|
||||
The entire operation must use one transaction.
|
||||
|
||||
The following must succeed or fail together:
|
||||
|
||||
1. repair validation,
|
||||
2. miner validation,
|
||||
3. repair completion update,
|
||||
4. miner lifecycle transition,
|
||||
5. completion event insertion.
|
||||
|
||||
### Concurrent Completion
|
||||
|
||||
Two simultaneous completion requests must not both succeed.
|
||||
|
||||
Expected result:
|
||||
|
||||
- one request succeeds,
|
||||
- the other receives `409 Conflict`,
|
||||
- one completion event exists,
|
||||
- one final lifecycle transition exists.
|
||||
|
||||
Use row locking, compare-and-update semantics, or another database-backed mechanism consistent with the repository.
|
||||
|
||||
### Concurrent Miner Changes
|
||||
|
||||
A simultaneous lifecycle-changing operation must not leave:
|
||||
|
||||
- a completed repair with miner still in `in_repair`,
|
||||
- an active repair with miner already returned,
|
||||
- conflicting lifecycle history.
|
||||
|
||||
Lock ordering must be consistent with Begin Repair and other miner workflows to reduce deadlock risk.
|
||||
|
||||
Document the chosen lock order in code comments only where it is not obvious.
|
||||
|
||||
## Error and Failure Behaviour
|
||||
|
||||
On any failure after the transaction starts:
|
||||
|
||||
- no partial completion fields persist,
|
||||
- miner lifecycle remains unchanged,
|
||||
- no completion event persists,
|
||||
- repair remains active.
|
||||
|
||||
Unexpected database integrity failures should be translated using established service/repository exception conventions.
|
||||
|
||||
Do not catch broad exceptions merely to convert them into `409`.
|
||||
|
||||
## Tests
|
||||
|
||||
Add focused tests following current test organization.
|
||||
|
||||
### Authorization Tests
|
||||
|
||||
Verify:
|
||||
|
||||
- unauthenticated request is denied,
|
||||
- user without `repairs.complete` is denied,
|
||||
- Read Only / Viewer is denied,
|
||||
- Technician is allowed,
|
||||
- Administrator is allowed,
|
||||
- existing `repairs.read` behavior is unchanged,
|
||||
- existing `repairs.create` behavior is unchanged.
|
||||
|
||||
### Happy Path Tests
|
||||
|
||||
Verify:
|
||||
|
||||
- active repair completes successfully,
|
||||
- miner transitions from `in_repair` to requested eligible lifecycle,
|
||||
- repair is marked completed,
|
||||
- completion timestamp is server-controlled,
|
||||
- completing actor is authenticated user,
|
||||
- optional summary is persisted,
|
||||
- blank summary normalizes to null,
|
||||
- completion event is appended,
|
||||
- event sequence follows existing events,
|
||||
- response represents the completed repair.
|
||||
|
||||
Test at least:
|
||||
|
||||
- return to `active`,
|
||||
- return to `inactive`,
|
||||
- restoration to a valid original lifecycle when relevant.
|
||||
|
||||
### Domain Conflict Tests
|
||||
|
||||
Verify `409` for:
|
||||
|
||||
- already completed repair,
|
||||
- inactive/non-active repair,
|
||||
- miner not in `in_repair`,
|
||||
- mismatched active repair state,
|
||||
- disallowed destination lifecycle,
|
||||
- duplicate concurrent completion.
|
||||
|
||||
### Validation Tests
|
||||
|
||||
Verify `422` for:
|
||||
|
||||
- malformed repair UUID,
|
||||
- missing required lifecycle,
|
||||
- unknown lifecycle,
|
||||
- `in_repair` as destination,
|
||||
- summary over maximum length,
|
||||
- invalid request shape.
|
||||
|
||||
If lifecycle defaulting is intentionally supported, replace the missing-field expectation with tests for documented default behavior.
|
||||
|
||||
### Missing Resource Test
|
||||
|
||||
Verify `404` for unknown repair UUID.
|
||||
|
||||
### Transaction Rollback Tests
|
||||
|
||||
Force failures at meaningful steps, such as:
|
||||
|
||||
- miner update failure,
|
||||
- repair completion update failure,
|
||||
- event insertion failure.
|
||||
|
||||
Verify no partial state persists.
|
||||
|
||||
### Event Tests
|
||||
|
||||
Verify:
|
||||
|
||||
- exactly one completion event,
|
||||
- immutable event behavior remains,
|
||||
- actor is correct,
|
||||
- payload is structured,
|
||||
- ordering is explicit,
|
||||
- prior events remain unchanged.
|
||||
|
||||
### Database Constraint Tests
|
||||
|
||||
Verify applicable constraints directly, including:
|
||||
|
||||
- completed record requirements,
|
||||
- active/completed consistency,
|
||||
- resulting lifecycle restriction,
|
||||
- one-active-repair rule remains valid.
|
||||
|
||||
### Migration Tests
|
||||
|
||||
Verify:
|
||||
|
||||
- previous head upgrades,
|
||||
- new permission exists,
|
||||
- intended roles receive the permission,
|
||||
- unintended roles do not,
|
||||
- schema constraints exist,
|
||||
- downgrade works safely or refuses explicitly according to project convention,
|
||||
- re-upgrade works,
|
||||
- empty database upgrades through head.
|
||||
|
||||
### Routing Tests
|
||||
|
||||
Verify:
|
||||
|
||||
- static repair routes such as `/api/repairs/queue` and `/api/repairs/parts-summary` still resolve,
|
||||
- `/api/repairs/{repair_id}/complete` resolves correctly,
|
||||
- UUID detail and event routes remain unchanged,
|
||||
- malformed static-looking paths are not accidentally consumed by UUID routes,
|
||||
- expected route count increases only by the intended amount.
|
||||
|
||||
### Regression Tests
|
||||
|
||||
Run at minimum:
|
||||
|
||||
- Begin Repair tests,
|
||||
- Repair Timeline tests,
|
||||
- inventory lifecycle tests,
|
||||
- permission tests,
|
||||
- migration lifecycle suite,
|
||||
- relevant legacy repair tests,
|
||||
- complete project suite if practical.
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
After implementation and validation succeed:
|
||||
|
||||
- mark Return to Service complete in `docs/releases/v0.2-repair-workflow.md`,
|
||||
- update `docs/ROADMAP.md` only if the repository uses it as current implementation status,
|
||||
- update API documentation if maintained separately.
|
||||
|
||||
Do not mark Control Board Replacement or Repair Parts Tracking complete.
|
||||
|
||||
Do not perform broad stale-document cleanup in this task unless a directly changed statement would become materially false.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The task is complete when all of the following are true:
|
||||
|
||||
1. `POST /api/repairs/{repair_id}/complete` exists.
|
||||
2. Authentication and `repairs.complete` are enforced.
|
||||
3. Only an active repair can be completed.
|
||||
4. The associated miner must be in `in_repair`.
|
||||
5. The target lifecycle is explicit and validated.
|
||||
6. Repair completion and miner transition are atomic.
|
||||
7. Exactly one immutable completion event is appended.
|
||||
8. Concurrent completion cannot succeed twice.
|
||||
9. Existing repair static and UUID routes still resolve.
|
||||
10. Existing Begin Repair and Repair Timeline behavior remains unchanged.
|
||||
11. Migration lifecycle validation passes.
|
||||
12. Permission assignments are correct.
|
||||
13. Focused and regression tests pass.
|
||||
14. `git diff --check` passes.
|
||||
15. No unrelated files are changed.
|
||||
16. No commit or push is performed.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
Use repository-native commands where available.
|
||||
|
||||
At minimum run:
|
||||
|
||||
```bash
|
||||
python -m compileall .
|
||||
```
|
||||
|
||||
Run shell syntax checks for changed shell files, if any.
|
||||
|
||||
Run:
|
||||
|
||||
- focused Return to Service tests,
|
||||
- Begin Repair tests,
|
||||
- Repair Timeline tests,
|
||||
- inventory lifecycle tests,
|
||||
- permission tests,
|
||||
- migration tests,
|
||||
- route audits,
|
||||
- architecture audits,
|
||||
- full suite when practical.
|
||||
|
||||
Validate migrations through:
|
||||
|
||||
- previous head to new head,
|
||||
- downgrade,
|
||||
- re-upgrade,
|
||||
- empty database to head,
|
||||
- disposable development baseline.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
git status --short
|
||||
```
|
||||
|
||||
## Architecture Audit
|
||||
|
||||
Confirm:
|
||||
|
||||
- no SQL in routers,
|
||||
- no database driver imports in routers,
|
||||
- no transaction management in routers,
|
||||
- no FastAPI imports in services or repositories,
|
||||
- no generic repair update repository introduced,
|
||||
- actor identity comes from authentication,
|
||||
- timestamps are server-controlled,
|
||||
- events are append-only,
|
||||
- route ordering is safe.
|
||||
|
||||
## Completion Report
|
||||
|
||||
Return:
|
||||
|
||||
- branch and starting commit,
|
||||
- files created,
|
||||
- files modified,
|
||||
- migration revision and parent,
|
||||
- route added,
|
||||
- permission added and role assignments,
|
||||
- domain behavior,
|
||||
- concurrency strategy,
|
||||
- event type and payload,
|
||||
- tests added,
|
||||
- focused test results,
|
||||
- regression results,
|
||||
- migration validation results,
|
||||
- architecture audit results,
|
||||
- `git diff --check`,
|
||||
- known limitations,
|
||||
- final `git status --short`.
|
||||
|
||||
Do not commit.
|
||||
Do not push.
|
||||
@@ -57,7 +57,7 @@ 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
|
||||
run_alembic upgrade 0010_begin_repair
|
||||
current_revision="$(run_alembic current 2>/dev/null)"
|
||||
[[ "${current_revision}" == *"0010_begin_repair"* ]]
|
||||
|
||||
@@ -534,17 +534,17 @@ SELECT
|
||||
SQL
|
||||
)"
|
||||
[[ "${downgrade_state}" == "t" ]]
|
||||
run_alembic upgrade head
|
||||
run_alembic upgrade 0010_begin_repair
|
||||
reupgraded_revision="$(run_alembic current 2>/dev/null)"
|
||||
[[ "${reupgraded_revision}" == *"0010_begin_repair"* ]]
|
||||
|
||||
echo "Migrating an empty database from base through head..."
|
||||
echo "Migrating an empty database from base through Begin Repair..."
|
||||
"${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
|
||||
backend alembic upgrade 0010_begin_repair
|
||||
|
||||
empty_revision="$(
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
|
||||
@@ -88,7 +88,7 @@ health_json="$(cat "${health_file}")"
|
||||
|
||||
echo "Checking migrations and required schema..."
|
||||
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
||||
[[ "${current_revision}" == *"0011_repair_timeline"* ]]
|
||||
[[ "${current_revision}" == *"0012_return_to_service"* ]]
|
||||
|
||||
missing_tables="$(
|
||||
"${compose[@]}" exec -T postgres sh -c \
|
||||
|
||||
@@ -57,7 +57,7 @@ echo "Upgrading the previous head to Repair Timeline..."
|
||||
run_alembic upgrade 0010_begin_repair
|
||||
previous_revision="$(run_alembic current 2>/dev/null)"
|
||||
[[ "${previous_revision}" == *"0010_begin_repair"* ]]
|
||||
run_alembic upgrade head
|
||||
run_alembic upgrade 0011_repair_timeline
|
||||
current_revision="$(run_alembic current 2>/dev/null)"
|
||||
[[ "${current_revision}" == *"0011_repair_timeline"* ]]
|
||||
|
||||
@@ -149,17 +149,17 @@ SQL
|
||||
downgraded_revision="$(run_alembic current 2>/dev/null)"
|
||||
[[ "${downgraded_revision}" == *"0010_begin_repair"* ]]
|
||||
|
||||
run_alembic upgrade head
|
||||
run_alembic upgrade 0011_repair_timeline
|
||||
reupgraded_revision="$(run_alembic current 2>/dev/null)"
|
||||
[[ "${reupgraded_revision}" == *"0011_repair_timeline"* ]]
|
||||
|
||||
echo "Migrating an empty database from base through head..."
|
||||
echo "Migrating an empty database from base through Repair Timeline..."
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \
|
||||
-c "CREATE DATABASE minerops_timeline_empty"' >/dev/null
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
-e "POSTGRES_DB=${empty_database}" \
|
||||
backend alembic upgrade head
|
||||
backend alembic upgrade 0011_repair_timeline
|
||||
|
||||
empty_revision="$(
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
|
||||
Executable
+295
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
|
||||
project_name="${MINEROPS_RETURN_MIGRATION_PROJECT:-minerops-return-migration-$$}"
|
||||
empty_database="minerops_return_empty"
|
||||
compose=(
|
||||
docker compose
|
||||
--project-name "${project_name}"
|
||||
--env-file .env.dev
|
||||
-f compose.dev.yaml
|
||||
)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if (( status != 0 )); then
|
||||
echo
|
||||
echo "Return to Service migration test failed. Recent PostgreSQL logs:"
|
||||
"${compose[@]}" logs --no-color --tail 160 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 "Preparing previous head and truthful legacy completion data..."
|
||||
run_alembic upgrade 0011_repair_timeline
|
||||
previous_revision="$(run_alembic current 2>/dev/null)"
|
||||
[[ "${previous_revision}" == *"0011_repair_timeline"* ]]
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||
INSERT INTO users (
|
||||
id, username, display_name, password_hash, role_id
|
||||
)
|
||||
SELECT
|
||||
'00000000-0000-0000-0000-000000001301',
|
||||
'return-migration-test',
|
||||
'Return Migration Test',
|
||||
'not-used',
|
||||
id
|
||||
FROM roles
|
||||
WHERE name = 'Admin';
|
||||
|
||||
INSERT INTO miners (
|
||||
id, asset_tag, status, lifecycle_status, first_seen_at, last_seen_at
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000001302',
|
||||
'RETURN-MIGRATION-LEGACY',
|
||||
'unknown',
|
||||
'active',
|
||||
NULL,
|
||||
NULL
|
||||
);
|
||||
|
||||
INSERT INTO repairs (
|
||||
id, miner_id, status, reason, opened_by, completed_at, completed_by
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000001303',
|
||||
'00000000-0000-0000-0000-000000001302',
|
||||
'COMPLETED',
|
||||
'OTHER',
|
||||
'00000000-0000-0000-0000-000000001301',
|
||||
now(),
|
||||
'00000000-0000-0000-0000-000000001301'
|
||||
);
|
||||
SQL
|
||||
|
||||
echo "Upgrading previous head to Return to Service..."
|
||||
run_alembic upgrade head
|
||||
current_revision="$(run_alembic current 2>/dev/null)"
|
||||
[[ "${current_revision}" == *"0012_return_to_service"* ]]
|
||||
|
||||
echo "Checking schema, permission matrix, constraints, and legacy preservation..."
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||
DO $$
|
||||
DECLARE
|
||||
v_roles TEXT[];
|
||||
v_constraint_count INTEGER;
|
||||
BEGIN
|
||||
IF (
|
||||
SELECT count(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'repairs'
|
||||
AND column_name IN ('completion_summary', 'resulting_lifecycle')
|
||||
) <> 2 THEN
|
||||
RAISE EXCEPTION 'Return to Service columns are missing';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM repairs
|
||||
WHERE id = '00000000-0000-0000-0000-000000001303'
|
||||
AND (
|
||||
completion_summary IS NOT NULL
|
||||
OR resulting_lifecycle IS NOT NULL
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Legacy completion metadata was fabricated';
|
||||
END IF;
|
||||
|
||||
IF (SELECT count(*) FROM permissions WHERE key = 'repairs.complete') <> 1 THEN
|
||||
RAISE EXCEPTION 'repairs.complete permission was not registered once';
|
||||
END IF;
|
||||
|
||||
SELECT array_agg(roles.name ORDER BY roles.name)
|
||||
INTO v_roles
|
||||
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.complete';
|
||||
IF v_roles <> ARRAY['Admin', 'Manager', 'Technician'] THEN
|
||||
RAISE EXCEPTION 'Unexpected repairs.complete role matrix: %', v_roles;
|
||||
END IF;
|
||||
|
||||
SELECT count(*)
|
||||
INTO v_constraint_count
|
||||
FROM pg_constraint
|
||||
WHERE conrelid IN ('repairs'::regclass, 'repair_events'::regclass)
|
||||
AND conname IN (
|
||||
'ck_repairs_resulting_lifecycle',
|
||||
'ck_repairs_completion_summary',
|
||||
'ck_repairs_completion_metadata_state',
|
||||
'ck_repair_events_type',
|
||||
'ck_repair_events_payload_shape'
|
||||
);
|
||||
IF v_constraint_count <> 5 THEN
|
||||
RAISE EXCEPTION 'Expected Return to Service constraints are missing';
|
||||
END IF;
|
||||
|
||||
IF (
|
||||
SELECT convalidated
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'repairs'::regclass
|
||||
AND conname = 'ck_repairs_completion_metadata_state'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Legacy completion compatibility constraint was unexpectedly validated';
|
||||
END IF;
|
||||
|
||||
BEGIN
|
||||
INSERT INTO repairs (
|
||||
miner_id, status, reason, opened_by, completed_at, completed_by
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000001302',
|
||||
'COMPLETED',
|
||||
'OTHER',
|
||||
'00000000-0000-0000-0000-000000001301',
|
||||
now(),
|
||||
'00000000-0000-0000-0000-000000001301'
|
||||
);
|
||||
RAISE EXCEPTION 'New completed repair without resulting lifecycle was accepted';
|
||||
EXCEPTION
|
||||
WHEN check_violation THEN NULL;
|
||||
END;
|
||||
END
|
||||
$$;
|
||||
|
||||
INSERT INTO permissions (key, description)
|
||||
VALUES ('repairs.complete', 'Return repaired miners to service')
|
||||
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.complete'
|
||||
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF (SELECT count(*) FROM permissions WHERE key = 'repairs.complete') <> 1
|
||||
OR (
|
||||
SELECT count(*)
|
||||
FROM role_permissions
|
||||
JOIN permissions ON permissions.id = role_permissions.permission_id
|
||||
WHERE permissions.key = 'repairs.complete'
|
||||
) <> 3 THEN
|
||||
RAISE EXCEPTION 'repairs.complete seeding is not idempotent';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
SQL
|
||||
|
||||
echo "Downgrading and re-upgrading without new completion history..."
|
||||
run_alembic downgrade 0011_repair_timeline
|
||||
downgrade_state="$(
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' <<'SQL'
|
||||
SELECT
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM permissions WHERE key = 'repairs.complete'
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM permissions WHERE key = 'repairs.read'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'repairs'
|
||||
AND column_name IN ('completion_summary', 'resulting_lifecycle')
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM repairs
|
||||
WHERE id = '00000000-0000-0000-0000-000000001303'
|
||||
AND status = 'COMPLETED'
|
||||
AND completed_at IS NOT NULL
|
||||
AND completed_by IS NOT NULL
|
||||
);
|
||||
SQL
|
||||
)"
|
||||
[[ "${downgrade_state}" == "t" ]]
|
||||
downgraded_revision="$(run_alembic current 2>/dev/null)"
|
||||
[[ "${downgraded_revision}" == *"0011_repair_timeline"* ]]
|
||||
run_alembic upgrade head
|
||||
reupgraded_revision="$(run_alembic current 2>/dev/null)"
|
||||
[[ "${reupgraded_revision}" == *"0012_return_to_service"* ]]
|
||||
|
||||
echo "Checking downgrade refusal after authoritative completion history..."
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||
UPDATE repairs
|
||||
SET completion_summary = 'Migration completion',
|
||||
resulting_lifecycle = 'active'
|
||||
WHERE id = '00000000-0000-0000-0000-000000001303';
|
||||
|
||||
INSERT INTO repair_events (
|
||||
repair_id, sequence, event_type, created_by, payload
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000001303',
|
||||
1,
|
||||
'REPAIR_COMPLETED',
|
||||
'00000000-0000-0000-0000-000000001301',
|
||||
'{
|
||||
"previous_miner_lifecycle":"in_repair",
|
||||
"resulting_miner_lifecycle":"active",
|
||||
"completion_summary":"Migration completion"
|
||||
}'::jsonb
|
||||
);
|
||||
SQL
|
||||
if downgrade_error="$(run_alembic downgrade 0011_repair_timeline 2>&1)"; then
|
||||
echo "Downgrade discarded Return to Service history." >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "${downgrade_error}" == *"would lose authoritative history"* ]]
|
||||
failed_revision="$(run_alembic current 2>/dev/null)"
|
||||
[[ "${failed_revision}" == *"0012_return_to_service"* ]]
|
||||
|
||||
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_return_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}" == *"0012_return_to_service"* ]]
|
||||
|
||||
echo "Return to Service migration test passed."
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
|
||||
project_name="${MINEROPS_RETURN_SERVICE_TEST_PROJECT:-minerops-return-service-$$}"
|
||||
compose=(
|
||||
docker compose
|
||||
--project-name "${project_name}"
|
||||
--env-file .env.dev
|
||||
-f compose.dev.yaml
|
||||
)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if (( status != 0 )); then
|
||||
echo
|
||||
echo "Return to Service 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 Return to Service 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 Return to Service test image..."
|
||||
"${compose[@]}" build backend
|
||||
|
||||
echo "Migrating disposable database..."
|
||||
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||
|
||||
echo "Running Return to Service 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_return_to_service
|
||||
|
||||
echo "Return to Service tests passed."
|
||||
Reference in new issue
Block a user