179 lines
6.3 KiB
Python
179 lines
6.3 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import secrets
|
|
from datetime import UTC, datetime
|
|
from uuid import uuid4
|
|
|
|
from app.core.crypto import generate_secret_token_urlsafe, hash_opaque_token
|
|
from app.core.exceptions import DomainError
|
|
from app.modules.sync import repository as repo
|
|
from app.modules.sync.schemas import (
|
|
AckChangesRequest,
|
|
AckChangesResponse,
|
|
ChangeEventIn,
|
|
ChangeEventOut,
|
|
ConflictDetail,
|
|
ConflictSummary,
|
|
HeartbeatRequest,
|
|
HeartbeatResponse,
|
|
PairConfirmRequest,
|
|
PairConfirmResponse,
|
|
PairStartRequest,
|
|
PairStartResponse,
|
|
PullChangesRequest,
|
|
PullChangesResponse,
|
|
PushChangesRequest,
|
|
PushChangesResponse,
|
|
ResolveConflictRequest,
|
|
SyncCapabilitiesResponse,
|
|
)
|
|
from app.modules.sync.engine import SyncEngine
|
|
from app.modules.users.models import User
|
|
|
|
|
|
class SyncServiceError(DomainError):
|
|
pass
|
|
|
|
|
|
def get_capabilities() -> SyncCapabilitiesResponse:
|
|
return SyncCapabilitiesResponse()
|
|
|
|
|
|
def start_pairing(user: User, body: PairStartRequest) -> PairStartResponse:
|
|
member = repo.get_member(user.id, body.enterprise_id)
|
|
if not member or member.role != "admin":
|
|
if not user.is_superuser:
|
|
raise SyncServiceError("ENTERPRISE_ADMIN_ONLY")
|
|
enterprise = repo.get_enterprise_by_id(body.enterprise_id)
|
|
if not enterprise:
|
|
raise SyncServiceError("ENTERPRISE_NOT_FOUND")
|
|
code = f"{secrets.randbelow(900000) + 100000:06d}"
|
|
session = repo.create_pairing_session(body.enterprise_id, code, hash_opaque_token(code))
|
|
return PairStartResponse(session_id=session.id, code=code, expires_at=session.expires_at)
|
|
|
|
|
|
def confirm_pairing(body: PairConfirmRequest) -> PairConfirmResponse:
|
|
code_hash = hash_opaque_token(body.code.strip())
|
|
session = repo.get_pairing_session_by_code_hash(code_hash)
|
|
if not session:
|
|
raise SyncServiceError("INVALID_PAIRING_CODE")
|
|
if session.expires_at.replace(tzinfo=UTC) < datetime.now(UTC):
|
|
raise SyncServiceError("PAIRING_CODE_EXPIRED")
|
|
hub = repo.create_farm_hub(
|
|
session.enterprise_id,
|
|
body.hub_name or f"Hub {body.hub_site_id[:8]}",
|
|
body.hub_site_id,
|
|
body.hub_url,
|
|
)
|
|
api_key = generate_secret_token_urlsafe(48)
|
|
repo.create_hub_credential(hub.id, api_key)
|
|
repo.confirm_pairing_session(session.id, hub.id)
|
|
return PairConfirmResponse(
|
|
farm_hub_id=hub.id,
|
|
hub_site_id=hub.hub_site_id,
|
|
api_key=api_key,
|
|
enterprise_id=session.enterprise_id,
|
|
)
|
|
|
|
|
|
def hub_heartbeat(hub, body: HeartbeatRequest) -> HeartbeatResponse:
|
|
repo.update_hub_heartbeat(hub.farm_hub_id, body.wesp_version)
|
|
return HeartbeatResponse(server_time=datetime.now(UTC))
|
|
|
|
|
|
def push_changes(hub, body: PushChangesRequest) -> PushChangesResponse:
|
|
return SyncEngine(hub.enterprise_id, hub.hub_site_id).process_push(body.events, origin="hub")
|
|
|
|
|
|
def pull_changes(hub, body: PullChangesRequest) -> PullChangesResponse:
|
|
return SyncEngine(hub.enterprise_id, hub.hub_site_id).process_pull(hub.farm_hub_id, body.cursor, body.limit)
|
|
|
|
|
|
def ack_changes(hub, body: AckChangesRequest) -> AckChangesResponse:
|
|
return SyncEngine(hub.enterprise_id, hub.hub_site_id).process_ack(hub.farm_hub_id, body)
|
|
|
|
|
|
def list_conflicts_for_enterprise(enterprise_id: str) -> list[ConflictSummary]:
|
|
rows = repo.list_conflicts(enterprise_id)
|
|
return [
|
|
ConflictSummary(
|
|
id=row.id,
|
|
table_name=row.table_name,
|
|
record_id=row.record_id,
|
|
farm_hub_id=row.farm_hub_id,
|
|
status=row.status,
|
|
created_at=row.created_at,
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
|
|
def get_conflict_detail(conflict_id: str, enterprise_id: str) -> ConflictDetail:
|
|
row = repo.get_conflict(conflict_id)
|
|
if not row or row.enterprise_id != enterprise_id:
|
|
raise SyncServiceError("CONFLICT_NOT_FOUND")
|
|
return ConflictDetail(
|
|
id=row.id,
|
|
table_name=row.table_name,
|
|
record_id=row.record_id,
|
|
farm_hub_id=row.farm_hub_id,
|
|
status=row.status,
|
|
created_at=row.created_at,
|
|
orchestrator_snapshot=json.loads(row.orchestrator_snapshot_json or "{}"),
|
|
hub_snapshot=json.loads(row.hub_snapshot_json or "{}"),
|
|
held_event_ids=json.loads(row.held_event_ids_json or "[]"),
|
|
)
|
|
|
|
|
|
def resolve_conflict(conflict_id: str, enterprise_id: str, user_id: str, body: ResolveConflictRequest) -> None:
|
|
SyncEngine(enterprise_id, "orchestrator").resolve_conflict(conflict_id, user_id, body.resolution)
|
|
|
|
|
|
def get_sync_metrics(enterprise_id: str) -> dict:
|
|
from sqlalchemy import func, select
|
|
|
|
from app.core.database import session_scope
|
|
from app.modules.sync.models import FarmHub, SyncConflict, SyncCursor, SyncEventLog, SyncOutbox
|
|
|
|
with session_scope() as db:
|
|
hubs = list(db.scalars(select(FarmHub).where(FarmHub.enterprise_id == enterprise_id)))
|
|
outbox_pending = db.scalar(
|
|
select(func.count())
|
|
.select_from(SyncOutbox)
|
|
.where(SyncOutbox.enterprise_id == enterprise_id, SyncOutbox.status == "pending")
|
|
)
|
|
conflicts_pending = db.scalar(
|
|
select(func.count())
|
|
.select_from(SyncConflict)
|
|
.where(SyncConflict.enterprise_id == enterprise_id, SyncConflict.status == "pending")
|
|
)
|
|
max_seq = db.scalar(
|
|
select(func.max(SyncEventLog.seq)).where(SyncEventLog.enterprise_id == enterprise_id)
|
|
) or 0
|
|
hub_metrics = []
|
|
for h in hubs:
|
|
cursor = db.scalar(
|
|
select(SyncCursor).where(
|
|
SyncCursor.farm_hub_id == h.id,
|
|
SyncCursor.direction == "inbound",
|
|
)
|
|
)
|
|
lag = max(0, int(max_seq) - int(cursor.last_acked_seq if cursor else 0))
|
|
hub_metrics.append(
|
|
{
|
|
"farm_hub_id": h.id,
|
|
"name": h.name,
|
|
"hub_site_id": h.hub_site_id,
|
|
"status": h.status,
|
|
"last_seen": h.last_seen.isoformat() if h.last_seen else None,
|
|
"sync_lag": lag,
|
|
}
|
|
)
|
|
return {
|
|
"hubs": hub_metrics,
|
|
"outbox_pending": int(outbox_pending or 0),
|
|
"conflicts_pending": int(conflicts_pending or 0),
|
|
"max_event_seq": int(max_seq),
|
|
}
|