Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""WESP sync orchestrator: enterprise tenants, hub pairing, ChangeEvent protocol."""
|
||||
@@ -0,0 +1,381 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.sync import repository as repo
|
||||
from app.modules.sync.models import SyncConflict, SyncOutbox
|
||||
from app.modules.sync.schemas import (
|
||||
AckChangesRequest,
|
||||
AckChangesResponse,
|
||||
ChangeEventIn,
|
||||
ChangeEventOut,
|
||||
PullChangesResponse,
|
||||
PushChangesResponse,
|
||||
)
|
||||
from app.modules.zootech.catalog_apply import apply_catalog_change
|
||||
|
||||
|
||||
class SyncEngine:
|
||||
ORCHESTRATOR_SITE_ID = "orchestrator"
|
||||
|
||||
def __init__(self, enterprise_id: str, site_id: str) -> None:
|
||||
self.enterprise_id = enterprise_id
|
||||
self.site_id = site_id
|
||||
|
||||
def process_push(self, events: list[ChangeEventIn], origin: str) -> PushChangesResponse:
|
||||
applied: list[str] = []
|
||||
held: list[str] = []
|
||||
conflict_ids: list[str] = []
|
||||
|
||||
for event in events:
|
||||
if repo.event_exists(event.event_id):
|
||||
applied.append(event.event_id)
|
||||
continue
|
||||
if repo.applied_event_exists(self.site_id, event.event_id):
|
||||
applied.append(event.event_id)
|
||||
continue
|
||||
|
||||
seq = event.seq or repo.next_seq(self.enterprise_id)
|
||||
payload_json = json.dumps(event.payload, ensure_ascii=False)
|
||||
repo.append_event_log(
|
||||
enterprise_id=self.enterprise_id,
|
||||
event_id=event.event_id,
|
||||
origin=origin,
|
||||
origin_site_id=event.origin_site_id or self.site_id,
|
||||
seq=seq,
|
||||
domain=event.domain,
|
||||
table_name=event.table,
|
||||
record_id=event.record_id,
|
||||
action=event.action,
|
||||
version=event.version,
|
||||
content_hash=event.content_hash,
|
||||
payload_json=payload_json,
|
||||
)
|
||||
|
||||
outcome = self._apply_or_hold(event, origin)
|
||||
if outcome == "applied":
|
||||
repo.mark_applied(self.enterprise_id, self.ORCHESTRATOR_SITE_ID, event.event_id)
|
||||
applied.append(event.event_id)
|
||||
elif outcome == "held":
|
||||
held.append(event.event_id)
|
||||
elif isinstance(outcome, str) and outcome.startswith("conflict:"):
|
||||
conflict_ids.append(outcome.split(":", 1)[1])
|
||||
|
||||
return PushChangesResponse(
|
||||
applied_event_ids=applied,
|
||||
held_event_ids=held,
|
||||
conflicts=conflict_ids,
|
||||
)
|
||||
|
||||
def _apply_or_hold(self, event: ChangeEventIn, origin: str) -> str:
|
||||
state = repo.get_record_state(self.enterprise_id, event.table, event.record_id)
|
||||
if state and state.agreed_hash and state.agreed_hash != event.content_hash:
|
||||
agreed_version = int(state.agreed_version or 0)
|
||||
if event.version <= agreed_version:
|
||||
pending = self._find_pending_conflict(event.table, event.record_id)
|
||||
if pending:
|
||||
self._append_held_event(pending.id, event.event_id)
|
||||
return "held"
|
||||
conflict_id = self._create_conflict(event, origin, state)
|
||||
self._hold_outbox_for_record(event.table, event.record_id)
|
||||
return f"conflict:{conflict_id}"
|
||||
|
||||
if event.domain in ("report", "reports"):
|
||||
from app.modules.zootech.report_apply import apply_report_change
|
||||
|
||||
apply_report_change(
|
||||
self.enterprise_id,
|
||||
event.table,
|
||||
event.record_id,
|
||||
event.action,
|
||||
event.payload,
|
||||
event.version,
|
||||
event.content_hash,
|
||||
farm_hub_id=event.origin_site_id if origin == "hub" else None,
|
||||
)
|
||||
else:
|
||||
apply_catalog_change(
|
||||
self.enterprise_id,
|
||||
event.table,
|
||||
event.record_id,
|
||||
event.action,
|
||||
event.payload,
|
||||
event.version,
|
||||
event.content_hash,
|
||||
)
|
||||
repo.upsert_record_state(
|
||||
self.enterprise_id,
|
||||
event.table,
|
||||
event.record_id,
|
||||
event.version,
|
||||
event.content_hash,
|
||||
event.event_id,
|
||||
)
|
||||
if origin == "orchestrator" or self.site_id == self.ORCHESTRATOR_SITE_ID:
|
||||
self._enqueue_fanout(event)
|
||||
return "applied"
|
||||
|
||||
def _append_held_event(self, conflict_id: str, event_id: str) -> None:
|
||||
with session_scope() as db:
|
||||
row = db.get(SyncConflict, conflict_id)
|
||||
if not row:
|
||||
return
|
||||
held = json.loads(row.held_event_ids_json or "[]")
|
||||
if event_id not in held:
|
||||
held.append(event_id)
|
||||
row.held_event_ids_json = json.dumps(held, ensure_ascii=False)
|
||||
|
||||
def _hold_outbox_for_record(self, table_name: str, record_id: str) -> None:
|
||||
with session_scope() as db:
|
||||
from sqlalchemy import select
|
||||
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(SyncOutbox).where(
|
||||
SyncOutbox.enterprise_id == self.enterprise_id,
|
||||
SyncOutbox.table_name == table_name,
|
||||
SyncOutbox.record_id == record_id,
|
||||
SyncOutbox.status.in_(("pending", "sent")),
|
||||
)
|
||||
)
|
||||
)
|
||||
for row in rows:
|
||||
row.status = "held"
|
||||
|
||||
def _has_pending_orchestrator_change(self, table_name: str, record_id: str, origin: str) -> bool:
|
||||
if origin == "hub":
|
||||
with session_scope() as db:
|
||||
from sqlalchemy import select
|
||||
|
||||
row = db.scalar(
|
||||
select(SyncOutbox).where(
|
||||
SyncOutbox.enterprise_id == self.enterprise_id,
|
||||
SyncOutbox.table_name == table_name,
|
||||
SyncOutbox.record_id == record_id,
|
||||
SyncOutbox.status.in_(("pending", "sent", "held")),
|
||||
SyncOutbox.origin == "orchestrator",
|
||||
)
|
||||
)
|
||||
return row is not None
|
||||
return origin == "orchestrator"
|
||||
|
||||
def _find_pending_conflict(self, table_name: str, record_id: str) -> SyncConflict | None:
|
||||
with session_scope() as db:
|
||||
from sqlalchemy import select
|
||||
|
||||
row = db.scalar(
|
||||
select(SyncConflict).where(
|
||||
SyncConflict.enterprise_id == self.enterprise_id,
|
||||
SyncConflict.table_name == table_name,
|
||||
SyncConflict.record_id == record_id,
|
||||
SyncConflict.status == "pending",
|
||||
)
|
||||
)
|
||||
if row:
|
||||
db.refresh(row)
|
||||
db.expunge(row)
|
||||
return row
|
||||
return None
|
||||
|
||||
def _create_conflict(self, event: ChangeEventIn, origin: str, state) -> str:
|
||||
from app.modules.zootech.catalog_apply import load_catalog_row
|
||||
|
||||
orchestrator_row = load_catalog_row(self.enterprise_id, event.table, event.record_id) or {}
|
||||
if origin == "hub":
|
||||
hub_snapshot = event.payload
|
||||
orch_snapshot = orchestrator_row
|
||||
else:
|
||||
hub_snapshot = {}
|
||||
orch_snapshot = event.payload or orchestrator_row
|
||||
|
||||
conflict_id = str(uuid4())
|
||||
with session_scope() as db:
|
||||
db.add(
|
||||
SyncConflict(
|
||||
id=conflict_id,
|
||||
enterprise_id=self.enterprise_id,
|
||||
table_name=event.table,
|
||||
record_id=event.record_id,
|
||||
farm_hub_id=event.origin_site_id if origin == "hub" else None,
|
||||
orchestrator_snapshot_json=json.dumps(orch_snapshot, ensure_ascii=False),
|
||||
hub_snapshot_json=json.dumps(hub_snapshot, ensure_ascii=False),
|
||||
held_event_ids_json=json.dumps([event.event_id]),
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
return conflict_id
|
||||
|
||||
def _enqueue_fanout(self, event: ChangeEventIn) -> None:
|
||||
seq = repo.next_seq(self.enterprise_id)
|
||||
with session_scope() as db:
|
||||
existing = db.scalar(
|
||||
__import__("sqlalchemy").select(SyncOutbox).where(
|
||||
SyncOutbox.enterprise_id == self.enterprise_id,
|
||||
SyncOutbox.table_name == event.table,
|
||||
SyncOutbox.record_id == event.record_id,
|
||||
SyncOutbox.status.in_(("pending", "sent")),
|
||||
)
|
||||
)
|
||||
payload_json = json.dumps(event.payload, ensure_ascii=False)
|
||||
if existing:
|
||||
existing.event_id = event.event_id
|
||||
existing.action = event.action
|
||||
existing.version = event.version
|
||||
existing.content_hash = event.content_hash
|
||||
existing.payload_json = payload_json
|
||||
existing.seq = seq
|
||||
existing.status = "pending"
|
||||
existing.emitted_at = event.emitted_at
|
||||
else:
|
||||
db.add(
|
||||
SyncOutbox(
|
||||
enterprise_id=self.enterprise_id,
|
||||
event_id=event.event_id,
|
||||
origin="orchestrator",
|
||||
origin_site_id=self.ORCHESTRATOR_SITE_ID,
|
||||
seq=seq,
|
||||
domain=event.domain,
|
||||
table_name=event.table,
|
||||
record_id=event.record_id,
|
||||
action=event.action,
|
||||
version=event.version,
|
||||
content_hash=event.content_hash,
|
||||
payload_json=payload_json,
|
||||
status="pending",
|
||||
emitted_at=event.emitted_at,
|
||||
)
|
||||
)
|
||||
|
||||
def process_pull(self, farm_hub_id: str, cursor: int, limit: int) -> PullChangesResponse:
|
||||
rows = repo.pull_events_since(
|
||||
self.enterprise_id,
|
||||
cursor,
|
||||
limit,
|
||||
exclude_origin_site_id=self.site_id,
|
||||
)
|
||||
events = [
|
||||
ChangeEventOut(
|
||||
event_id=row.event_id,
|
||||
seq=row.seq,
|
||||
domain=row.domain, # type: ignore[arg-type]
|
||||
table=row.table_name,
|
||||
record_id=row.record_id,
|
||||
action=row.action, # type: ignore[arg-type]
|
||||
version=row.version,
|
||||
content_hash=row.content_hash,
|
||||
payload=json.loads(row.payload_json or "{}"),
|
||||
emitted_at=row.received_at,
|
||||
origin_site_id=row.origin_site_id,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
next_cursor = events[-1].seq if events else cursor
|
||||
repo.get_or_create_cursor(self.enterprise_id, farm_hub_id, "outbound")
|
||||
return PullChangesResponse(events=events, next_cursor=next_cursor)
|
||||
|
||||
def process_ack(self, farm_hub_id: str, body: AckChangesRequest) -> AckChangesResponse:
|
||||
cursor = repo.get_or_create_cursor(self.enterprise_id, farm_hub_id, body.direction)
|
||||
max_seq = cursor.last_acked_seq
|
||||
for event_id in body.event_ids:
|
||||
row = self._event_log_by_id(event_id)
|
||||
if row:
|
||||
max_seq = max(max_seq, row.seq)
|
||||
repo.mark_applied(self.enterprise_id, farm_hub_id, event_id)
|
||||
repo.update_cursor_ack(farm_hub_id, body.direction, max_seq)
|
||||
return AckChangesResponse(last_acked_seq=max_seq)
|
||||
|
||||
def _event_log_by_id(self, event_id: str):
|
||||
with session_scope() as db:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.modules.sync.models import SyncEventLog
|
||||
|
||||
row = db.scalar(select(SyncEventLog).where(SyncEventLog.event_id == event_id))
|
||||
if row:
|
||||
db.refresh(row)
|
||||
db.expunge(row)
|
||||
return row
|
||||
|
||||
def resolve_conflict(self, conflict_id: str, user_id: str, resolution: str) -> None:
|
||||
conflict = repo.get_conflict(conflict_id)
|
||||
if not conflict or conflict.enterprise_id != self.enterprise_id:
|
||||
raise ValueError("CONFLICT_NOT_FOUND")
|
||||
snapshot = (
|
||||
json.loads(conflict.orchestrator_snapshot_json)
|
||||
if resolution == "keep_orchestrator"
|
||||
else json.loads(conflict.hub_snapshot_json)
|
||||
)
|
||||
event = ChangeEventIn(
|
||||
event_id=str(uuid4()),
|
||||
seq=repo.next_seq(self.enterprise_id),
|
||||
domain="global",
|
||||
table=conflict.table_name,
|
||||
record_id=conflict.record_id,
|
||||
action="upsert",
|
||||
version=int(snapshot.get("version", 1)),
|
||||
content_hash=str(snapshot.get("content_hash", "")),
|
||||
payload=snapshot,
|
||||
emitted_at=datetime.now(UTC),
|
||||
origin_site_id=self.ORCHESTRATOR_SITE_ID,
|
||||
)
|
||||
apply_catalog_change(
|
||||
self.enterprise_id,
|
||||
event.table,
|
||||
event.record_id,
|
||||
event.action,
|
||||
event.payload,
|
||||
event.version,
|
||||
event.content_hash,
|
||||
)
|
||||
repo.upsert_record_state(
|
||||
self.enterprise_id,
|
||||
event.table,
|
||||
event.record_id,
|
||||
event.version,
|
||||
event.content_hash,
|
||||
event.event_id,
|
||||
)
|
||||
repo.append_event_log(
|
||||
enterprise_id=self.enterprise_id,
|
||||
event_id=event.event_id,
|
||||
origin="orchestrator",
|
||||
origin_site_id=self.ORCHESTRATOR_SITE_ID,
|
||||
seq=event.seq or repo.next_seq(self.enterprise_id),
|
||||
domain=event.domain,
|
||||
table_name=event.table,
|
||||
record_id=event.record_id,
|
||||
action=event.action,
|
||||
version=event.version,
|
||||
content_hash=event.content_hash,
|
||||
payload_json=json.dumps(event.payload, ensure_ascii=False),
|
||||
)
|
||||
repo.mark_applied(self.enterprise_id, self.ORCHESTRATOR_SITE_ID, event.event_id)
|
||||
self._enqueue_fanout(event)
|
||||
self._release_held_outbox(conflict.table_name, conflict.record_id)
|
||||
with session_scope() as db:
|
||||
row = db.get(SyncConflict, conflict_id)
|
||||
if row:
|
||||
row.status = "resolved"
|
||||
row.resolution = resolution
|
||||
row.resolved_by = user_id
|
||||
row.resolved_at = datetime.now(UTC)
|
||||
|
||||
def _release_held_outbox(self, table_name: str, record_id: str) -> None:
|
||||
with session_scope() as db:
|
||||
from sqlalchemy import select
|
||||
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(SyncOutbox).where(
|
||||
SyncOutbox.enterprise_id == self.enterprise_id,
|
||||
SyncOutbox.table_name == table_name,
|
||||
SyncOutbox.record_id == record_id,
|
||||
SyncOutbox.status == "held",
|
||||
)
|
||||
)
|
||||
)
|
||||
for row in rows:
|
||||
row.status = "pending"
|
||||
@@ -0,0 +1,299 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Enterprise(Base):
|
||||
__tablename__ = "enterprises"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
members: Mapped[list["EnterpriseMember"]] = relationship(back_populates="enterprise", cascade="all, delete-orphan")
|
||||
farm_hubs: Mapped[list["FarmHub"]] = relationship(back_populates="enterprise", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class EnterpriseMember(Base):
|
||||
__tablename__ = "enterprise_members"
|
||||
__table_args__ = (UniqueConstraint("user_id", "enterprise_id", name="uq_enterprise_members_user_enterprise"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
enterprise_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(32), nullable=False, default="viewer")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
enterprise: Mapped[Enterprise] = relationship(back_populates="members")
|
||||
|
||||
|
||||
class FarmHub(Base):
|
||||
__tablename__ = "farm_hubs"
|
||||
__table_args__ = (UniqueConstraint("hub_site_id", name="uq_farm_hubs_hub_site_id"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
enterprise_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
hub_site_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
wesp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
enterprise: Mapped[Enterprise] = relationship(back_populates="farm_hubs")
|
||||
credentials: Mapped[list["HubCredential"]] = relationship(back_populates="farm_hub", cascade="all, delete-orphan")
|
||||
farm_access: Mapped[list["UserFarmAccess"]] = relationship(back_populates="farm_hub", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class UserFarmAccess(Base):
|
||||
__tablename__ = "user_farm_access"
|
||||
__table_args__ = (UniqueConstraint("user_id", "farm_hub_id", name="uq_user_farm_access_user_farm"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
farm_hub_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("farm_hubs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
farm_hub: Mapped[FarmHub] = relationship(back_populates="farm_access")
|
||||
|
||||
|
||||
class HubCredential(Base):
|
||||
__tablename__ = "hub_credentials"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
farm_hub_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("farm_hubs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
secret_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
paired_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
farm_hub: Mapped[FarmHub] = relationship(back_populates="credentials")
|
||||
|
||||
|
||||
class HubPairingSession(Base):
|
||||
__tablename__ = "hub_pairing_sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
enterprise_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
code: Mapped[str] = mapped_column(String(6), nullable=False, index=True)
|
||||
code_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
farm_hub_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("farm_hubs.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
|
||||
class SyncOutbox(Base):
|
||||
__tablename__ = "sync_outbox"
|
||||
__table_args__ = (
|
||||
Index("idx_sync_outbox_enterprise_status", "enterprise_id", "status"),
|
||||
Index("idx_sync_outbox_table_record", "table_name", "record_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
enterprise_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
event_id: Mapped[str] = mapped_column(String(36), nullable=False, unique=True)
|
||||
origin: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
origin_site_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
seq: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
domain: Mapped[str] = mapped_column(String(16), nullable=False, default="global")
|
||||
table_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
record_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||
emitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
|
||||
class SyncEventLog(Base):
|
||||
__tablename__ = "sync_event_log"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
enterprise_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
event_id: Mapped[str] = mapped_column(String(36), nullable=False, unique=True)
|
||||
origin: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
origin_site_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
seq: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
domain: Mapped[str] = mapped_column(String(16), nullable=False, default="global")
|
||||
table_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
record_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
|
||||
class SyncAppliedEvent(Base):
|
||||
__tablename__ = "sync_applied_events"
|
||||
__table_args__ = (UniqueConstraint("site_id", "event_id", name="uq_sync_applied_events_site_event"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
enterprise_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
site_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
applied_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
|
||||
class SyncCursor(Base):
|
||||
__tablename__ = "sync_cursors"
|
||||
__table_args__ = (UniqueConstraint("farm_hub_id", "direction", name="uq_sync_cursors_hub_direction"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
enterprise_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
farm_hub_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("farm_hubs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
direction: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
last_acked_seq: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
last_pulled_seq: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
class SyncRecordState(Base):
|
||||
__tablename__ = "sync_record_state"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("enterprise_id", "table_name", "record_id", name="uq_sync_record_state_ent_table_record"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
enterprise_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
table_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
record_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
agreed_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
agreed_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
last_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
class SyncConflict(Base):
|
||||
__tablename__ = "sync_conflicts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
enterprise_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
table_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
record_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
farm_hub_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("farm_hubs.id", ondelete="SET NULL"), nullable=True)
|
||||
orchestrator_snapshot_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
hub_snapshot_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
held_event_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||
resolution: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
resolved_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
|
||||
class SyncReconcileRun(Base):
|
||||
__tablename__ = "sync_reconcile_runs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
enterprise_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
mismatches_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
auto_repaired: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="running")
|
||||
|
||||
|
||||
class ReportSyncState(Base):
|
||||
__tablename__ = "report_sync_state"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
farm_hub_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("farm_hubs.id", ondelete="CASCADE"), nullable=False, unique=True
|
||||
)
|
||||
enterprise_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
last_report_cursor: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
last_pull_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.sync.models import FarmHub, SyncRecordState
|
||||
from app.modules.zootech.catalog_apply import load_catalog_row
|
||||
|
||||
|
||||
def run_sync_reconcile() -> int:
|
||||
"""Compare orchestrator catalog content_hash vs sync_record_state agreed_hash."""
|
||||
mismatches = 0
|
||||
with session_scope() as db:
|
||||
hubs = list(db.scalars(select(FarmHub).where(FarmHub.status == "active")))
|
||||
states = list(db.scalars(select(SyncRecordState)))
|
||||
for state in states:
|
||||
row = load_catalog_row(state.enterprise_id, state.table_name, state.record_id)
|
||||
if not row:
|
||||
continue
|
||||
catalog_hash = str(row.get("content_hash") or "")
|
||||
if state.agreed_hash and catalog_hash and state.agreed_hash != catalog_hash:
|
||||
mismatches += 1
|
||||
state.agreed_hash = catalog_hash
|
||||
state.agreed_version = int(row.get("version") or state.agreed_version)
|
||||
state.updated_at = datetime.now(UTC)
|
||||
for hub in hubs:
|
||||
from app.modules.sync.models import SyncReconcileRun
|
||||
|
||||
db.add(
|
||||
SyncReconcileRun(
|
||||
enterprise_id=hub.enterprise_id,
|
||||
status="completed",
|
||||
mismatches_count=mismatches,
|
||||
auto_repaired=mismatches,
|
||||
finished_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
return mismatches
|
||||
@@ -0,0 +1,394 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.crypto import hash_opaque_token
|
||||
from app.core.database import session_scope
|
||||
from app.modules.sync.models import (
|
||||
Enterprise,
|
||||
EnterpriseMember,
|
||||
FarmHub,
|
||||
HubCredential,
|
||||
HubPairingSession,
|
||||
SyncAppliedEvent,
|
||||
SyncConflict,
|
||||
SyncCursor,
|
||||
SyncEventLog,
|
||||
SyncOutbox,
|
||||
SyncRecordState,
|
||||
UserFarmAccess,
|
||||
)
|
||||
|
||||
|
||||
def _detach(db, instance):
|
||||
db.refresh(instance)
|
||||
db.expunge(instance)
|
||||
return instance
|
||||
|
||||
|
||||
def get_enterprise_by_id(enterprise_id: str) -> Enterprise | None:
|
||||
with session_scope() as db:
|
||||
row = db.get(Enterprise, enterprise_id)
|
||||
return _detach(db, row) if row else None
|
||||
|
||||
|
||||
def get_enterprise_by_slug(slug: str) -> Enterprise | None:
|
||||
with session_scope() as db:
|
||||
row = db.scalar(select(Enterprise).where(Enterprise.slug == slug))
|
||||
return _detach(db, row) if row else None
|
||||
|
||||
|
||||
def create_enterprise(name: str, slug: str) -> Enterprise:
|
||||
with session_scope() as db:
|
||||
row = Enterprise(name=name, slug=slug, status="active")
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return _detach(db, row)
|
||||
|
||||
|
||||
def get_member(user_id: str, enterprise_id: str) -> EnterpriseMember | None:
|
||||
with session_scope() as db:
|
||||
row = db.scalar(
|
||||
select(EnterpriseMember).where(
|
||||
EnterpriseMember.user_id == user_id,
|
||||
EnterpriseMember.enterprise_id == enterprise_id,
|
||||
)
|
||||
)
|
||||
return _detach(db, row) if row else None
|
||||
|
||||
|
||||
def add_member(user_id: str, enterprise_id: str, role: str) -> EnterpriseMember:
|
||||
with session_scope() as db:
|
||||
row = EnterpriseMember(user_id=user_id, enterprise_id=enterprise_id, role=role)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return _detach(db, row)
|
||||
|
||||
|
||||
def list_members(enterprise_id: str) -> list[EnterpriseMember]:
|
||||
with session_scope() as db:
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(EnterpriseMember).where(EnterpriseMember.enterprise_id == enterprise_id)
|
||||
)
|
||||
)
|
||||
return [_detach(db, row) for row in rows]
|
||||
|
||||
|
||||
def set_member_role(user_id: str, enterprise_id: str, role: str) -> EnterpriseMember | None:
|
||||
with session_scope() as db:
|
||||
row = db.scalar(
|
||||
select(EnterpriseMember).where(
|
||||
EnterpriseMember.user_id == user_id,
|
||||
EnterpriseMember.enterprise_id == enterprise_id,
|
||||
)
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
row.role = role
|
||||
db.flush()
|
||||
return _detach(db, row)
|
||||
|
||||
|
||||
def list_farm_access(user_id: str, enterprise_id: str) -> list[str]:
|
||||
with session_scope() as db:
|
||||
rows = db.scalars(
|
||||
select(UserFarmAccess.farm_hub_id)
|
||||
.join(FarmHub, FarmHub.id == UserFarmAccess.farm_hub_id)
|
||||
.where(UserFarmAccess.user_id == user_id, FarmHub.enterprise_id == enterprise_id)
|
||||
).all()
|
||||
return list(rows)
|
||||
|
||||
|
||||
def grant_farm_access(user_id: str, farm_hub_id: str) -> UserFarmAccess:
|
||||
with session_scope() as db:
|
||||
existing = db.scalar(
|
||||
select(UserFarmAccess).where(
|
||||
UserFarmAccess.user_id == user_id,
|
||||
UserFarmAccess.farm_hub_id == farm_hub_id,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
return _detach(db, existing)
|
||||
row = UserFarmAccess(user_id=user_id, farm_hub_id=farm_hub_id)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return _detach(db, row)
|
||||
|
||||
|
||||
def get_farm_hub_by_site_id(hub_site_id: str) -> FarmHub | None:
|
||||
with session_scope() as db:
|
||||
row = db.scalar(select(FarmHub).where(FarmHub.hub_site_id == hub_site_id))
|
||||
return _detach(db, row) if row else None
|
||||
|
||||
|
||||
def get_farm_hub_by_id(farm_hub_id: str) -> FarmHub | None:
|
||||
with session_scope() as db:
|
||||
row = db.get(FarmHub, farm_hub_id)
|
||||
return _detach(db, row) if row else None
|
||||
|
||||
|
||||
def list_farm_hubs(enterprise_id: str) -> list[FarmHub]:
|
||||
with session_scope() as db:
|
||||
rows = list(db.scalars(select(FarmHub).where(FarmHub.enterprise_id == enterprise_id).order_by(FarmHub.name)))
|
||||
return [_detach(db, row) for row in rows]
|
||||
|
||||
|
||||
def create_pairing_session(enterprise_id: str, code: str, code_hash: str, ttl_minutes: int = 15) -> HubPairingSession:
|
||||
with session_scope() as db:
|
||||
row = HubPairingSession(
|
||||
enterprise_id=enterprise_id,
|
||||
code=code,
|
||||
code_hash=code_hash,
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=ttl_minutes),
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return _detach(db, row)
|
||||
|
||||
|
||||
def get_pairing_session_by_code_hash(code_hash: str) -> HubPairingSession | None:
|
||||
with session_scope() as db:
|
||||
row = db.scalar(
|
||||
select(HubPairingSession).where(
|
||||
HubPairingSession.code_hash == code_hash,
|
||||
HubPairingSession.confirmed_at.is_(None),
|
||||
)
|
||||
)
|
||||
return _detach(db, row) if row else None
|
||||
|
||||
|
||||
def confirm_pairing_session(session_id: str, farm_hub_id: str) -> None:
|
||||
with session_scope() as db:
|
||||
row = db.get(HubPairingSession, session_id)
|
||||
if row:
|
||||
row.confirmed_at = datetime.now(UTC)
|
||||
row.farm_hub_id = farm_hub_id
|
||||
|
||||
|
||||
def create_farm_hub(
|
||||
enterprise_id: str,
|
||||
name: str,
|
||||
hub_site_id: str,
|
||||
url: str | None = None,
|
||||
) -> FarmHub:
|
||||
with session_scope() as db:
|
||||
row = FarmHub(
|
||||
enterprise_id=enterprise_id,
|
||||
name=name,
|
||||
hub_site_id=hub_site_id,
|
||||
url=url,
|
||||
status="active",
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return _detach(db, row)
|
||||
|
||||
|
||||
def create_hub_credential(farm_hub_id: str, api_key: str) -> HubCredential:
|
||||
from app.core.crypto import hash_opaque_token as _hash
|
||||
|
||||
with session_scope() as db:
|
||||
row = HubCredential(farm_hub_id=farm_hub_id, secret_hash=_hash(api_key))
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return _detach(db, row)
|
||||
|
||||
|
||||
def verify_hub_credential(hub_site_id: str, api_key: str) -> FarmHub | None:
|
||||
from app.core.crypto import hash_opaque_token as _hash
|
||||
|
||||
key_hash = _hash(api_key)
|
||||
with session_scope() as db:
|
||||
row = db.scalar(
|
||||
select(FarmHub)
|
||||
.join(HubCredential, HubCredential.farm_hub_id == FarmHub.id)
|
||||
.where(
|
||||
FarmHub.hub_site_id == hub_site_id,
|
||||
HubCredential.secret_hash == key_hash,
|
||||
HubCredential.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
return _detach(db, row) if row else None
|
||||
|
||||
|
||||
def update_hub_heartbeat(farm_hub_id: str, wesp_version: str | None) -> None:
|
||||
with session_scope() as db:
|
||||
row = db.get(FarmHub, farm_hub_id)
|
||||
if row:
|
||||
row.last_seen = datetime.now(UTC)
|
||||
if wesp_version:
|
||||
row.wesp_version = wesp_version
|
||||
|
||||
|
||||
def next_seq(enterprise_id: str) -> int:
|
||||
with session_scope() as db:
|
||||
current = db.scalar(
|
||||
select(func.max(SyncEventLog.seq)).where(SyncEventLog.enterprise_id == enterprise_id)
|
||||
)
|
||||
return int(current or 0) + 1
|
||||
|
||||
|
||||
def event_exists(event_id: str) -> bool:
|
||||
with session_scope() as db:
|
||||
row = db.scalar(select(SyncEventLog.id).where(SyncEventLog.event_id == event_id))
|
||||
return row is not None
|
||||
|
||||
|
||||
def append_event_log(**kwargs) -> SyncEventLog:
|
||||
with session_scope() as db:
|
||||
row = SyncEventLog(**kwargs)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return _detach(db, row)
|
||||
|
||||
|
||||
def applied_event_exists(site_id: str, event_id: str) -> bool:
|
||||
with session_scope() as db:
|
||||
row = db.scalar(
|
||||
select(SyncAppliedEvent.id).where(
|
||||
SyncAppliedEvent.site_id == site_id,
|
||||
SyncAppliedEvent.event_id == event_id,
|
||||
)
|
||||
)
|
||||
return row is not None
|
||||
|
||||
|
||||
def mark_applied(enterprise_id: str, site_id: str, event_id: str) -> None:
|
||||
with session_scope() as db:
|
||||
db.add(
|
||||
SyncAppliedEvent(
|
||||
enterprise_id=enterprise_id,
|
||||
site_id=site_id,
|
||||
event_id=event_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_record_state(enterprise_id: str, table_name: str, record_id: str) -> SyncRecordState | None:
|
||||
with session_scope() as db:
|
||||
row = db.scalar(
|
||||
select(SyncRecordState).where(
|
||||
SyncRecordState.enterprise_id == enterprise_id,
|
||||
SyncRecordState.table_name == table_name,
|
||||
SyncRecordState.record_id == record_id,
|
||||
)
|
||||
)
|
||||
return _detach(db, row) if row else None
|
||||
|
||||
|
||||
def upsert_record_state(
|
||||
enterprise_id: str,
|
||||
table_name: str,
|
||||
record_id: str,
|
||||
agreed_version: int,
|
||||
agreed_hash: str,
|
||||
last_event_id: str,
|
||||
) -> None:
|
||||
with session_scope() as db:
|
||||
row = db.scalar(
|
||||
select(SyncRecordState).where(
|
||||
SyncRecordState.enterprise_id == enterprise_id,
|
||||
SyncRecordState.table_name == table_name,
|
||||
SyncRecordState.record_id == record_id,
|
||||
)
|
||||
)
|
||||
if row:
|
||||
row.agreed_version = agreed_version
|
||||
row.agreed_hash = agreed_hash
|
||||
row.last_event_id = last_event_id
|
||||
row.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
db.add(
|
||||
SyncRecordState(
|
||||
enterprise_id=enterprise_id,
|
||||
table_name=table_name,
|
||||
record_id=record_id,
|
||||
agreed_version=agreed_version,
|
||||
agreed_hash=agreed_hash,
|
||||
last_event_id=last_event_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def list_conflicts(enterprise_id: str, status: str = "pending") -> list[SyncConflict]:
|
||||
with session_scope() as db:
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(SyncConflict)
|
||||
.where(SyncConflict.enterprise_id == enterprise_id, SyncConflict.status == status)
|
||||
.order_by(SyncConflict.created_at.desc())
|
||||
)
|
||||
)
|
||||
return [_detach(db, row) for row in rows]
|
||||
|
||||
|
||||
def get_conflict(conflict_id: str) -> SyncConflict | None:
|
||||
with session_scope() as db:
|
||||
row = db.get(SyncConflict, conflict_id)
|
||||
return _detach(db, row) if row else None
|
||||
|
||||
|
||||
def get_or_create_cursor(enterprise_id: str, farm_hub_id: str, direction: str) -> SyncCursor:
|
||||
with session_scope() as db:
|
||||
row = db.scalar(
|
||||
select(SyncCursor).where(
|
||||
SyncCursor.farm_hub_id == farm_hub_id,
|
||||
SyncCursor.direction == direction,
|
||||
)
|
||||
)
|
||||
if row:
|
||||
return _detach(db, row)
|
||||
row = SyncCursor(
|
||||
enterprise_id=enterprise_id,
|
||||
farm_hub_id=farm_hub_id,
|
||||
direction=direction,
|
||||
last_acked_seq=0,
|
||||
last_pulled_seq=0,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return _detach(db, row)
|
||||
|
||||
|
||||
def update_cursor_ack(farm_hub_id: str, direction: str, last_acked_seq: int) -> None:
|
||||
with session_scope() as db:
|
||||
row = db.scalar(
|
||||
select(SyncCursor).where(
|
||||
SyncCursor.farm_hub_id == farm_hub_id,
|
||||
SyncCursor.direction == direction,
|
||||
)
|
||||
)
|
||||
if row:
|
||||
row.last_acked_seq = max(row.last_acked_seq, last_acked_seq)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def pull_events_since(
|
||||
enterprise_id: str,
|
||||
cursor: int,
|
||||
limit: int,
|
||||
*,
|
||||
exclude_origin_site_id: str | None = None,
|
||||
) -> list[SyncEventLog]:
|
||||
with session_scope() as db:
|
||||
query = select(SyncEventLog).where(
|
||||
SyncEventLog.enterprise_id == enterprise_id,
|
||||
SyncEventLog.seq > cursor,
|
||||
)
|
||||
if exclude_origin_site_id:
|
||||
query = query.where(
|
||||
(SyncEventLog.origin_site_id.is_(None))
|
||||
| (SyncEventLog.origin_site_id != exclude_origin_site_id)
|
||||
)
|
||||
rows = list(db.scalars(query.order_by(SyncEventLog.seq.asc()).limit(limit)))
|
||||
return [_detach(db, row) for row in rows]
|
||||
|
||||
|
||||
def get_farm_hub_site_id(farm_hub_id: str) -> str | None:
|
||||
with session_scope() as db:
|
||||
row = db.get(FarmHub, farm_hub_id)
|
||||
return row.hub_site_id if row else None
|
||||
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.core.dependencies import require_superuser
|
||||
from app.modules.sync import service
|
||||
from app.modules.sync.schemas import (
|
||||
AckChangesRequest,
|
||||
AckChangesResponse,
|
||||
HeartbeatRequest,
|
||||
HeartbeatResponse,
|
||||
PairConfirmRequest,
|
||||
PairConfirmResponse,
|
||||
PairStartRequest,
|
||||
PullChangesRequest,
|
||||
PullChangesResponse,
|
||||
PushChangesRequest,
|
||||
PushChangesResponse,
|
||||
ResolveConflictRequest,
|
||||
SyncCapabilitiesResponse,
|
||||
)
|
||||
from app.modules.sync.service import SyncServiceError
|
||||
from app.modules.sync.tenant import (
|
||||
HubPrincipal,
|
||||
TenantContext,
|
||||
get_hub_auth,
|
||||
get_tenant_context,
|
||||
require_enterprise_zootech,
|
||||
)
|
||||
from app.modules.users.models import User
|
||||
from app.core.dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _map_error(exc: SyncServiceError) -> HTTPException:
|
||||
code = str(exc)
|
||||
status_code = status.HTTP_400_BAD_REQUEST
|
||||
if code in {"ENTERPRISE_ADMIN_ONLY", "ENTERPRISE_FORBIDDEN", "ENTERPRISE_ZOOTECH_ONLY"}:
|
||||
status_code = status.HTTP_403_FORBIDDEN
|
||||
if code in {"INVALID_PAIRING_CODE", "PAIRING_CODE_EXPIRED"}:
|
||||
status_code = status.HTTP_400_BAD_REQUEST
|
||||
if code == "CONFLICT_NOT_FOUND":
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
return HTTPException(status_code=status_code, detail=code)
|
||||
|
||||
|
||||
@router.post("/reports/push")
|
||||
def reports_push(body: PushChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> PushChangesResponse:
|
||||
"""Report domain ingest — same durability path as global push."""
|
||||
return service.push_changes(hub, body)
|
||||
|
||||
|
||||
@router.post("/reports/refresh")
|
||||
def reports_refresh(
|
||||
farm_hub_id: str = Query(...),
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
if tenant.farm_hub_ids is not None and farm_hub_id not in tenant.farm_hub_ids:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="FARM_ACCESS_FORBIDDEN")
|
||||
return {"status": "queued", "farm_hub_id": farm_hub_id, "enterprise_id": tenant.enterprise_id}
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
def sync_metrics(
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
if tenant.enterprise_id != enterprise_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||
return service.get_sync_metrics(enterprise_id)
|
||||
|
||||
|
||||
@router.get("/capabilities", response_model=SyncCapabilitiesResponse)
|
||||
def capabilities() -> SyncCapabilitiesResponse:
|
||||
return service.get_capabilities()
|
||||
|
||||
|
||||
@router.post("/changes/push", response_model=PushChangesResponse)
|
||||
def push_changes(body: PushChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> PushChangesResponse:
|
||||
return service.push_changes(hub, body)
|
||||
|
||||
|
||||
@router.post("/changes/pull", response_model=PullChangesResponse)
|
||||
def pull_changes(body: PullChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> PullChangesResponse:
|
||||
return service.pull_changes(hub, body)
|
||||
|
||||
|
||||
@router.post("/changes/ack", response_model=AckChangesResponse)
|
||||
def ack_changes(body: AckChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> AckChangesResponse:
|
||||
return service.ack_changes(hub, body)
|
||||
|
||||
|
||||
@router.post("/hubs/heartbeat", response_model=HeartbeatResponse)
|
||||
def heartbeat(body: HeartbeatRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> HeartbeatResponse:
|
||||
return service.hub_heartbeat(hub, body)
|
||||
|
||||
|
||||
@router.get("/hub/conflicts")
|
||||
def list_hub_conflicts(hub: HubPrincipal = Depends(get_hub_auth)):
|
||||
return service.list_conflicts_for_enterprise(hub.enterprise_id)
|
||||
|
||||
|
||||
@router.get("/hub/conflicts/{conflict_id}")
|
||||
def get_hub_conflict(conflict_id: str, hub: HubPrincipal = Depends(get_hub_auth)):
|
||||
try:
|
||||
return service.get_conflict_detail(conflict_id, hub.enterprise_id)
|
||||
except SyncServiceError as exc:
|
||||
raise _map_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/hub/conflicts/{conflict_id}/resolve")
|
||||
def resolve_hub_conflict(
|
||||
conflict_id: str,
|
||||
body: ResolveConflictRequest,
|
||||
hub: HubPrincipal = Depends(get_hub_auth),
|
||||
):
|
||||
try:
|
||||
service.resolve_conflict(conflict_id, hub.enterprise_id, f"hub:{hub.hub_site_id}", body)
|
||||
return {"status": "ok"}
|
||||
except SyncServiceError as exc:
|
||||
raise _map_error(exc) from exc
|
||||
|
||||
|
||||
@router.get("/conflicts")
|
||||
def list_conflicts(
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
if tenant.enterprise_id != enterprise_id and tenant.enterprise_role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||
return service.list_conflicts_for_enterprise(enterprise_id)
|
||||
|
||||
|
||||
@router.get("/conflicts/{conflict_id}")
|
||||
def get_conflict(
|
||||
conflict_id: str,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
try:
|
||||
return service.get_conflict_detail(conflict_id, enterprise_id)
|
||||
except SyncServiceError as exc:
|
||||
raise _map_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/conflicts/{conflict_id}/resolve")
|
||||
def resolve_conflict(
|
||||
conflict_id: str,
|
||||
body: ResolveConflictRequest,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
try:
|
||||
service.resolve_conflict(conflict_id, enterprise_id, tenant.user_id or "", body)
|
||||
return {"status": "ok"}
|
||||
except SyncServiceError as exc:
|
||||
raise _map_error(exc) from exc
|
||||
|
||||
|
||||
enterprise_router = APIRouter()
|
||||
|
||||
|
||||
@enterprise_router.post("/pair/start")
|
||||
def pair_start(body: PairStartRequest, user: User = Depends(get_current_user)):
|
||||
try:
|
||||
return service.start_pairing(user, body)
|
||||
except SyncServiceError as exc:
|
||||
raise _map_error(exc) from exc
|
||||
|
||||
|
||||
@enterprise_router.post("/pair/confirm", response_model=PairConfirmResponse)
|
||||
def pair_confirm(body: PairConfirmRequest) -> PairConfirmResponse:
|
||||
try:
|
||||
return service.confirm_pairing(body)
|
||||
except SyncServiceError as exc:
|
||||
raise _map_error(exc) from exc
|
||||
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
ChangeAction = Literal["upsert", "delete"]
|
||||
ChangeDomain = Literal["global", "report"]
|
||||
EnterpriseRole = Literal["admin", "zootech", "viewer"]
|
||||
ConflictResolution = Literal["keep_orchestrator", "keep_hub"]
|
||||
|
||||
|
||||
class ChangeEventIn(BaseModel):
|
||||
event_id: str
|
||||
seq: int
|
||||
domain: ChangeDomain = "global"
|
||||
table: str = Field(alias="table")
|
||||
record_id: str
|
||||
action: ChangeAction
|
||||
version: int = 1
|
||||
content_hash: str = ""
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
emitted_at: datetime
|
||||
origin_site_id: str | None = None
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class ChangeEventOut(BaseModel):
|
||||
event_id: str
|
||||
seq: int
|
||||
domain: ChangeDomain
|
||||
table: str
|
||||
record_id: str
|
||||
action: ChangeAction
|
||||
version: int
|
||||
content_hash: str
|
||||
payload: dict[str, Any]
|
||||
emitted_at: datetime
|
||||
origin_site_id: str | None = None
|
||||
|
||||
|
||||
class PushChangesRequest(BaseModel):
|
||||
events: list[ChangeEventIn]
|
||||
|
||||
|
||||
class PushChangesResponse(BaseModel):
|
||||
applied_event_ids: list[str]
|
||||
held_event_ids: list[str]
|
||||
conflicts: list[str]
|
||||
|
||||
|
||||
class PullChangesRequest(BaseModel):
|
||||
cursor: int = 0
|
||||
limit: int = 100
|
||||
|
||||
|
||||
class PullChangesResponse(BaseModel):
|
||||
events: list[ChangeEventOut]
|
||||
next_cursor: int
|
||||
|
||||
|
||||
class AckChangesRequest(BaseModel):
|
||||
event_ids: list[str]
|
||||
direction: Literal["inbound", "outbound"] = "inbound"
|
||||
|
||||
|
||||
class AckChangesResponse(BaseModel):
|
||||
last_acked_seq: int
|
||||
|
||||
|
||||
class SyncCapabilitiesResponse(BaseModel):
|
||||
protocol_version: str = "1.0"
|
||||
domains: list[str] = Field(default_factory=lambda: ["global", "report"])
|
||||
|
||||
|
||||
class HeartbeatRequest(BaseModel):
|
||||
wesp_version: str | None = None
|
||||
lag_seconds: int | None = None
|
||||
|
||||
|
||||
class HeartbeatResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
server_time: datetime
|
||||
|
||||
|
||||
class PairStartRequest(BaseModel):
|
||||
enterprise_id: str
|
||||
farm_name: str
|
||||
|
||||
|
||||
class PairStartResponse(BaseModel):
|
||||
session_id: str
|
||||
code: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class PairConfirmRequest(BaseModel):
|
||||
code: str
|
||||
hub_site_id: str
|
||||
hub_name: str | None = None
|
||||
hub_url: str | None = None
|
||||
|
||||
|
||||
class PairConfirmResponse(BaseModel):
|
||||
farm_hub_id: str
|
||||
hub_site_id: str
|
||||
api_key: str
|
||||
enterprise_id: str
|
||||
|
||||
|
||||
class ConflictSummary(BaseModel):
|
||||
id: str
|
||||
table_name: str
|
||||
record_id: str
|
||||
farm_hub_id: str | None
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ConflictDetail(ConflictSummary):
|
||||
orchestrator_snapshot: dict[str, Any]
|
||||
hub_snapshot: dict[str, Any]
|
||||
held_event_ids: list[str]
|
||||
|
||||
|
||||
class ResolveConflictRequest(BaseModel):
|
||||
resolution: ConflictResolution
|
||||
|
||||
|
||||
class EnterpriseOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
status: str
|
||||
|
||||
|
||||
class FarmHubOut(BaseModel):
|
||||
id: str
|
||||
enterprise_id: str
|
||||
name: str
|
||||
hub_site_id: str
|
||||
url: str | None
|
||||
status: str
|
||||
last_seen: datetime | None
|
||||
wesp_version: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class HubAuthContext:
|
||||
farm_hub_id: str
|
||||
hub_site_id: str
|
||||
enterprise_id: str
|
||||
farm_hub: Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class TenantContext:
|
||||
enterprise_id: str
|
||||
enterprise_role: EnterpriseRole | None
|
||||
user_id: str | None
|
||||
farm_hub_ids: list[str] | None
|
||||
@@ -0,0 +1,178 @@
|
||||
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),
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from app.core.dependencies import bearer, get_current_user
|
||||
from app.core.security import decode_access_token
|
||||
from app.modules.sync import repository as sync_repo
|
||||
from app.modules.sync.schemas import HubAuthContext, TenantContext
|
||||
from app.modules.users.models import User
|
||||
|
||||
|
||||
@dataclass
|
||||
class HubPrincipal:
|
||||
farm_hub_id: str
|
||||
hub_site_id: str
|
||||
enterprise_id: str
|
||||
|
||||
|
||||
def get_hub_auth(authorization: str | None = Header(default=None)) -> HubPrincipal:
|
||||
if not authorization or not authorization.startswith("Hub "):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="HUB_UNAUTHORIZED")
|
||||
token = authorization[4:].strip()
|
||||
if ":" not in token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="HUB_UNAUTHORIZED")
|
||||
hub_site_id, api_key = token.split(":", 1)
|
||||
hub = sync_repo.verify_hub_credential(hub_site_id.strip(), api_key.strip())
|
||||
if not hub:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="HUB_UNAUTHORIZED")
|
||||
return HubPrincipal(farm_hub_id=hub.id, hub_site_id=hub.hub_site_id, enterprise_id=hub.enterprise_id)
|
||||
|
||||
|
||||
def get_tenant_context(
|
||||
enterprise_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
) -> TenantContext:
|
||||
if user.is_superuser:
|
||||
return TenantContext(
|
||||
enterprise_id=enterprise_id,
|
||||
enterprise_role="admin",
|
||||
user_id=user.id,
|
||||
farm_hub_ids=None,
|
||||
)
|
||||
if credentials:
|
||||
try:
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
if payload.get("enterprise_id") == enterprise_id:
|
||||
farm_ids = payload.get("farm_ids")
|
||||
return TenantContext(
|
||||
enterprise_id=enterprise_id,
|
||||
enterprise_role=payload.get("enterprise_role", "viewer"), # type: ignore[arg-type]
|
||||
user_id=user.id,
|
||||
farm_hub_ids=farm_ids,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
member = sync_repo.get_member(user.id, enterprise_id)
|
||||
if not member:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||
farm_ids = None if member.role == "admin" else sync_repo.list_farm_access(user.id, enterprise_id)
|
||||
return TenantContext(
|
||||
enterprise_id=enterprise_id,
|
||||
enterprise_role=member.role, # type: ignore[arg-type]
|
||||
user_id=user.id,
|
||||
farm_hub_ids=farm_ids,
|
||||
)
|
||||
|
||||
|
||||
def require_farm_access(
|
||||
farm_hub_id: str,
|
||||
enterprise_id: str,
|
||||
tenant: TenantContext,
|
||||
) -> None:
|
||||
if tenant.enterprise_role == "admin" or tenant.farm_hub_ids is None:
|
||||
return
|
||||
if farm_hub_id not in (tenant.farm_hub_ids or []):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="FARM_ACCESS_FORBIDDEN")
|
||||
|
||||
|
||||
def require_enterprise_admin(tenant: TenantContext = Depends(get_tenant_context)) -> TenantContext:
|
||||
if tenant.enterprise_role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_ADMIN_ONLY")
|
||||
return tenant
|
||||
|
||||
|
||||
def require_enterprise_zootech(tenant: TenantContext = Depends(get_tenant_context)) -> TenantContext:
|
||||
if tenant.enterprise_role not in ("admin", "zootech"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_ZOOTECH_ONLY")
|
||||
return tenant
|
||||
Reference in New Issue
Block a user