Initial commit: site monorepo with API, web, and infra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
влад
2026-07-16 10:11:54 +03:00
co-authored by Cursor
commit 016910ffb7
447 changed files with 73972 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""WESP sync orchestrator: enterprise tenants, hub pairing, ChangeEvent protocol."""
+381
View File
@@ -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"
+299
View File
@@ -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)
+41
View File
@@ -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
+394
View File
@@ -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
+178
View File
@@ -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
+165
View File
@@ -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
+178
View File
@@ -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),
}
+92
View File
@@ -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