230 lines
13 KiB
Python
230 lines
13 KiB
Python
|
|
"""Discovery, taxonomy, search runs, and project candidates.
|
||
|
|
|
||
|
|
Revision ID: 0003_discovery_schema
|
||
|
|
Revises: 0002_domain_schema
|
||
|
|
Create Date: 2026-10-04
|
||
|
|
"""
|
||
|
|
|
||
|
|
from collections.abc import Sequence
|
||
|
|
|
||
|
|
import sqlalchemy as sa
|
||
|
|
from alembic import op
|
||
|
|
from sqlalchemy.dialects import postgresql
|
||
|
|
|
||
|
|
revision: str = "0003_discovery_schema"
|
||
|
|
down_revision: str | None = "0002_domain_schema"
|
||
|
|
branch_labels: str | Sequence[str] | None = None
|
||
|
|
depends_on: str | Sequence[str] | None = None
|
||
|
|
|
||
|
|
|
||
|
|
def upgrade() -> None:
|
||
|
|
op.create_table(
|
||
|
|
"buying_projects",
|
||
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
|
|
sa.Column("name", sa.String(256), nullable=False),
|
||
|
|
sa.Column("status", sa.String(32), nullable=False),
|
||
|
|
sa.Column("workspace_id", sa.String(64)),
|
||
|
|
sa.Column("profile_id", sa.String(128), nullable=False),
|
||
|
|
sa.Column("profile_version", sa.Integer(), nullable=False),
|
||
|
|
sa.Column("shortlist_size", sa.Integer(), nullable=False, server_default="5"),
|
||
|
|
sa.Column("schedule_interval_minutes", sa.Integer(), nullable=False, server_default="180"),
|
||
|
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||
|
|
sa.Column("archived_at", sa.DateTime(timezone=True)),
|
||
|
|
sa.ForeignKeyConstraint(
|
||
|
|
["profile_id", "profile_version"],
|
||
|
|
["product_profiles.id", "product_profiles.version"],
|
||
|
|
name="fk_buying_projects_profile",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint("status IN ('ACTIVE', 'PURCHASED', 'ARCHIVED')", name="ck_buying_projects_status"),
|
||
|
|
sa.CheckConstraint("shortlist_size > 0", name="ck_buying_projects_shortlist"),
|
||
|
|
sa.CheckConstraint("schedule_interval_minutes > 0", name="ck_buying_projects_interval"),
|
||
|
|
)
|
||
|
|
op.create_table(
|
||
|
|
"buying_project_criteria",
|
||
|
|
sa.Column("project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("buying_projects.id"), primary_key=True),
|
||
|
|
sa.Column("version", sa.Integer(), primary_key=True),
|
||
|
|
sa.Column("definition", postgresql.JSONB, nullable=False),
|
||
|
|
sa.Column("definition_hash", sa.String(64), nullable=False),
|
||
|
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||
|
|
sa.CheckConstraint("version > 0", name="ck_project_criteria_version"),
|
||
|
|
)
|
||
|
|
op.create_table(
|
||
|
|
"source_categories",
|
||
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
|
|
sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False),
|
||
|
|
sa.Column("external_category_key", sa.String(64), nullable=False),
|
||
|
|
sa.Column("name", sa.String(256), nullable=False),
|
||
|
|
sa.Column("source_url", sa.Text(), nullable=False),
|
||
|
|
sa.Column("status", sa.String(32), nullable=False),
|
||
|
|
sa.Column("last_verified_at", sa.DateTime(timezone=True)),
|
||
|
|
sa.UniqueConstraint("source_id", "external_category_key", name="uq_source_category"),
|
||
|
|
)
|
||
|
|
op.create_table(
|
||
|
|
"source_filter_definitions",
|
||
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
|
|
sa.Column("source_category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_categories.id"), nullable=False),
|
||
|
|
sa.Column("external_filter_key", sa.String(64), nullable=False),
|
||
|
|
sa.Column("label", sa.String(256), nullable=False),
|
||
|
|
sa.Column("filter_type", sa.String(32), nullable=False),
|
||
|
|
sa.Column("canonical_unit", sa.String(32)),
|
||
|
|
sa.Column("status", sa.String(32), nullable=False),
|
||
|
|
sa.UniqueConstraint("source_category_id", "external_filter_key", name="uq_source_filter"),
|
||
|
|
)
|
||
|
|
op.create_table(
|
||
|
|
"source_filter_options",
|
||
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
|
|
sa.Column("filter_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_filter_definitions.id"), nullable=False),
|
||
|
|
sa.Column("source_value", sa.String(256), nullable=False),
|
||
|
|
sa.Column("normalized_value", postgresql.JSONB),
|
||
|
|
sa.Column("operator", sa.String(16)),
|
||
|
|
sa.Column("unit", sa.String(32)),
|
||
|
|
sa.UniqueConstraint("filter_id", "source_value", name="uq_filter_option"),
|
||
|
|
)
|
||
|
|
op.create_table(
|
||
|
|
"source_filter_ranges",
|
||
|
|
sa.Column("filter_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_filter_definitions.id"), primary_key=True),
|
||
|
|
sa.Column("minimum", sa.Numeric(12, 2)),
|
||
|
|
sa.Column("maximum", sa.Numeric(12, 2)),
|
||
|
|
sa.Column("step", sa.Numeric(12, 2)),
|
||
|
|
sa.Column("unit", sa.String(32)),
|
||
|
|
)
|
||
|
|
op.create_table(
|
||
|
|
"criterion_definitions",
|
||
|
|
sa.Column("key", sa.String(128), primary_key=True),
|
||
|
|
sa.Column("value_type", sa.String(32), nullable=False),
|
||
|
|
sa.Column("unit", sa.String(32)),
|
||
|
|
sa.Column("semantic", sa.String(128)),
|
||
|
|
sa.Column("description", sa.Text()),
|
||
|
|
)
|
||
|
|
op.create_table(
|
||
|
|
"criterion_mappings",
|
||
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
|
|
sa.Column("criterion_key", sa.String(128), sa.ForeignKey("criterion_definitions.key"), nullable=False),
|
||
|
|
sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False),
|
||
|
|
sa.Column("source_category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_categories.id"), nullable=False),
|
||
|
|
sa.Column("external_filter_key", sa.String(64), nullable=False),
|
||
|
|
sa.Column("source_value", sa.String(256)),
|
||
|
|
sa.Column("encoding", postgresql.JSONB, nullable=False),
|
||
|
|
sa.Column("status", sa.String(32), nullable=False),
|
||
|
|
sa.Column("definition_hash", sa.String(64), nullable=False),
|
||
|
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||
|
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||
|
|
)
|
||
|
|
op.create_table(
|
||
|
|
"discovery_searches",
|
||
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
|
|
sa.Column("buying_project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("buying_projects.id"), nullable=False),
|
||
|
|
sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False),
|
||
|
|
sa.Column("source_category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_categories.id"), nullable=False),
|
||
|
|
sa.Column("criteria_version", sa.Integer(), nullable=False),
|
||
|
|
sa.Column("mapping_version", sa.String(64), nullable=False),
|
||
|
|
sa.Column("generated_url", sa.Text(), nullable=False),
|
||
|
|
sa.Column("status", sa.String(32), nullable=False),
|
||
|
|
sa.Column("broad_result_threshold", sa.Integer(), nullable=False, server_default="300"),
|
||
|
|
sa.Column("next_run_at", sa.DateTime(timezone=True)),
|
||
|
|
sa.Column("last_successful_run_at", sa.DateTime(timezone=True)),
|
||
|
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||
|
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||
|
|
)
|
||
|
|
op.create_table(
|
||
|
|
"search_runs",
|
||
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
|
|
sa.Column("discovery_search_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("discovery_searches.id"), nullable=False),
|
||
|
|
sa.Column("criteria_version", sa.Integer(), nullable=False),
|
||
|
|
sa.Column("mapping_version", sa.String(64), nullable=False),
|
||
|
|
sa.Column("generated_url", sa.Text(), nullable=False),
|
||
|
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||
|
|
sa.Column("finished_at", sa.DateTime(timezone=True)),
|
||
|
|
sa.Column("status", sa.String(32), nullable=False),
|
||
|
|
sa.Column("result_count", sa.Integer(), nullable=False, server_default="0"),
|
||
|
|
sa.Column("page_count", sa.Integer(), nullable=False, server_default="0"),
|
||
|
|
sa.Column("http_status_summary", postgresql.JSONB, nullable=False),
|
||
|
|
sa.Column("error_code", sa.String(64)),
|
||
|
|
)
|
||
|
|
op.create_table(
|
||
|
|
"source_product_references",
|
||
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
|
|
sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False),
|
||
|
|
sa.Column("source_category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_categories.id"), nullable=False),
|
||
|
|
sa.Column("source_external_id", sa.String(256)),
|
||
|
|
sa.Column("source_url", sa.Text()),
|
||
|
|
sa.Column("source_title", sa.String(512), nullable=False),
|
||
|
|
sa.Column("manufacturer", sa.String(256)),
|
||
|
|
sa.Column("model", sa.String(256)),
|
||
|
|
sa.Column("manufacturer_sku", sa.String(128)),
|
||
|
|
sa.Column("gtin_ean", sa.String(32)),
|
||
|
|
sa.Column("normalized_spec_fingerprint", sa.Text()),
|
||
|
|
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False),
|
||
|
|
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False),
|
||
|
|
sa.Column("lifecycle_status", sa.String(32), nullable=False),
|
||
|
|
sa.Column("match_status", sa.String(32), nullable=False),
|
||
|
|
sa.Column("consecutive_missing_success_runs", sa.Integer(), nullable=False, server_default="0"),
|
||
|
|
sa.Column("product_variant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_variants.id")),
|
||
|
|
)
|
||
|
|
op.create_table(
|
||
|
|
"product_observations",
|
||
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
|
|
sa.Column("source_product_reference_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_product_references.id"), nullable=False),
|
||
|
|
sa.Column("search_run_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("search_runs.id"), nullable=False),
|
||
|
|
sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False),
|
||
|
|
sa.Column("raw_title", sa.String(512), nullable=False),
|
||
|
|
sa.Column("best_price", sa.Numeric(12, 2)),
|
||
|
|
sa.Column("currency", sa.String(3)),
|
||
|
|
sa.Column("raw_attributes", postgresql.JSONB, nullable=False),
|
||
|
|
sa.Column("normalized_attributes", postgresql.JSONB, nullable=False),
|
||
|
|
sa.Column("availability", sa.String(32)),
|
||
|
|
sa.Column("seller_count", sa.Integer()),
|
||
|
|
)
|
||
|
|
op.create_table(
|
||
|
|
"search_run_items",
|
||
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
|
|
sa.Column("search_run_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("search_runs.id"), nullable=False),
|
||
|
|
sa.Column("source_product_reference_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_product_references.id"), nullable=False),
|
||
|
|
sa.Column("observation_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_observations.id")),
|
||
|
|
sa.Column("diff_status", sa.String(32), nullable=False),
|
||
|
|
sa.UniqueConstraint("search_run_id", "source_product_reference_id", name="uq_run_item"),
|
||
|
|
)
|
||
|
|
op.create_table(
|
||
|
|
"project_candidates",
|
||
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
|
|
sa.Column("buying_project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("buying_projects.id"), nullable=False),
|
||
|
|
sa.Column("source_product_reference_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("source_product_references.id"), nullable=False),
|
||
|
|
sa.Column("product_variant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_variants.id")),
|
||
|
|
sa.Column("status", sa.String(32), nullable=False),
|
||
|
|
sa.Column("match_status", sa.String(32), nullable=False),
|
||
|
|
sa.Column("product_eligibility", sa.String(16)),
|
||
|
|
sa.Column("product_score", sa.Numeric(6, 2)),
|
||
|
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||
|
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||
|
|
sa.UniqueConstraint("buying_project_id", "source_product_reference_id", name="uq_project_candidate"),
|
||
|
|
)
|
||
|
|
op.create_table(
|
||
|
|
"candidate_status_history",
|
||
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
|
|
sa.Column("candidate_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("project_candidates.id"), nullable=False),
|
||
|
|
sa.Column("from_status", sa.String(32)),
|
||
|
|
sa.Column("to_status", sa.String(32), nullable=False),
|
||
|
|
sa.Column("reason", sa.String(128)),
|
||
|
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||
|
|
)
|
||
|
|
op.create_index("ix_discovery_searches_next_run", "discovery_searches", ["next_run_at"])
|
||
|
|
op.create_index("ix_search_runs_search", "search_runs", ["discovery_search_id", "started_at"])
|
||
|
|
|
||
|
|
|
||
|
|
def downgrade() -> None:
|
||
|
|
op.drop_table("candidate_status_history")
|
||
|
|
op.drop_table("project_candidates")
|
||
|
|
op.drop_table("search_run_items")
|
||
|
|
op.drop_table("product_observations")
|
||
|
|
op.drop_table("source_product_references")
|
||
|
|
op.drop_table("search_runs")
|
||
|
|
op.drop_table("discovery_searches")
|
||
|
|
op.drop_table("criterion_mappings")
|
||
|
|
op.drop_table("criterion_definitions")
|
||
|
|
op.drop_table("source_filter_ranges")
|
||
|
|
op.drop_table("source_filter_options")
|
||
|
|
op.drop_table("source_filter_definitions")
|
||
|
|
op.drop_table("source_categories")
|
||
|
|
op.drop_table("buying_project_criteria")
|
||
|
|
op.drop_table("buying_projects")
|