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
+69
View File
@@ -0,0 +1,69 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.db.base import Base
# Import models so Alembic can discover metadata.
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken # noqa: F401
from app.modules.content.models import ContentPage # noqa: F401
from app.modules.sync.models import ( # noqa: F401
Enterprise,
EnterpriseMember,
FarmHub,
HubCredential,
HubPairingSession,
ReportSyncState,
SyncAppliedEvent,
SyncConflict,
SyncCursor,
SyncEventLog,
SyncOutbox,
SyncReconcileRun,
SyncRecordState,
UserFarmAccess,
)
from app.modules.users.models import User, UserProfile # noqa: F401
from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
transaction_per_migration=True,
)
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,120 @@
"""initial schema
Revision ID: 20260711_0001
Revises:
Create Date: 2026-07-11 14:00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260711_0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("role", sa.String(length=16), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("failed_login_attempts", sa.Integer(), nullable=False),
sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_index("idx_users_email", "users", ["email"], unique=True)
op.create_index("idx_users_status", "users", ["status"], unique=False)
op.create_table(
"user_profiles",
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("display_name", sa.String(length=120), nullable=False),
sa.Column("avatar_url", sa.String(length=512), nullable=True),
sa.Column("metadata_json", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id"),
)
op.create_table(
"refresh_tokens",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("family_id", sa.String(length=36), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("idx_refresh_tokens_user", "refresh_tokens", ["user_id"], unique=False)
op.create_index("idx_refresh_tokens_family", "refresh_tokens", ["family_id"], unique=False)
op.create_table(
"password_reset_tokens",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("idx_password_reset_user", "password_reset_tokens", ["user_id"], unique=False)
op.create_table(
"email_verification_tokens",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_table(
"content_pages",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("slug", sa.String(length=120), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("author_id", sa.String(length=36), nullable=True),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["author_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("slug"),
)
op.create_index("idx_content_slug", "content_pages", ["slug"], unique=True)
op.create_index("idx_content_status", "content_pages", ["status"], unique=False)
def downgrade() -> None:
op.drop_index("idx_content_status", table_name="content_pages")
op.drop_index("idx_content_slug", table_name="content_pages")
op.drop_table("content_pages")
op.drop_table("email_verification_tokens")
op.drop_index("idx_password_reset_user", table_name="password_reset_tokens")
op.drop_table("password_reset_tokens")
op.drop_index("idx_refresh_tokens_family", table_name="refresh_tokens")
op.drop_index("idx_refresh_tokens_user", table_name="refresh_tokens")
op.drop_table("refresh_tokens")
op.drop_table("user_profiles")
op.drop_index("idx_users_status", table_name="users")
op.drop_index("idx_users_email", table_name="users")
op.drop_table("users")
@@ -0,0 +1,33 @@
"""seed admin and demo content
Revision ID: 20260711_0002
Revises: 20260711_0001
Create Date: 2026-07-11 14:05:00
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260711_0002"
down_revision: Union[str, None] = "20260711_0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Demo users and CMS pages are seeded on API startup (app.main lifespan → run_seed)
# after all schema migrations, including is_superuser (20260714_0003).
pass
def downgrade() -> None:
from sqlalchemy import delete
from app.core.database import session_scope
from app.modules.content.models import ContentPage
from app.modules.users.models import User
with session_scope() as db:
db.execute(delete(ContentPage).where(ContentPage.slug.in_(["about", "privacy", "terms"])))
db.execute(delete(User).where(User.email == "admin@compton.example"))
@@ -0,0 +1,36 @@
"""add is_superuser to users
Revision ID: 20260714_0003
Revises: 20260711_0002
Create Date: 2026-07-14 12:45:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260714_0003"
down_revision: Union[str, None] = "20260711_0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default=sa.false()),
)
bind = op.get_bind()
if bind.dialect.name != "sqlite":
op.alter_column("users", "is_superuser", server_default=None)
op.execute(
sa.text(
"UPDATE users SET is_superuser = true "
"WHERE email = 'admin@compton.example' AND role = 'admin'"
)
)
def downgrade() -> None:
op.drop_column("users", "is_superuser")
@@ -0,0 +1,55 @@
"""db security constraints and token indexes
Revision ID: 20260714_0004
Revises: 20260714_0003
Create Date: 2026-07-14 13:30:00
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260714_0004"
down_revision: Union[str, None] = "20260714_0003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name != "sqlite":
op.create_check_constraint(
"ck_users_role_allowed",
"users",
"role IN ('user', 'admin')",
)
op.create_check_constraint(
"ck_users_status_allowed",
"users",
"status IN ('pending', 'active', 'blocked')",
)
op.create_check_constraint(
"ck_users_superuser_requires_admin",
"users",
"(is_superuser = false) OR (role = 'admin')",
)
op.create_check_constraint(
"ck_content_pages_status_allowed",
"content_pages",
"status IN ('draft', 'published')",
)
op.create_index("idx_refresh_tokens_expires_at", "refresh_tokens", ["expires_at"], unique=False)
op.create_index("idx_password_reset_expires_at", "password_reset_tokens", ["expires_at"], unique=False)
op.create_index("idx_email_verify_expires_at", "email_verification_tokens", ["expires_at"], unique=False)
def downgrade() -> None:
op.drop_index("idx_email_verify_expires_at", table_name="email_verification_tokens")
op.drop_index("idx_password_reset_expires_at", table_name="password_reset_tokens")
op.drop_index("idx_refresh_tokens_expires_at", table_name="refresh_tokens")
bind = op.get_bind()
if bind.dialect.name != "sqlite":
op.drop_constraint("ck_content_pages_status_allowed", "content_pages", type_="check")
op.drop_constraint("ck_users_superuser_requires_admin", "users", type_="check")
op.drop_constraint("ck_users_status_allowed", "users", type_="check")
op.drop_constraint("ck_users_role_allowed", "users", type_="check")
@@ -0,0 +1,35 @@
"""backfill superuser flag for seeded admin
Revision ID: 20260714_0005
Revises: 20260714_0004
Create Date: 2026-07-14 14:00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260714_0005"
down_revision: Union[str, None] = "20260714_0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
sa.text(
"UPDATE users SET is_superuser = true "
"WHERE email = 'admin@compton.example' AND role = 'admin'"
)
)
op.execute(
sa.text(
"UPDATE users SET is_superuser = false "
"WHERE email IN ('ops@compton.example', 'user@compton.example')"
)
)
def downgrade() -> None:
pass
@@ -0,0 +1,367 @@
"""enterprise tenant and sync durability schema
Revision ID: 20260715_0006
Revises: 20260714_0005
Create Date: 2026-07-15 12:00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260715_0006"
down_revision: Union[str, None] = "20260714_0005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"enterprises",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("slug", sa.String(length=64), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("slug"),
)
op.create_index("idx_enterprises_slug", "enterprises", ["slug"], unique=True)
op.create_index("idx_enterprises_status", "enterprises", ["status"], unique=False)
op.create_table(
"enterprise_members",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("role", sa.String(length=32), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "enterprise_id", name="uq_enterprise_members_user_enterprise"),
)
op.create_index("idx_enterprise_members_user", "enterprise_members", ["user_id"], unique=False)
op.create_index("idx_enterprise_members_enterprise", "enterprise_members", ["enterprise_id"], unique=False)
op.create_table(
"farm_hubs",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("hub_site_id", sa.String(length=36), nullable=False),
sa.Column("url", sa.String(length=512), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("last_seen", sa.DateTime(timezone=True), nullable=True),
sa.Column("wesp_version", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("hub_site_id"),
)
op.create_index("idx_farm_hubs_enterprise", "farm_hubs", ["enterprise_id"], unique=False)
op.create_index("idx_farm_hubs_status", "farm_hubs", ["status"], unique=False)
op.create_table(
"user_farm_access",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("farm_hub_id", sa.String(length=36), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "farm_hub_id", name="uq_user_farm_access_user_farm"),
)
op.create_index("idx_user_farm_access_user", "user_farm_access", ["user_id"], unique=False)
op.create_index("idx_user_farm_access_farm", "user_farm_access", ["farm_hub_id"], unique=False)
op.create_table(
"hub_credentials",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("farm_hub_id", sa.String(length=36), nullable=False),
sa.Column("secret_hash", sa.String(length=255), nullable=False),
sa.Column("paired_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_hub_credentials_farm", "hub_credentials", ["farm_hub_id"], unique=False)
op.create_table(
"hub_pairing_sessions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("code", sa.String(length=6), nullable=False),
sa.Column("code_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("farm_hub_id", sa.String(length=36), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_hub_pairing_code", "hub_pairing_sessions", ["code"], unique=False)
op.create_index("idx_hub_pairing_enterprise", "hub_pairing_sessions", ["enterprise_id"], unique=False)
_create_sync_tables()
_create_zootech_tables()
bind = op.get_bind()
if bind.dialect.name == "postgresql":
_create_rls_policies()
def _create_sync_tables() -> None:
op.create_table(
"sync_outbox",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("event_id", sa.String(length=36), nullable=False),
sa.Column("origin", sa.String(length=32), nullable=False),
sa.Column("origin_site_id", sa.String(length=36), nullable=True),
sa.Column("seq", sa.BigInteger(), nullable=False),
sa.Column("domain", sa.String(length=16), nullable=False),
sa.Column("table_name", sa.String(length=64), nullable=False),
sa.Column("record_id", sa.String(length=64), nullable=False),
sa.Column("action", sa.String(length=16), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("emitted_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("event_id"),
)
op.create_index("idx_sync_outbox_enterprise_status", "sync_outbox", ["enterprise_id", "status"], unique=False)
op.create_index("idx_sync_outbox_table_record", "sync_outbox", ["table_name", "record_id"], unique=False)
op.create_table(
"sync_event_log",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("event_id", sa.String(length=36), nullable=False),
sa.Column("origin", sa.String(length=32), nullable=False),
sa.Column("origin_site_id", sa.String(length=36), nullable=True),
sa.Column("seq", sa.BigInteger(), nullable=False),
sa.Column("domain", sa.String(length=16), nullable=False),
sa.Column("table_name", sa.String(length=64), nullable=False),
sa.Column("record_id", sa.String(length=64), nullable=False),
sa.Column("action", sa.String(length=16), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=False),
sa.Column("received_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("event_id"),
)
op.create_index("idx_sync_event_log_enterprise", "sync_event_log", ["enterprise_id"], unique=False)
op.create_index("idx_sync_event_log_seq", "sync_event_log", ["seq"], unique=False)
op.create_table(
"sync_applied_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("site_id", sa.String(length=36), nullable=False),
sa.Column("event_id", sa.String(length=36), nullable=False),
sa.Column("applied_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("site_id", "event_id", name="uq_sync_applied_events_site_event"),
)
op.create_index("idx_sync_applied_enterprise", "sync_applied_events", ["enterprise_id"], unique=False)
op.create_table(
"sync_cursors",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("farm_hub_id", sa.String(length=36), nullable=False),
sa.Column("direction", sa.String(length=16), nullable=False),
sa.Column("last_acked_seq", sa.BigInteger(), nullable=False),
sa.Column("last_pulled_seq", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("farm_hub_id", "direction", name="uq_sync_cursors_hub_direction"),
)
op.create_table(
"sync_record_state",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("table_name", sa.String(length=64), nullable=False),
sa.Column("record_id", sa.String(length=64), nullable=False),
sa.Column("agreed_version", sa.Integer(), nullable=False),
sa.Column("agreed_hash", sa.String(length=64), nullable=False),
sa.Column("last_event_id", sa.String(length=36), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", "table_name", "record_id", name="uq_sync_record_state_ent_table_record"),
)
op.create_table(
"sync_conflicts",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("table_name", sa.String(length=64), nullable=False),
sa.Column("record_id", sa.String(length=64), nullable=False),
sa.Column("farm_hub_id", sa.String(length=36), nullable=True),
sa.Column("orchestrator_snapshot_json", sa.Text(), nullable=False),
sa.Column("hub_snapshot_json", sa.Text(), nullable=False),
sa.Column("held_event_ids_json", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("resolution", sa.String(length=32), nullable=True),
sa.Column("resolved_by", sa.String(length=36), nullable=True),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_sync_conflicts_status", "sync_conflicts", ["status"], unique=False)
op.create_index("idx_sync_conflicts_record", "sync_conflicts", ["record_id"], unique=False)
op.create_table(
"sync_reconcile_runs",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("mismatches_count", sa.Integer(), nullable=False),
sa.Column("auto_repaired", sa.Integer(), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"report_sync_state",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("farm_hub_id", sa.String(length=36), nullable=False),
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("last_report_cursor", sa.BigInteger(), nullable=False),
sa.Column("last_pull_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("farm_hub_id"),
)
def _create_zootech_tables() -> None:
op.create_table(
"zootech_component",
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("type", sa.String(length=100), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("dry_matter", sa.Float(), nullable=False),
sa.Column("protein", sa.Float(), nullable=False),
sa.Column("energy", sa.Float(), nullable=False),
sa.Column("price", sa.Float(), nullable=False),
sa.Column("external_no", sa.Integer(), nullable=True),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_component_ent_id"),
)
op.create_index("idx_zootech_component_enterprise", "zootech_component", ["enterprise_id"], unique=False)
op.create_table(
"zootech_recipe",
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("heads_per_trip", sa.Integer(), nullable=False),
sa.Column("mixing_time", sa.Integer(), nullable=False),
sa.Column("trip_percent", sa.Float(), nullable=False),
sa.Column("dry_matter_locked", sa.Boolean(), nullable=False),
sa.Column("unloading_link_broken", sa.Boolean(), nullable=False),
sa.Column("target_component_id", sa.String(length=36), nullable=True),
sa.Column("ration_type", sa.String(length=10), nullable=True),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_recipe_ent_id"),
)
op.create_index("idx_zootech_recipe_enterprise", "zootech_recipe", ["enterprise_id"], unique=False)
op.create_table(
"zootech_ingredient",
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("recipe_id", sa.String(length=36), nullable=False),
sa.Column("component_id", sa.String(length=36), nullable=True),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("amount", sa.Float(), nullable=False),
sa.Column("weight_per_head", sa.Float(), nullable=True),
sa.Column("dry_matter", sa.Float(), nullable=False),
sa.Column("dry_matter_per_head", sa.Float(), nullable=True),
sa.Column("order", sa.Integer(), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_ingredient_ent_id"),
)
op.create_index("idx_zootech_ingredient_enterprise", "zootech_ingredient", ["enterprise_id"], unique=False)
op.create_index("idx_zootech_ingredient_recipe", "zootech_ingredient", ["recipe_id"], unique=False)
def _create_rls_policies() -> None:
for table in ("zootech_component", "zootech_recipe", "zootech_ingredient"):
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(
f"""
CREATE POLICY tenant_isolation_{table} ON {table}
USING (enterprise_id = current_setting('app.enterprise_id', true))
WITH CHECK (enterprise_id = current_setting('app.enterprise_id', true))
"""
)
def downgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == "postgresql":
for table in ("zootech_component", "zootech_recipe", "zootech_ingredient"):
op.execute(f"DROP POLICY IF EXISTS tenant_isolation_{table} ON {table}")
for table in (
"zootech_ingredient",
"zootech_recipe",
"zootech_component",
"report_sync_state",
"sync_reconcile_runs",
"sync_conflicts",
"sync_record_state",
"sync_cursors",
"sync_applied_events",
"sync_event_log",
"sync_outbox",
"hub_pairing_sessions",
"hub_credentials",
"user_farm_access",
"farm_hubs",
"enterprise_members",
"enterprises",
):
op.drop_table(table)
@@ -0,0 +1,180 @@
"""zootech full catalog tables
Revision ID: 20260716_0007
Revises: 20260715_0006
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0007"
down_revision: Union[str, None] = "20260715_0006"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _simple_catalog(name: str, uq: str) -> None:
op.create_table(
name,
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", "id", name=uq),
)
op.create_index(f"idx_{name}_enterprise", name, ["enterprise_id"])
def upgrade() -> None:
op.create_table(
"zootech_unloading_group",
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("recipe_id", sa.String(length=36), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_unloading_group_ent_id"),
)
op.create_table(
"zootech_feed_mixer",
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_feed_mixer_ent_id"),
)
op.create_table(
"zootech_feed_dispenser",
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_feed_dispenser_ent_id"),
)
op.create_table(
"zootech_feeding_location",
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_feeding_location_ent_id"),
)
op.create_table(
"zootech_feeding_period",
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("dispenser_id", sa.String(length=36), nullable=True),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_feeding_period_ent_id"),
)
op.create_table(
"zootech_feeding_point",
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("period_id", sa.String(length=36), nullable=True),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_feeding_point_ent_id"),
)
op.create_table(
"zootech_period_recipes",
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("period_id", sa.String(length=36), nullable=False),
sa.Column("recipe_id", sa.String(length=36), nullable=False),
sa.Column("order", sa.Integer(), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("period_id", "recipe_id"),
sa.UniqueConstraint("enterprise_id", "period_id", "recipe_id", name="uq_zootech_period_recipes_ent"),
)
op.create_table(
"zootech_trip",
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("mixer_id", sa.String(length=36), nullable=True),
sa.Column("recipe_id", sa.String(length=36), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_trip_ent_id"),
)
for tbl, uq in (
("zootech_daily_trip_skip", "uq_zootech_daily_trip_skip_ent_id"),
("zootech_daily_ingredient_skip", "uq_zootech_daily_ingredient_skip_ent_id"),
("zootech_daily_unloading_group_skip", "uq_zootech_daily_unloading_group_skip_ent_id"),
("zootech_daily_ingredient_replacement", "uq_zootech_daily_ingredient_replacement_ent_id"),
("zootech_daily_component_norm_adjustment", "uq_zootech_daily_component_norm_adjustment_ent_id"),
):
_simple_catalog(tbl, uq)
def downgrade() -> None:
for tbl in (
"zootech_daily_component_norm_adjustment",
"zootech_daily_ingredient_replacement",
"zootech_daily_unloading_group_skip",
"zootech_daily_ingredient_skip",
"zootech_daily_trip_skip",
"zootech_trip",
"zootech_period_recipes",
"zootech_feeding_point",
"zootech_feeding_period",
"zootech_feeding_location",
"zootech_feed_dispenser",
"zootech_feed_mixer",
"zootech_unloading_group",
):
op.drop_table(tbl)
@@ -0,0 +1,81 @@
"""Report tables + report outbox
Revision ID: 20260716_0008
Revises: 20260716_0007
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0008"
down_revision: Union[str, None] = "20260716_0007"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _report_table(name: str) -> None:
op.create_table(
name,
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("farm_hub_id", sa.String(length=36), nullable=True),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("trip_id", sa.String(length=36), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=True),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", "id", name=f"uq_{name}_ent_id"),
)
op.create_index(f"idx_{name}_enterprise", name, ["enterprise_id"])
op.create_index(f"idx_{name}_farm_hub", name, ["farm_hub_id"])
def upgrade() -> None:
_report_table("zootech_loading_report")
_report_table("zootech_unloading_report")
op.create_table(
"zootech_feed_alert",
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("farm_hub_id", sa.String(length=36), nullable=True),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("alert_type", sa.String(length=64), nullable=False),
sa.Column("severity", sa.String(length=16), nullable=False),
sa.Column("message", sa.Text(), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_feed_alert_ent_id"),
)
op.create_table(
"report_outbox",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("farm_hub_id", sa.String(length=36), nullable=False),
sa.Column("event_id", sa.String(length=36), nullable=False),
sa.Column("table_name", sa.String(length=64), nullable=False),
sa.Column("record_id", sa.String(length=64), nullable=False),
sa.Column("action", sa.String(length=16), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("event_id"),
)
def downgrade() -> None:
op.drop_table("report_outbox")
op.drop_table("zootech_feed_alert")
op.drop_table("zootech_unloading_report")
op.drop_table("zootech_loading_report")
@@ -0,0 +1,45 @@
"""org settings + feed quality (enterprise scoped)
Revision ID: 20260716_0009
Revises: 20260716_0008
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0009"
down_revision: Union[str, None] = "20260716_0008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"zootech_org_settings",
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", name="uq_zootech_org_settings_enterprise"),
)
op.create_table(
"zootech_feed_quality_settings",
sa.Column("enterprise_id", sa.String(length=36), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("enterprise_id", name="uq_zootech_feed_quality_settings_enterprise"),
)
def downgrade() -> None:
op.drop_table("zootech_feed_quality_settings")
op.drop_table("zootech_org_settings")
@@ -0,0 +1,35 @@
"""Widen sync record_id for composite keys (period_recipes uuid:uuid)."""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260716_0010"
down_revision = "20260716_0009"
branch_labels = None
depends_on = None
_TABLES = ("sync_outbox", "sync_event_log", "sync_record_state", "sync_conflicts")
def upgrade() -> None:
for table in _TABLES:
op.alter_column(
table,
"record_id",
existing_type=sa.String(length=64),
type_=sa.String(length=128),
existing_nullable=False,
)
def downgrade() -> None:
for table in reversed(_TABLES):
op.alter_column(
table,
"record_id",
existing_type=sa.String(length=128),
type_=sa.String(length=64),
existing_nullable=False,
)
@@ -0,0 +1,23 @@
"""Add payload_json to zootech_feed_alert
Revision ID: 20260716_0011
Revises: 20260716_0010
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0011"
down_revision: Union[str, None] = "20260716_0010"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("zootech_feed_alert", sa.Column("payload_json", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("zootech_feed_alert", "payload_json")