Files
microfish/backend/app/models/operations.py
Kunthawat Greethong 8b84378fe1 feat: SaaS foundation for CrowdSight
Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation:

- Local memory backend (Zep-compatible): memory services/models, local graph
  builder + updater, AgentActivity seam, import-boundary isolation; Zep stays
  default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven.
- Durable product persistence: projects/simulations/reports schema (migration
  0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project
  read-first + ArtifactStore abstraction; durable JobQueue + worker.py.
- SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM
  accounting), redacted AuditService, idempotency, CORS allowlist, safe API
  errors, single-use PasswordResetService + endpoints (covers invite-pending).
- Exactly 3 roles (super_admin/admin/user) with tenant authz policy.
- Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings
  (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with
  admin/super-admin route guards, th/en i18n.
- Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn
  wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep.

Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap).
No commit of credentials; secrets handled via env/.env.example.
Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel
container build of deploy topology.
2026-08-31 13:05:21 +07:00

129 lines
5.7 KiB
Python

"""Durable operation metadata for jobs, retries, idempotency, and audit."""
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from uuid import uuid4
from sqlalchemy import JSON, CheckConstraint, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func, text
from sqlalchemy.orm import Mapped, mapped_column
from ..db import Base
def _id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex}"
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
class JobStatus(str, Enum):
QUEUED = "queued"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELLED = "cancelled"
class Job(Base):
"""Durable, tenant-owned unit of asynchronous work."""
__tablename__ = "jobs"
__table_args__ = (
CheckConstraint(
"status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')",
name="ck_jobs_status",
),
Index("ix_jobs_org_status_created", "organization_id", "status", "created_at"),
Index("ix_jobs_org_owner", "organization_id", "owner_user_id"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: _id("job"))
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
owner_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
project_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
graph_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
operation: Mapped[str] = mapped_column(String(120), nullable=False)
status: Mapped[JobStatus] = mapped_column(
String(32), nullable=False, default=JobStatus.QUEUED.value, server_default=text("'queued'")
)
progress: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0"))
message: Mapped[str] = mapped_column(Text, nullable=False, default="", server_default=text("''"))
result: Mapped[dict | list | None] = mapped_column(JSON, nullable=True)
progress_detail: Mapped[dict | list | None] = mapped_column(JSON, nullable=True)
job_metadata: Mapped[dict | list | None] = mapped_column("metadata", JSON, nullable=True)
error_code: Mapped[str | None] = mapped_column(String(120), nullable=True)
result_ref: Mapped[str | None] = mapped_column(String(512), nullable=True)
idempotency_key: Mapped[str | None] = mapped_column(String(128), nullable=True)
attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0"))
settings_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utc_now, server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utc_now, onupdate=_utc_now, server_default=func.now(), nullable=False
)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class IdempotencyRecord(Base):
"""Request fingerprint and replayable response for retry-safe mutations."""
__tablename__ = "idempotency_records"
__table_args__ = (
UniqueConstraint("organization_id", "user_id", "key", name="uq_idempotency_org_user_key"),
Index("ix_idempotency_expiry", "expires_at"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: _id("idem"))
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
key: Mapped[str] = mapped_column(String(128), nullable=False)
request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default="reserved", server_default=text("'reserved'")
)
response_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
response_body: Mapped[dict | list | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utc_now, server_default=func.now(), nullable=False
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class AuditLog(Base):
"""Tenant-scoped redacted audit event; details must never contain secrets."""
__tablename__ = "audit_logs"
__table_args__ = (
Index("ix_audit_org_created", "organization_id", "created_at"),
Index("ix_audit_org_target", "organization_id", "target_type", "target_id"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: _id("audit"))
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
actor_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
action: Mapped[str] = mapped_column(String(160), nullable=False)
target_type: Mapped[str] = mapped_column(String(80), nullable=False)
target_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
details: Mapped[dict | list | None] = mapped_column("metadata", JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utc_now, server_default=func.now(), nullable=False
)