feat: add Letzshop vendor directory with sync and admin management

- Add LetzshopVendorCache model to store cached vendor data from Letzshop API
- Create LetzshopVendorSyncService for syncing vendor directory
- Add Celery task for background vendor sync
- Create admin page at /admin/letzshop/vendor-directory with:
  - Stats dashboard (total, claimed, unclaimed vendors)
  - Searchable/filterable vendor list
  - "Sync Now" button to trigger sync
  - Ability to create platform vendors from Letzshop cache
- Add API endpoints for vendor directory management
- Add Pydantic schemas for API responses

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-13 20:35:46 +01:00
parent 78b14a4b00
commit ccfbbcb804
13 changed files with 2571 additions and 46 deletions

View File

@@ -0,0 +1,367 @@
"""add letzshop_vendor_cache table
Revision ID: 1b398cf45e85
Revises: 09d84a46530f
Create Date: 2026-01-13 19:38:45.423378
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects import sqlite
# revision identifiers, used by Alembic.
revision: str = '1b398cf45e85'
down_revision: Union[str, None] = '09d84a46530f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('letzshop_vendor_cache',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('letzshop_id', sa.String(length=50), nullable=False),
sa.Column('slug', sa.String(length=200), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('company_name', sa.String(length=255), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('description_en', sa.Text(), nullable=True),
sa.Column('description_fr', sa.Text(), nullable=True),
sa.Column('description_de', sa.Text(), nullable=True),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('phone', sa.String(length=50), nullable=True),
sa.Column('fax', sa.String(length=50), nullable=True),
sa.Column('website', sa.String(length=500), nullable=True),
sa.Column('street', sa.String(length=255), nullable=True),
sa.Column('street_number', sa.String(length=50), nullable=True),
sa.Column('city', sa.String(length=100), nullable=True),
sa.Column('zipcode', sa.String(length=20), nullable=True),
sa.Column('country_iso', sa.String(length=5), nullable=True),
sa.Column('latitude', sa.String(length=20), nullable=True),
sa.Column('longitude', sa.String(length=20), nullable=True),
sa.Column('categories', sqlite.JSON(), nullable=True),
sa.Column('background_image_url', sa.String(length=500), nullable=True),
sa.Column('social_media_links', sqlite.JSON(), nullable=True),
sa.Column('opening_hours_en', sa.Text(), nullable=True),
sa.Column('opening_hours_fr', sa.Text(), nullable=True),
sa.Column('opening_hours_de', sa.Text(), nullable=True),
sa.Column('representative_name', sa.String(length=255), nullable=True),
sa.Column('representative_title', sa.String(length=100), nullable=True),
sa.Column('claimed_by_vendor_id', sa.Integer(), nullable=True),
sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('raw_data', sqlite.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['claimed_by_vendor_id'], ['vendors.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_vendor_cache_active', 'letzshop_vendor_cache', ['is_active'], unique=False)
op.create_index('idx_vendor_cache_city', 'letzshop_vendor_cache', ['city'], unique=False)
op.create_index('idx_vendor_cache_claimed', 'letzshop_vendor_cache', ['claimed_by_vendor_id'], unique=False)
op.create_index(op.f('ix_letzshop_vendor_cache_claimed_by_vendor_id'), 'letzshop_vendor_cache', ['claimed_by_vendor_id'], unique=False)
op.create_index(op.f('ix_letzshop_vendor_cache_id'), 'letzshop_vendor_cache', ['id'], unique=False)
op.create_index(op.f('ix_letzshop_vendor_cache_letzshop_id'), 'letzshop_vendor_cache', ['letzshop_id'], unique=True)
op.create_index(op.f('ix_letzshop_vendor_cache_slug'), 'letzshop_vendor_cache', ['slug'], unique=True)
op.drop_constraint('architecture_rules_rule_id_key', 'architecture_rules', type_='unique')
op.alter_column('capacity_snapshots', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('capacity_snapshots', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.create_index(op.f('ix_features_id'), 'features', ['id'], unique=False)
op.create_index(op.f('ix_features_minimum_tier_id'), 'features', ['minimum_tier_id'], unique=False)
op.create_index('idx_inv_tx_order', 'inventory_transactions', ['order_id'], unique=False)
op.alter_column('invoices', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('invoices', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('letzshop_fulfillment_queue', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('letzshop_fulfillment_queue', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('letzshop_sync_logs', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('letzshop_sync_logs', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('media_files', 'created_at',
existing_type=postgresql.TIMESTAMP(),
nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('media_files', 'updated_at',
existing_type=postgresql.TIMESTAMP(),
nullable=False)
op.alter_column('order_item_exceptions', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('order_item_exceptions', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('order_items', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('order_items', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('orders', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('orders', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.drop_index('ix_password_reset_tokens_customer_id', table_name='password_reset_tokens')
op.create_index(op.f('ix_password_reset_tokens_id'), 'password_reset_tokens', ['id'], unique=False)
op.alter_column('product_media', 'created_at',
existing_type=postgresql.TIMESTAMP(),
nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('product_media', 'updated_at',
existing_type=postgresql.TIMESTAMP(),
nullable=False)
op.alter_column('products', 'is_digital',
existing_type=sa.BOOLEAN(),
nullable=True,
existing_server_default=sa.text('false'))
op.alter_column('products', 'product_type',
existing_type=sa.VARCHAR(length=20),
nullable=True,
existing_server_default=sa.text("'physical'::character varying"))
op.drop_index('idx_product_is_digital', table_name='products')
op.create_index(op.f('ix_products_is_digital'), 'products', ['is_digital'], unique=False)
op.drop_constraint('uq_vendor_email_settings_vendor_id', 'vendor_email_settings', type_='unique')
op.drop_index('ix_vendor_email_templates_lookup', table_name='vendor_email_templates')
op.create_index(op.f('ix_vendor_email_templates_id'), 'vendor_email_templates', ['id'], unique=False)
op.alter_column('vendor_invoice_settings', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('vendor_invoice_settings', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.drop_constraint('vendor_invoice_settings_vendor_id_key', 'vendor_invoice_settings', type_='unique')
op.alter_column('vendor_letzshop_credentials', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('vendor_letzshop_credentials', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.drop_constraint('vendor_letzshop_credentials_vendor_id_key', 'vendor_letzshop_credentials', type_='unique')
op.alter_column('vendor_subscriptions', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('vendor_subscriptions', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.drop_constraint('vendor_subscriptions_vendor_id_key', 'vendor_subscriptions', type_='unique')
op.drop_constraint('fk_vendor_subscriptions_tier_id', 'vendor_subscriptions', type_='foreignkey')
op.create_foreign_key(None, 'vendor_subscriptions', 'subscription_tiers', ['tier_id'], ['id'])
op.alter_column('vendors', 'storefront_locale',
existing_type=sa.VARCHAR(length=10),
comment=None,
existing_comment='Currency/number formatting locale (NULL = inherit from platform)',
existing_nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('vendors', 'storefront_locale',
existing_type=sa.VARCHAR(length=10),
comment='Currency/number formatting locale (NULL = inherit from platform)',
existing_nullable=True)
op.drop_constraint(None, 'vendor_subscriptions', type_='foreignkey')
op.create_foreign_key('fk_vendor_subscriptions_tier_id', 'vendor_subscriptions', 'subscription_tiers', ['tier_id'], ['id'], ondelete='SET NULL')
op.create_unique_constraint('vendor_subscriptions_vendor_id_key', 'vendor_subscriptions', ['vendor_id'])
op.alter_column('vendor_subscriptions', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('vendor_subscriptions', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.create_unique_constraint('vendor_letzshop_credentials_vendor_id_key', 'vendor_letzshop_credentials', ['vendor_id'])
op.alter_column('vendor_letzshop_credentials', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('vendor_letzshop_credentials', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.create_unique_constraint('vendor_invoice_settings_vendor_id_key', 'vendor_invoice_settings', ['vendor_id'])
op.alter_column('vendor_invoice_settings', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('vendor_invoice_settings', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.drop_index(op.f('ix_vendor_email_templates_id'), table_name='vendor_email_templates')
op.create_index('ix_vendor_email_templates_lookup', 'vendor_email_templates', ['vendor_id', 'template_code', 'language'], unique=False)
op.create_unique_constraint('uq_vendor_email_settings_vendor_id', 'vendor_email_settings', ['vendor_id'])
op.drop_index(op.f('ix_products_is_digital'), table_name='products')
op.create_index('idx_product_is_digital', 'products', ['is_digital'], unique=False)
op.alter_column('products', 'product_type',
existing_type=sa.VARCHAR(length=20),
nullable=False,
existing_server_default=sa.text("'physical'::character varying"))
op.alter_column('products', 'is_digital',
existing_type=sa.BOOLEAN(),
nullable=False,
existing_server_default=sa.text('false'))
op.alter_column('product_media', 'updated_at',
existing_type=postgresql.TIMESTAMP(),
nullable=True)
op.alter_column('product_media', 'created_at',
existing_type=postgresql.TIMESTAMP(),
nullable=True,
existing_server_default=sa.text('now()'))
op.drop_index(op.f('ix_password_reset_tokens_id'), table_name='password_reset_tokens')
op.create_index('ix_password_reset_tokens_customer_id', 'password_reset_tokens', ['customer_id'], unique=False)
op.alter_column('orders', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('orders', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('order_items', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('order_items', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('order_item_exceptions', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('order_item_exceptions', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('media_files', 'updated_at',
existing_type=postgresql.TIMESTAMP(),
nullable=True)
op.alter_column('media_files', 'created_at',
existing_type=postgresql.TIMESTAMP(),
nullable=True,
existing_server_default=sa.text('now()'))
op.alter_column('letzshop_sync_logs', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('letzshop_sync_logs', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('letzshop_fulfillment_queue', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('letzshop_fulfillment_queue', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('invoices', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('invoices', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.drop_index('idx_inv_tx_order', table_name='inventory_transactions')
op.drop_index(op.f('ix_features_minimum_tier_id'), table_name='features')
op.drop_index(op.f('ix_features_id'), table_name='features')
op.alter_column('capacity_snapshots', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('capacity_snapshots', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.create_unique_constraint('architecture_rules_rule_id_key', 'architecture_rules', ['rule_id'])
op.drop_index(op.f('ix_letzshop_vendor_cache_slug'), table_name='letzshop_vendor_cache')
op.drop_index(op.f('ix_letzshop_vendor_cache_letzshop_id'), table_name='letzshop_vendor_cache')
op.drop_index(op.f('ix_letzshop_vendor_cache_id'), table_name='letzshop_vendor_cache')
op.drop_index(op.f('ix_letzshop_vendor_cache_claimed_by_vendor_id'), table_name='letzshop_vendor_cache')
op.drop_index('idx_vendor_cache_claimed', table_name='letzshop_vendor_cache')
op.drop_index('idx_vendor_cache_city', table_name='letzshop_vendor_cache')
op.drop_index('idx_vendor_cache_active', table_name='letzshop_vendor_cache')
op.drop_table('letzshop_vendor_cache')
# ### end Alembic commands ###