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:
367
alembic/versions/1b398cf45e85_add_letzshop_vendor_cache_table.py
Normal file
367
alembic/versions/1b398cf45e85_add_letzshop_vendor_cache_table.py
Normal 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 ###
|
||||||
@@ -27,6 +27,7 @@ from app.services.letzshop import (
|
|||||||
LetzshopClientError,
|
LetzshopClientError,
|
||||||
LetzshopCredentialsService,
|
LetzshopCredentialsService,
|
||||||
LetzshopOrderService,
|
LetzshopOrderService,
|
||||||
|
LetzshopVendorSyncService,
|
||||||
OrderNotFoundError,
|
OrderNotFoundError,
|
||||||
VendorNotFoundError,
|
VendorNotFoundError,
|
||||||
)
|
)
|
||||||
@@ -34,8 +35,13 @@ from app.tasks.letzshop_tasks import process_historical_import
|
|||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
from models.schema.letzshop import (
|
from models.schema.letzshop import (
|
||||||
FulfillmentOperationResponse,
|
FulfillmentOperationResponse,
|
||||||
|
LetzshopCachedVendorDetail,
|
||||||
|
LetzshopCachedVendorDetailResponse,
|
||||||
|
LetzshopCachedVendorItem,
|
||||||
|
LetzshopCachedVendorListResponse,
|
||||||
LetzshopConnectionTestRequest,
|
LetzshopConnectionTestRequest,
|
||||||
LetzshopConnectionTestResponse,
|
LetzshopConnectionTestResponse,
|
||||||
|
LetzshopCreateVendorFromCacheResponse,
|
||||||
LetzshopCredentialsCreate,
|
LetzshopCredentialsCreate,
|
||||||
LetzshopCredentialsResponse,
|
LetzshopCredentialsResponse,
|
||||||
LetzshopCredentialsUpdate,
|
LetzshopCredentialsUpdate,
|
||||||
@@ -51,6 +57,9 @@ from models.schema.letzshop import (
|
|||||||
LetzshopSuccessResponse,
|
LetzshopSuccessResponse,
|
||||||
LetzshopSyncTriggerRequest,
|
LetzshopSyncTriggerRequest,
|
||||||
LetzshopSyncTriggerResponse,
|
LetzshopSyncTriggerResponse,
|
||||||
|
LetzshopVendorDirectoryStats,
|
||||||
|
LetzshopVendorDirectoryStatsResponse,
|
||||||
|
LetzshopVendorDirectorySyncResponse,
|
||||||
LetzshopVendorListResponse,
|
LetzshopVendorListResponse,
|
||||||
LetzshopVendorOverview,
|
LetzshopVendorOverview,
|
||||||
)
|
)
|
||||||
@@ -1272,3 +1281,239 @@ def sync_tracking_for_vendor(
|
|||||||
message=f"Tracking sync failed: {e}",
|
message=f"Tracking sync failed: {e}",
|
||||||
errors=[str(e)],
|
errors=[str(e)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Vendor Directory (Letzshop Marketplace Vendors)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def get_vendor_sync_service(db: Session) -> LetzshopVendorSyncService:
|
||||||
|
"""Get vendor sync service instance."""
|
||||||
|
return LetzshopVendorSyncService(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vendor-directory/sync")
|
||||||
|
def trigger_vendor_directory_sync(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Trigger a sync of the Letzshop vendor directory.
|
||||||
|
|
||||||
|
Fetches all vendors from Letzshop's public GraphQL API and updates
|
||||||
|
the local cache. This is typically run daily via Celery beat, but
|
||||||
|
can be triggered manually here.
|
||||||
|
"""
|
||||||
|
from app.tasks.celery_tasks.letzshop import sync_vendor_directory
|
||||||
|
|
||||||
|
# Try to dispatch via Celery first
|
||||||
|
try:
|
||||||
|
task = sync_vendor_directory.delay()
|
||||||
|
logger.info(
|
||||||
|
f"Admin {current_admin.email} triggered vendor directory sync (task={task.id})"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Vendor directory sync started",
|
||||||
|
"task_id": task.id,
|
||||||
|
"mode": "celery",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
# Fall back to background tasks
|
||||||
|
logger.warning(f"Celery dispatch failed, using background tasks: {e}")
|
||||||
|
|
||||||
|
def run_sync():
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
|
||||||
|
sync_db = SessionLocal()
|
||||||
|
try:
|
||||||
|
sync_service = LetzshopVendorSyncService(sync_db)
|
||||||
|
sync_service.sync_all_vendors()
|
||||||
|
finally:
|
||||||
|
sync_db.close()
|
||||||
|
|
||||||
|
background_tasks.add_task(run_sync)
|
||||||
|
logger.info(
|
||||||
|
f"Admin {current_admin.email} triggered vendor directory sync (background task)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Vendor directory sync started",
|
||||||
|
"mode": "background_task",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/vendor-directory/stats",
|
||||||
|
response_model=LetzshopVendorDirectoryStatsResponse,
|
||||||
|
)
|
||||||
|
def get_vendor_directory_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
|
) -> LetzshopVendorDirectoryStatsResponse:
|
||||||
|
"""
|
||||||
|
Get statistics about the Letzshop vendor directory cache.
|
||||||
|
|
||||||
|
Returns total, active, claimed, and unclaimed vendor counts.
|
||||||
|
"""
|
||||||
|
sync_service = get_vendor_sync_service(db)
|
||||||
|
stats_data = sync_service.get_sync_stats()
|
||||||
|
return LetzshopVendorDirectoryStatsResponse(
|
||||||
|
stats=LetzshopVendorDirectoryStats(**stats_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/vendor-directory/vendors",
|
||||||
|
response_model=LetzshopCachedVendorListResponse,
|
||||||
|
)
|
||||||
|
def list_cached_vendors(
|
||||||
|
search: str | None = Query(None, description="Search by name"),
|
||||||
|
city: str | None = Query(None, description="Filter by city"),
|
||||||
|
category: str | None = Query(None, description="Filter by category"),
|
||||||
|
only_unclaimed: bool = Query(False, description="Only show unclaimed vendors"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
|
) -> LetzshopCachedVendorListResponse:
|
||||||
|
"""
|
||||||
|
List cached Letzshop vendors with search and filtering.
|
||||||
|
|
||||||
|
This returns vendors from the local cache, not directly from Letzshop.
|
||||||
|
"""
|
||||||
|
sync_service = get_vendor_sync_service(db)
|
||||||
|
vendors, total = sync_service.search_cached_vendors(
|
||||||
|
search=search,
|
||||||
|
city=city,
|
||||||
|
category=category,
|
||||||
|
only_unclaimed=only_unclaimed,
|
||||||
|
page=page,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
return LetzshopCachedVendorListResponse(
|
||||||
|
vendors=[
|
||||||
|
LetzshopCachedVendorItem(
|
||||||
|
id=v.id,
|
||||||
|
letzshop_id=v.letzshop_id,
|
||||||
|
slug=v.slug,
|
||||||
|
name=v.name,
|
||||||
|
company_name=v.company_name,
|
||||||
|
email=v.email,
|
||||||
|
phone=v.phone,
|
||||||
|
website=v.website,
|
||||||
|
city=v.city,
|
||||||
|
categories=v.categories or [],
|
||||||
|
is_active=v.is_active,
|
||||||
|
is_claimed=v.is_claimed,
|
||||||
|
claimed_by_vendor_id=v.claimed_by_vendor_id,
|
||||||
|
last_synced_at=v.last_synced_at,
|
||||||
|
letzshop_url=v.letzshop_url,
|
||||||
|
)
|
||||||
|
for v in vendors
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
limit=limit,
|
||||||
|
has_more=(page * limit) < total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/vendor-directory/vendors/{slug}",
|
||||||
|
response_model=LetzshopCachedVendorDetailResponse,
|
||||||
|
)
|
||||||
|
def get_cached_vendor_detail(
|
||||||
|
slug: str = Path(..., description="Letzshop vendor slug"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
|
) -> LetzshopCachedVendorDetailResponse:
|
||||||
|
"""
|
||||||
|
Get detailed information about a cached Letzshop vendor.
|
||||||
|
"""
|
||||||
|
sync_service = get_vendor_sync_service(db)
|
||||||
|
vendor = sync_service.get_cached_vendor(slug)
|
||||||
|
|
||||||
|
if not vendor:
|
||||||
|
raise ResourceNotFoundException("LetzshopVendor", slug)
|
||||||
|
|
||||||
|
return LetzshopCachedVendorDetailResponse(
|
||||||
|
vendor=LetzshopCachedVendorDetail(
|
||||||
|
id=vendor.id,
|
||||||
|
letzshop_id=vendor.letzshop_id,
|
||||||
|
slug=vendor.slug,
|
||||||
|
name=vendor.name,
|
||||||
|
company_name=vendor.company_name,
|
||||||
|
description_en=vendor.description_en,
|
||||||
|
description_fr=vendor.description_fr,
|
||||||
|
description_de=vendor.description_de,
|
||||||
|
email=vendor.email,
|
||||||
|
phone=vendor.phone,
|
||||||
|
fax=vendor.fax,
|
||||||
|
website=vendor.website,
|
||||||
|
street=vendor.street,
|
||||||
|
street_number=vendor.street_number,
|
||||||
|
city=vendor.city,
|
||||||
|
zipcode=vendor.zipcode,
|
||||||
|
country_iso=vendor.country_iso,
|
||||||
|
latitude=vendor.latitude,
|
||||||
|
longitude=vendor.longitude,
|
||||||
|
categories=vendor.categories or [],
|
||||||
|
background_image_url=vendor.background_image_url,
|
||||||
|
social_media_links=vendor.social_media_links or [],
|
||||||
|
opening_hours_en=vendor.opening_hours_en,
|
||||||
|
opening_hours_fr=vendor.opening_hours_fr,
|
||||||
|
opening_hours_de=vendor.opening_hours_de,
|
||||||
|
representative_name=vendor.representative_name,
|
||||||
|
representative_title=vendor.representative_title,
|
||||||
|
is_active=vendor.is_active,
|
||||||
|
is_claimed=vendor.is_claimed,
|
||||||
|
claimed_by_vendor_id=vendor.claimed_by_vendor_id,
|
||||||
|
claimed_at=vendor.claimed_at,
|
||||||
|
last_synced_at=vendor.last_synced_at,
|
||||||
|
letzshop_url=vendor.letzshop_url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/vendor-directory/vendors/{slug}/create-vendor",
|
||||||
|
response_model=LetzshopCreateVendorFromCacheResponse,
|
||||||
|
)
|
||||||
|
def create_vendor_from_letzshop(
|
||||||
|
slug: str = Path(..., description="Letzshop vendor slug"),
|
||||||
|
company_id: int = Query(..., description="Company ID to create vendor under"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
|
) -> LetzshopCreateVendorFromCacheResponse:
|
||||||
|
"""
|
||||||
|
Create a platform vendor from a cached Letzshop vendor.
|
||||||
|
|
||||||
|
This creates a new vendor on the platform using information from the
|
||||||
|
Letzshop vendor cache. The vendor will be linked to the specified company.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slug: The Letzshop vendor slug
|
||||||
|
company_id: The company ID to create the vendor under
|
||||||
|
"""
|
||||||
|
sync_service = get_vendor_sync_service(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
vendor_info = sync_service.create_vendor_from_cache(slug, company_id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Admin {current_admin.email} created vendor {vendor_info['vendor_code']} "
|
||||||
|
f"from Letzshop vendor {slug}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return LetzshopCreateVendorFromCacheResponse(
|
||||||
|
message=f"Vendor '{vendor_info['name']}' created successfully",
|
||||||
|
vendor=vendor_info,
|
||||||
|
letzshop_vendor_slug=slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValidationException(str(e))
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ import re
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from app.exceptions import ResourceNotFoundException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.services.letzshop.vendor_sync_service import LetzshopVendorSyncService
|
||||||
from app.services.platform_signup_service import platform_signup_service
|
from app.services.platform_signup_service import platform_signup_service
|
||||||
|
from models.database.letzshop import LetzshopVendorCache
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -34,13 +38,40 @@ class LetzshopVendorInfo(BaseModel):
|
|||||||
letzshop_id: str | None = None
|
letzshop_id: str | None = None
|
||||||
slug: str
|
slug: str
|
||||||
name: str
|
name: str
|
||||||
|
company_name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
logo_url: str | None = None
|
email: str | None = None
|
||||||
category: str | None = None
|
phone: str | None = None
|
||||||
|
website: str | None = None
|
||||||
|
address: str | None = None
|
||||||
city: str | None = None
|
city: str | None = None
|
||||||
|
categories: list[str] = []
|
||||||
|
background_image_url: str | None = None
|
||||||
|
social_media_links: list[str] = []
|
||||||
letzshop_url: str
|
letzshop_url: str
|
||||||
is_claimed: bool = False
|
is_claimed: bool = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_cache(cls, cache: LetzshopVendorCache, lang: str = "en") -> "LetzshopVendorInfo":
|
||||||
|
"""Create from cache entry."""
|
||||||
|
return cls(
|
||||||
|
letzshop_id=cache.letzshop_id,
|
||||||
|
slug=cache.slug,
|
||||||
|
name=cache.name,
|
||||||
|
company_name=cache.company_name,
|
||||||
|
description=cache.get_description(lang),
|
||||||
|
email=cache.email,
|
||||||
|
phone=cache.phone,
|
||||||
|
website=cache.website,
|
||||||
|
address=cache.get_full_address(),
|
||||||
|
city=cache.city,
|
||||||
|
categories=cache.categories or [],
|
||||||
|
background_image_url=cache.background_image_url,
|
||||||
|
social_media_links=cache.social_media_links or [],
|
||||||
|
letzshop_url=cache.letzshop_url,
|
||||||
|
is_claimed=cache.is_claimed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LetzshopVendorListResponse(BaseModel):
|
class LetzshopVendorListResponse(BaseModel):
|
||||||
"""Paginated list of Letzshop vendors."""
|
"""Paginated list of Letzshop vendors."""
|
||||||
@@ -113,35 +144,42 @@ async def list_letzshop_vendors(
|
|||||||
search: Annotated[str | None, Query(description="Search by name")] = None,
|
search: Annotated[str | None, Query(description="Search by name")] = None,
|
||||||
category: Annotated[str | None, Query(description="Filter by category")] = None,
|
category: Annotated[str | None, Query(description="Filter by category")] = None,
|
||||||
city: Annotated[str | None, Query(description="Filter by city")] = None,
|
city: Annotated[str | None, Query(description="Filter by city")] = None,
|
||||||
|
only_unclaimed: Annotated[bool, Query(description="Only show unclaimed vendors")] = False,
|
||||||
|
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
|
||||||
page: Annotated[int, Query(ge=1)] = 1,
|
page: Annotated[int, Query(ge=1)] = 1,
|
||||||
limit: Annotated[int, Query(ge=1, le=50)] = 20,
|
limit: Annotated[int, Query(ge=1, le=50)] = 20,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
) -> LetzshopVendorListResponse:
|
) -> LetzshopVendorListResponse:
|
||||||
"""
|
"""
|
||||||
List Letzshop vendors (placeholder - will fetch from cache/API).
|
List Letzshop vendors from cached directory.
|
||||||
|
|
||||||
In production, this would fetch from a cached vendor list
|
The cache is periodically synced from Letzshop's public GraphQL API.
|
||||||
that is periodically synced from Letzshop's public directory.
|
Run the sync task manually or wait for scheduled sync if cache is empty.
|
||||||
"""
|
"""
|
||||||
# TODO: Implement actual Letzshop vendor listing
|
sync_service = LetzshopVendorSyncService(db)
|
||||||
# For now, return placeholder data to allow UI development
|
|
||||||
|
|
||||||
# This is placeholder data - in production, we would:
|
vendors, total = sync_service.search_cached_vendors(
|
||||||
# 1. Query our cached letzshop_vendor_cache table
|
search=search,
|
||||||
# 2. Or fetch from Letzshop's public API if available
|
city=city,
|
||||||
|
category=category,
|
||||||
# Return empty list for now - the actual data will come from Phase 4
|
only_unclaimed=only_unclaimed,
|
||||||
return LetzshopVendorListResponse(
|
|
||||||
vendors=[],
|
|
||||||
total=0,
|
|
||||||
page=page,
|
page=page,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
has_more=False,
|
)
|
||||||
|
|
||||||
|
return LetzshopVendorListResponse(
|
||||||
|
vendors=[LetzshopVendorInfo.from_cache(v, lang) for v in vendors],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
limit=limit,
|
||||||
|
has_more=(page * limit) < total,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/letzshop-vendors/lookup", response_model=LetzshopLookupResponse) # public
|
@router.post("/letzshop-vendors/lookup", response_model=LetzshopLookupResponse) # public
|
||||||
async def lookup_letzshop_vendor(
|
async def lookup_letzshop_vendor(
|
||||||
request: LetzshopLookupRequest,
|
request: LetzshopLookupRequest,
|
||||||
|
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> LetzshopLookupResponse:
|
) -> LetzshopLookupResponse:
|
||||||
"""
|
"""
|
||||||
@@ -149,7 +187,7 @@ async def lookup_letzshop_vendor(
|
|||||||
|
|
||||||
This endpoint:
|
This endpoint:
|
||||||
1. Extracts the slug from the provided URL
|
1. Extracts the slug from the provided URL
|
||||||
2. Attempts to fetch vendor info from Letzshop
|
2. Looks up vendor in local cache (or fetches from Letzshop if not cached)
|
||||||
3. Checks if the vendor is already claimed on our platform
|
3. Checks if the vendor is already claimed on our platform
|
||||||
4. Returns vendor info for signup pre-fill
|
4. Returns vendor info for signup pre-fill
|
||||||
"""
|
"""
|
||||||
@@ -162,23 +200,25 @@ async def lookup_letzshop_vendor(
|
|||||||
error="Could not extract vendor slug from URL",
|
error="Could not extract vendor slug from URL",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if already claimed (using service layer)
|
sync_service = LetzshopVendorSyncService(db)
|
||||||
is_claimed = platform_signup_service.check_vendor_claimed(db, slug)
|
|
||||||
|
|
||||||
# TODO: Fetch actual vendor info from Letzshop (Phase 4)
|
# First try cache
|
||||||
# For now, return basic info based on the slug
|
cache_entry = sync_service.get_cached_vendor(slug)
|
||||||
letzshop_url = f"https://letzshop.lu/vendors/{slug}"
|
|
||||||
|
|
||||||
vendor_info = LetzshopVendorInfo(
|
# If not in cache, try to fetch from Letzshop
|
||||||
slug=slug,
|
if not cache_entry:
|
||||||
name=slug.replace("-", " ").title(), # Placeholder name
|
logger.info(f"Vendor {slug} not in cache, fetching from Letzshop...")
|
||||||
letzshop_url=letzshop_url,
|
cache_entry = sync_service.sync_single_vendor(slug)
|
||||||
is_claimed=is_claimed,
|
|
||||||
)
|
if not cache_entry:
|
||||||
|
return LetzshopLookupResponse(
|
||||||
|
found=False,
|
||||||
|
error="Vendor not found on Letzshop",
|
||||||
|
)
|
||||||
|
|
||||||
return LetzshopLookupResponse(
|
return LetzshopLookupResponse(
|
||||||
found=True,
|
found=True,
|
||||||
vendor=vendor_info,
|
vendor=LetzshopVendorInfo.from_cache(cache_entry, lang),
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -192,26 +232,40 @@ async def lookup_letzshop_vendor(
|
|||||||
@router.get("/letzshop-vendors/{slug}", response_model=LetzshopVendorInfo) # public
|
@router.get("/letzshop-vendors/{slug}", response_model=LetzshopVendorInfo) # public
|
||||||
async def get_letzshop_vendor(
|
async def get_letzshop_vendor(
|
||||||
slug: str,
|
slug: str,
|
||||||
|
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> LetzshopVendorInfo:
|
) -> LetzshopVendorInfo:
|
||||||
"""
|
"""
|
||||||
Get a specific Letzshop vendor by slug.
|
Get a specific Letzshop vendor by slug.
|
||||||
|
|
||||||
Returns 404 if vendor not found.
|
Returns 404 if vendor not found in cache or on Letzshop.
|
||||||
"""
|
"""
|
||||||
slug = slug.lower()
|
slug = slug.lower()
|
||||||
|
|
||||||
# Check if claimed (using service layer)
|
sync_service = LetzshopVendorSyncService(db)
|
||||||
is_claimed = platform_signup_service.check_vendor_claimed(db, slug)
|
|
||||||
|
|
||||||
# TODO: Fetch actual vendor info from cache/API (Phase 4)
|
# First try cache
|
||||||
# For now, return placeholder based on slug
|
cache_entry = sync_service.get_cached_vendor(slug)
|
||||||
|
|
||||||
letzshop_url = f"https://letzshop.lu/vendors/{slug}"
|
# If not in cache, try to fetch from Letzshop
|
||||||
|
if not cache_entry:
|
||||||
|
logger.info(f"Vendor {slug} not in cache, fetching from Letzshop...")
|
||||||
|
cache_entry = sync_service.sync_single_vendor(slug)
|
||||||
|
|
||||||
return LetzshopVendorInfo(
|
if not cache_entry:
|
||||||
slug=slug,
|
raise ResourceNotFoundException("LetzshopVendor", slug)
|
||||||
name=slug.replace("-", " ").title(),
|
|
||||||
letzshop_url=letzshop_url,
|
return LetzshopVendorInfo.from_cache(cache_entry, lang)
|
||||||
is_claimed=is_claimed,
|
|
||||||
)
|
|
||||||
|
@router.get("/letzshop-vendors-stats") # public
|
||||||
|
async def get_letzshop_vendor_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Get statistics about the Letzshop vendor cache.
|
||||||
|
|
||||||
|
Returns total, active, claimed, and unclaimed vendor counts.
|
||||||
|
"""
|
||||||
|
sync_service = LetzshopVendorSyncService(db)
|
||||||
|
return sync_service.get_sync_stats()
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from app.api.deps import (
|
|||||||
get_current_admin_optional,
|
get_current_admin_optional,
|
||||||
get_db,
|
get_db,
|
||||||
)
|
)
|
||||||
|
from app.core.config import settings
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -660,6 +661,7 @@ async def admin_background_tasks_page(
|
|||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"user": current_user,
|
"user": current_user,
|
||||||
|
"flower_url": settings.flower_url,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -760,6 +762,38 @@ async def admin_letzshop_product_detail_page(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# LETZSHOP VENDOR DIRECTORY
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/letzshop/vendor-directory",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
async def admin_letzshop_vendor_directory_page(
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Render Letzshop vendor directory management page.
|
||||||
|
|
||||||
|
Allows admins to:
|
||||||
|
- View cached Letzshop vendors
|
||||||
|
- Trigger manual sync from Letzshop API
|
||||||
|
- Create platform vendors from cached Letzshop vendors
|
||||||
|
"""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/letzshop-vendor-directory.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": current_user,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PRODUCT CATALOG ROUTES
|
# PRODUCT CATALOG ROUTES
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Provides:
|
|||||||
- Credential management service
|
- Credential management service
|
||||||
- Order import service
|
- Order import service
|
||||||
- Fulfillment sync service
|
- Fulfillment sync service
|
||||||
|
- Vendor directory sync service
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .client_service import (
|
from .client_service import (
|
||||||
@@ -26,6 +27,10 @@ from .order_service import (
|
|||||||
OrderNotFoundError,
|
OrderNotFoundError,
|
||||||
VendorNotFoundError,
|
VendorNotFoundError,
|
||||||
)
|
)
|
||||||
|
from .vendor_sync_service import (
|
||||||
|
LetzshopVendorSyncService,
|
||||||
|
get_vendor_sync_service,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Client
|
# Client
|
||||||
@@ -42,4 +47,7 @@ __all__ = [
|
|||||||
"LetzshopOrderService",
|
"LetzshopOrderService",
|
||||||
"OrderNotFoundError",
|
"OrderNotFoundError",
|
||||||
"VendorNotFoundError",
|
"VendorNotFoundError",
|
||||||
|
# Vendor Sync Service
|
||||||
|
"LetzshopVendorSyncService",
|
||||||
|
"get_vendor_sync_service",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -366,6 +366,83 @@ query GetShipmentsPaginated($first: Int!, $after: String) {{
|
|||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# GraphQL Queries - Vendor Directory (Public)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
QUERY_VENDORS_PAGINATED = """
|
||||||
|
query GetVendorsPaginated($first: Int!, $after: String) {
|
||||||
|
vendors(first: $first, after: $after) {
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
totalCount
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
name
|
||||||
|
active
|
||||||
|
companyName
|
||||||
|
legalName
|
||||||
|
email
|
||||||
|
phone
|
||||||
|
fax
|
||||||
|
homepage
|
||||||
|
description { en fr de }
|
||||||
|
location {
|
||||||
|
street
|
||||||
|
number
|
||||||
|
city
|
||||||
|
zipcode
|
||||||
|
country { iso }
|
||||||
|
}
|
||||||
|
lat
|
||||||
|
lng
|
||||||
|
vendorCategories { name { en fr de } }
|
||||||
|
backgroundImage { url }
|
||||||
|
socialMediaLinks { url }
|
||||||
|
openingHours { en fr de }
|
||||||
|
representative
|
||||||
|
representativeTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
QUERY_VENDOR_BY_SLUG = """
|
||||||
|
query GetVendorBySlug($slug: String!) {
|
||||||
|
vendor(slug: $slug) {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
name
|
||||||
|
active
|
||||||
|
companyName
|
||||||
|
legalName
|
||||||
|
email
|
||||||
|
phone
|
||||||
|
fax
|
||||||
|
homepage
|
||||||
|
description { en fr de }
|
||||||
|
location {
|
||||||
|
street
|
||||||
|
number
|
||||||
|
city
|
||||||
|
zipcode
|
||||||
|
country { iso }
|
||||||
|
}
|
||||||
|
lat
|
||||||
|
lng
|
||||||
|
vendorCategories { name { en fr de } }
|
||||||
|
backgroundImage { url }
|
||||||
|
socialMediaLinks { url }
|
||||||
|
openingHours { en fr de }
|
||||||
|
representative
|
||||||
|
representativeTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# GraphQL Mutations
|
# GraphQL Mutations
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -475,6 +552,74 @@ class LetzshopClient:
|
|||||||
self.close()
|
self.close()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _execute_public(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
variables: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Execute a GraphQL query without authentication (for public queries).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: The GraphQL query string.
|
||||||
|
variables: Optional variables for the query.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The response data from the API.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
LetzshopAPIError: If the API returns an error.
|
||||||
|
LetzshopConnectionError: If the request fails.
|
||||||
|
"""
|
||||||
|
payload = {"query": query}
|
||||||
|
if variables:
|
||||||
|
payload["variables"] = variables
|
||||||
|
|
||||||
|
logger.debug(f"Executing public GraphQL request to {self.endpoint}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use a simple request without Authorization header
|
||||||
|
response = requests.post(
|
||||||
|
self.endpoint,
|
||||||
|
json=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
except requests.exceptions.Timeout as e:
|
||||||
|
raise LetzshopConnectionError(f"Request timed out: {e}") from e
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
raise LetzshopConnectionError(f"Connection failed: {e}") from e
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise LetzshopConnectionError(f"Request failed: {e}") from e
|
||||||
|
|
||||||
|
# Handle HTTP-level errors
|
||||||
|
if response.status_code >= 500:
|
||||||
|
raise LetzshopAPIError(
|
||||||
|
f"Letzshop server error (HTTP {response.status_code})",
|
||||||
|
response_data={"status_code": response.status_code},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse JSON response
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError as e:
|
||||||
|
raise LetzshopAPIError(
|
||||||
|
f"Invalid JSON response: {response.text[:200]}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
logger.debug(f"GraphQL response: {data}")
|
||||||
|
|
||||||
|
# Handle GraphQL errors
|
||||||
|
if "errors" in data:
|
||||||
|
errors = data["errors"]
|
||||||
|
error_messages = [e.get("message", str(e)) for e in errors]
|
||||||
|
raise LetzshopAPIError(
|
||||||
|
f"GraphQL errors: {'; '.join(error_messages)}",
|
||||||
|
response_data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.get("data", {})
|
||||||
|
|
||||||
def _execute(
|
def _execute(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
@@ -771,3 +916,100 @@ class LetzshopClient:
|
|||||||
|
|
||||||
data = self._execute(MUTATION_SET_SHIPMENT_TRACKING, variables)
|
data = self._execute(MUTATION_SET_SHIPMENT_TRACKING, variables)
|
||||||
return data.get("setShipmentTracking", {})
|
return data.get("setShipmentTracking", {})
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Vendor Directory Queries (Public - No Auth Required)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def get_all_vendors_paginated(
|
||||||
|
self,
|
||||||
|
page_size: int = 50,
|
||||||
|
max_pages: int | None = None,
|
||||||
|
progress_callback: Callable[[int, int, int], None] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Fetch all vendors from Letzshop marketplace directory.
|
||||||
|
|
||||||
|
This uses the public GraphQL API (no authentication required).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_size: Number of vendors per page (default 50).
|
||||||
|
max_pages: Maximum number of pages to fetch (None = all).
|
||||||
|
progress_callback: Optional callback(page, total_fetched, total_count)
|
||||||
|
for progress updates.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of all vendor data dictionaries.
|
||||||
|
"""
|
||||||
|
all_vendors = []
|
||||||
|
cursor = None
|
||||||
|
page = 0
|
||||||
|
total_count = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
page += 1
|
||||||
|
variables = {"first": page_size}
|
||||||
|
if cursor:
|
||||||
|
variables["after"] = cursor
|
||||||
|
|
||||||
|
logger.info(f"Fetching vendors page {page} (cursor: {cursor})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use public endpoint (no authentication required)
|
||||||
|
data = self._execute_public(QUERY_VENDORS_PAGINATED, variables)
|
||||||
|
except LetzshopAPIError as e:
|
||||||
|
logger.error(f"Error fetching vendors page {page}: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
vendors_data = data.get("vendors", {})
|
||||||
|
nodes = vendors_data.get("nodes", [])
|
||||||
|
page_info = vendors_data.get("pageInfo", {})
|
||||||
|
|
||||||
|
if total_count is None:
|
||||||
|
total_count = vendors_data.get("totalCount", 0)
|
||||||
|
logger.info(f"Total vendors in Letzshop: {total_count}")
|
||||||
|
|
||||||
|
all_vendors.extend(nodes)
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(page, len(all_vendors), total_count)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Page {page}: fetched {len(nodes)} vendors, "
|
||||||
|
f"total: {len(all_vendors)}/{total_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if there are more pages
|
||||||
|
if not page_info.get("hasNextPage"):
|
||||||
|
logger.info(f"Reached last page. Total vendors: {len(all_vendors)}")
|
||||||
|
break
|
||||||
|
|
||||||
|
cursor = page_info.get("endCursor")
|
||||||
|
|
||||||
|
# Check max pages limit
|
||||||
|
if max_pages and page >= max_pages:
|
||||||
|
logger.info(
|
||||||
|
f"Reached max pages limit ({max_pages}). "
|
||||||
|
f"Total vendors: {len(all_vendors)}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
return all_vendors
|
||||||
|
|
||||||
|
def get_vendor_by_slug(self, slug: str) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Get a single vendor by their URL slug.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slug: The vendor's URL slug (e.g., "nicks-diecast-corner").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Vendor data dictionary or None if not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Use public endpoint (no authentication required)
|
||||||
|
data = self._execute_public(QUERY_VENDOR_BY_SLUG, {"slug": slug})
|
||||||
|
return data.get("vendor")
|
||||||
|
except LetzshopAPIError as e:
|
||||||
|
logger.warning(f"Vendor not found with slug '{slug}': {e}")
|
||||||
|
return None
|
||||||
|
|||||||
521
app/services/letzshop/vendor_sync_service.py
Normal file
521
app/services/letzshop/vendor_sync_service.py
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
# app/services/letzshop/vendor_sync_service.py
|
||||||
|
"""
|
||||||
|
Service for syncing Letzshop vendor directory to local cache.
|
||||||
|
|
||||||
|
Fetches vendor data from Letzshop's public GraphQL API and stores it
|
||||||
|
in the letzshop_vendor_cache table for fast lookups during signup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.services.letzshop.client_service import LetzshopClient
|
||||||
|
from models.database.letzshop import LetzshopVendorCache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LetzshopVendorSyncService:
|
||||||
|
"""
|
||||||
|
Service for syncing Letzshop vendor directory.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
service = LetzshopVendorSyncService(db)
|
||||||
|
stats = service.sync_all_vendors()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
"""Initialize the sync service."""
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def sync_all_vendors(
|
||||||
|
self,
|
||||||
|
progress_callback: Callable[[int, int, int], None] | None = None,
|
||||||
|
max_pages: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Sync all vendors from Letzshop to local cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
progress_callback: Optional callback(page, fetched, total) for progress.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with sync statistics.
|
||||||
|
"""
|
||||||
|
stats = {
|
||||||
|
"started_at": datetime.now(UTC),
|
||||||
|
"total_fetched": 0,
|
||||||
|
"created": 0,
|
||||||
|
"updated": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"error_details": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Starting Letzshop vendor directory sync...")
|
||||||
|
|
||||||
|
# Create client (no API key needed for public vendor data)
|
||||||
|
client = LetzshopClient(api_key="")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fetch all vendors
|
||||||
|
vendors = client.get_all_vendors_paginated(
|
||||||
|
page_size=50,
|
||||||
|
max_pages=max_pages,
|
||||||
|
progress_callback=progress_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats["total_fetched"] = len(vendors)
|
||||||
|
logger.info(f"Fetched {len(vendors)} vendors from Letzshop")
|
||||||
|
|
||||||
|
# Process each vendor
|
||||||
|
for vendor_data in vendors:
|
||||||
|
try:
|
||||||
|
result = self._upsert_vendor(vendor_data)
|
||||||
|
if result == "created":
|
||||||
|
stats["created"] += 1
|
||||||
|
elif result == "updated":
|
||||||
|
stats["updated"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
stats["errors"] += 1
|
||||||
|
error_info = {
|
||||||
|
"vendor_id": vendor_data.get("id"),
|
||||||
|
"slug": vendor_data.get("slug"),
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
stats["error_details"].append(error_info)
|
||||||
|
logger.error(f"Error processing vendor {vendor_data.get('slug')}: {e}")
|
||||||
|
|
||||||
|
# Commit all changes
|
||||||
|
self.db.commit()
|
||||||
|
logger.info(
|
||||||
|
f"Sync complete: {stats['created']} created, "
|
||||||
|
f"{stats['updated']} updated, {stats['errors']} errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.error(f"Vendor sync failed: {e}")
|
||||||
|
stats["error"] = str(e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
stats["completed_at"] = datetime.now(UTC)
|
||||||
|
stats["duration_seconds"] = (
|
||||||
|
stats["completed_at"] - stats["started_at"]
|
||||||
|
).total_seconds()
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def _upsert_vendor(self, vendor_data: dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Insert or update a vendor in the cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vendor_data: Raw vendor data from Letzshop API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
"created" or "updated" indicating the operation performed.
|
||||||
|
"""
|
||||||
|
letzshop_id = vendor_data.get("id")
|
||||||
|
slug = vendor_data.get("slug")
|
||||||
|
|
||||||
|
if not letzshop_id or not slug:
|
||||||
|
raise ValueError("Vendor missing required id or slug")
|
||||||
|
|
||||||
|
# Parse the vendor data
|
||||||
|
parsed = self._parse_vendor_data(vendor_data)
|
||||||
|
|
||||||
|
# Check if exists
|
||||||
|
existing = (
|
||||||
|
self.db.query(LetzshopVendorCache)
|
||||||
|
.filter(LetzshopVendorCache.letzshop_id == letzshop_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update existing record (preserve claimed status)
|
||||||
|
for key, value in parsed.items():
|
||||||
|
if key not in ("claimed_by_vendor_id", "claimed_at"):
|
||||||
|
setattr(existing, key, value)
|
||||||
|
existing.last_synced_at = datetime.now(UTC)
|
||||||
|
return "updated"
|
||||||
|
else:
|
||||||
|
# Create new record
|
||||||
|
cache_entry = LetzshopVendorCache(
|
||||||
|
**parsed,
|
||||||
|
last_synced_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
self.db.add(cache_entry)
|
||||||
|
return "created"
|
||||||
|
|
||||||
|
def _parse_vendor_data(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Parse raw Letzshop vendor data into cache model fields.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Raw vendor data from Letzshop API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of parsed fields for LetzshopVendorCache.
|
||||||
|
"""
|
||||||
|
# Extract location
|
||||||
|
location = data.get("location") or {}
|
||||||
|
country = location.get("country") or {}
|
||||||
|
|
||||||
|
# Extract descriptions
|
||||||
|
description = data.get("description") or {}
|
||||||
|
|
||||||
|
# Extract opening hours
|
||||||
|
opening_hours = data.get("openingHours") or {}
|
||||||
|
|
||||||
|
# Extract categories (list of translated name objects)
|
||||||
|
categories = []
|
||||||
|
for cat in data.get("vendorCategories") or []:
|
||||||
|
cat_name = cat.get("name") or {}
|
||||||
|
# Prefer English, fallback to French or German
|
||||||
|
name = cat_name.get("en") or cat_name.get("fr") or cat_name.get("de")
|
||||||
|
if name:
|
||||||
|
categories.append(name)
|
||||||
|
|
||||||
|
# Extract social media URLs
|
||||||
|
social_links = []
|
||||||
|
for link in data.get("socialMediaLinks") or []:
|
||||||
|
url = link.get("url")
|
||||||
|
if url:
|
||||||
|
social_links.append(url)
|
||||||
|
|
||||||
|
# Extract background image
|
||||||
|
bg_image = data.get("backgroundImage") or {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"letzshop_id": data.get("id"),
|
||||||
|
"slug": data.get("slug"),
|
||||||
|
"name": data.get("name"),
|
||||||
|
"company_name": data.get("companyName") or data.get("legalName"),
|
||||||
|
"is_active": data.get("active", True),
|
||||||
|
# Descriptions
|
||||||
|
"description_en": description.get("en"),
|
||||||
|
"description_fr": description.get("fr"),
|
||||||
|
"description_de": description.get("de"),
|
||||||
|
# Contact
|
||||||
|
"email": data.get("email"),
|
||||||
|
"phone": data.get("phone"),
|
||||||
|
"fax": data.get("fax"),
|
||||||
|
"website": data.get("homepage"),
|
||||||
|
# Location
|
||||||
|
"street": location.get("street"),
|
||||||
|
"street_number": location.get("number"),
|
||||||
|
"city": location.get("city"),
|
||||||
|
"zipcode": location.get("zipcode"),
|
||||||
|
"country_iso": country.get("iso", "LU"),
|
||||||
|
"latitude": str(data.get("lat")) if data.get("lat") else None,
|
||||||
|
"longitude": str(data.get("lng")) if data.get("lng") else None,
|
||||||
|
# Categories and media
|
||||||
|
"categories": categories,
|
||||||
|
"background_image_url": bg_image.get("url"),
|
||||||
|
"social_media_links": social_links,
|
||||||
|
# Opening hours
|
||||||
|
"opening_hours_en": opening_hours.get("en"),
|
||||||
|
"opening_hours_fr": opening_hours.get("fr"),
|
||||||
|
"opening_hours_de": opening_hours.get("de"),
|
||||||
|
# Representative
|
||||||
|
"representative_name": data.get("representative"),
|
||||||
|
"representative_title": data.get("representativeTitle"),
|
||||||
|
# Raw data for reference
|
||||||
|
"raw_data": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
def sync_single_vendor(self, slug: str) -> LetzshopVendorCache | None:
|
||||||
|
"""
|
||||||
|
Sync a single vendor by slug.
|
||||||
|
|
||||||
|
Useful for on-demand refresh when a user looks up a vendor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slug: The vendor's URL slug.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated/created cache entry, or None if not found.
|
||||||
|
"""
|
||||||
|
client = LetzshopClient(api_key="")
|
||||||
|
|
||||||
|
try:
|
||||||
|
vendor_data = client.get_vendor_by_slug(slug)
|
||||||
|
|
||||||
|
if not vendor_data:
|
||||||
|
logger.warning(f"Vendor not found on Letzshop: {slug}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = self._upsert_vendor(vendor_data)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Single vendor sync: {slug} ({result})")
|
||||||
|
|
||||||
|
return (
|
||||||
|
self.db.query(LetzshopVendorCache)
|
||||||
|
.filter(LetzshopVendorCache.slug == slug)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
def get_cached_vendor(self, slug: str) -> LetzshopVendorCache | None:
|
||||||
|
"""
|
||||||
|
Get a vendor from cache by slug.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slug: The vendor's URL slug.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cache entry or None if not found.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
self.db.query(LetzshopVendorCache)
|
||||||
|
.filter(LetzshopVendorCache.slug == slug.lower())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
def search_cached_vendors(
|
||||||
|
self,
|
||||||
|
search: str | None = None,
|
||||||
|
city: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
only_unclaimed: bool = False,
|
||||||
|
page: int = 1,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> tuple[list[LetzshopVendorCache], int]:
|
||||||
|
"""
|
||||||
|
Search cached vendors with filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search: Search term for name.
|
||||||
|
city: Filter by city.
|
||||||
|
category: Filter by category.
|
||||||
|
only_unclaimed: Only return vendors not yet claimed.
|
||||||
|
page: Page number (1-indexed).
|
||||||
|
limit: Items per page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (vendors list, total count).
|
||||||
|
"""
|
||||||
|
query = self.db.query(LetzshopVendorCache).filter(
|
||||||
|
LetzshopVendorCache.is_active == True # noqa: E712
|
||||||
|
)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
search_term = f"%{search.lower()}%"
|
||||||
|
query = query.filter(
|
||||||
|
func.lower(LetzshopVendorCache.name).like(search_term)
|
||||||
|
)
|
||||||
|
|
||||||
|
if city:
|
||||||
|
query = query.filter(
|
||||||
|
func.lower(LetzshopVendorCache.city) == city.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
# Search in JSON array
|
||||||
|
query = query.filter(
|
||||||
|
LetzshopVendorCache.categories.contains([category])
|
||||||
|
)
|
||||||
|
|
||||||
|
if only_unclaimed:
|
||||||
|
query = query.filter(
|
||||||
|
LetzshopVendorCache.claimed_by_vendor_id.is_(None)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
vendors = (
|
||||||
|
query.order_by(LetzshopVendorCache.name)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return vendors, total
|
||||||
|
|
||||||
|
def get_sync_stats(self) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get statistics about the vendor cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with cache statistics.
|
||||||
|
"""
|
||||||
|
total = self.db.query(LetzshopVendorCache).count()
|
||||||
|
active = (
|
||||||
|
self.db.query(LetzshopVendorCache)
|
||||||
|
.filter(LetzshopVendorCache.is_active == True) # noqa: E712
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
claimed = (
|
||||||
|
self.db.query(LetzshopVendorCache)
|
||||||
|
.filter(LetzshopVendorCache.claimed_by_vendor_id.isnot(None))
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get last sync time
|
||||||
|
last_synced = (
|
||||||
|
self.db.query(func.max(LetzshopVendorCache.last_synced_at)).scalar()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get unique cities
|
||||||
|
cities = (
|
||||||
|
self.db.query(LetzshopVendorCache.city)
|
||||||
|
.filter(LetzshopVendorCache.city.isnot(None))
|
||||||
|
.distinct()
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_vendors": total,
|
||||||
|
"active_vendors": active,
|
||||||
|
"claimed_vendors": claimed,
|
||||||
|
"unclaimed_vendors": active - claimed,
|
||||||
|
"unique_cities": cities,
|
||||||
|
"last_synced_at": last_synced.isoformat() if last_synced else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def mark_vendor_claimed(
|
||||||
|
self,
|
||||||
|
letzshop_slug: str,
|
||||||
|
vendor_id: int,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Mark a Letzshop vendor as claimed by a platform vendor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
letzshop_slug: The Letzshop vendor slug.
|
||||||
|
vendor_id: The platform vendor ID that claimed it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False if vendor not found.
|
||||||
|
"""
|
||||||
|
cache_entry = self.get_cached_vendor(letzshop_slug)
|
||||||
|
|
||||||
|
if not cache_entry:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cache_entry.claimed_by_vendor_id = vendor_id
|
||||||
|
cache_entry.claimed_at = datetime.now(UTC)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Vendor {letzshop_slug} claimed by vendor_id={vendor_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def create_vendor_from_cache(
|
||||||
|
self,
|
||||||
|
letzshop_slug: str,
|
||||||
|
company_id: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a platform vendor from a cached Letzshop vendor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
letzshop_slug: The Letzshop vendor slug.
|
||||||
|
company_id: The company ID to create the vendor under.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with created vendor info.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If vendor not found, already claimed, or company not found.
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from app.services.admin_service import admin_service
|
||||||
|
from models.database.company import Company
|
||||||
|
from models.database.vendor import Vendor
|
||||||
|
from models.schema.vendor import VendorCreate
|
||||||
|
|
||||||
|
# Get cache entry
|
||||||
|
cache_entry = self.get_cached_vendor(letzshop_slug)
|
||||||
|
if not cache_entry:
|
||||||
|
raise ValueError(f"Letzshop vendor '{letzshop_slug}' not found in cache")
|
||||||
|
|
||||||
|
if cache_entry.is_claimed:
|
||||||
|
raise ValueError(
|
||||||
|
f"Letzshop vendor '{cache_entry.name}' is already claimed "
|
||||||
|
f"by vendor ID {cache_entry.claimed_by_vendor_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify company exists
|
||||||
|
company = self.db.query(Company).filter(Company.id == company_id).first()
|
||||||
|
if not company:
|
||||||
|
raise ValueError(f"Company with ID {company_id} not found")
|
||||||
|
|
||||||
|
# Generate vendor code from slug
|
||||||
|
vendor_code = letzshop_slug.upper().replace("-", "_")[:20]
|
||||||
|
|
||||||
|
# Check if vendor code already exists
|
||||||
|
existing = (
|
||||||
|
self.db.query(Vendor)
|
||||||
|
.filter(func.upper(Vendor.vendor_code) == vendor_code)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
vendor_code = f"{vendor_code[:16]}_{random.randint(100, 999)}"
|
||||||
|
|
||||||
|
# Generate subdomain from slug
|
||||||
|
subdomain = letzshop_slug.lower().replace("_", "-")[:30]
|
||||||
|
existing_subdomain = (
|
||||||
|
self.db.query(Vendor)
|
||||||
|
.filter(func.lower(Vendor.subdomain) == subdomain)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing_subdomain:
|
||||||
|
subdomain = f"{subdomain[:26]}-{random.randint(100, 999)}"
|
||||||
|
|
||||||
|
# Create vendor data from cache
|
||||||
|
address = f"{cache_entry.street or ''} {cache_entry.street_number or ''}".strip()
|
||||||
|
vendor_data = VendorCreate(
|
||||||
|
name=cache_entry.name,
|
||||||
|
vendor_code=vendor_code,
|
||||||
|
subdomain=subdomain,
|
||||||
|
company_id=company_id,
|
||||||
|
email=cache_entry.email or company.email,
|
||||||
|
phone=cache_entry.phone,
|
||||||
|
description=cache_entry.description_en or cache_entry.description_fr or "",
|
||||||
|
city=cache_entry.city,
|
||||||
|
country=cache_entry.country_iso or "LU",
|
||||||
|
website=cache_entry.website,
|
||||||
|
address_line_1=address or None,
|
||||||
|
postal_code=cache_entry.zipcode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create vendor
|
||||||
|
vendor = admin_service.create_vendor(self.db, vendor_data)
|
||||||
|
|
||||||
|
# Mark the Letzshop vendor as claimed (commits internally) # noqa: SVC-006
|
||||||
|
self.mark_vendor_claimed(letzshop_slug, vendor.id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created vendor {vendor.vendor_code} from Letzshop vendor {letzshop_slug}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": vendor.id,
|
||||||
|
"vendor_code": vendor.vendor_code,
|
||||||
|
"name": vendor.name,
|
||||||
|
"subdomain": vendor.subdomain,
|
||||||
|
"company_id": vendor.company_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton-style function for easy access
|
||||||
|
def get_vendor_sync_service(db: Session) -> LetzshopVendorSyncService:
|
||||||
|
"""Get a vendor sync service instance."""
|
||||||
|
return LetzshopVendorSyncService(db)
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
# app/tasks/celery_tasks/letzshop.py
|
# app/tasks/celery_tasks/letzshop.py
|
||||||
"""
|
"""
|
||||||
Celery tasks for Letzshop historical order imports.
|
Celery tasks for Letzshop integration.
|
||||||
|
|
||||||
Wraps the existing process_historical_import function for Celery execution.
|
Includes:
|
||||||
|
- Historical order imports
|
||||||
|
- Vendor directory sync
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from app.core.celery_config import celery_app
|
from app.core.celery_config import celery_app
|
||||||
from app.services.admin_notification_service import admin_notification_service
|
from app.services.admin_notification_service import admin_notification_service
|
||||||
from app.services.letzshop import LetzshopClientError
|
from app.services.letzshop import LetzshopClientError
|
||||||
from app.services.letzshop.credentials_service import LetzshopCredentialsService
|
from app.services.letzshop.credentials_service import LetzshopCredentialsService
|
||||||
from app.services.letzshop.order_service import LetzshopOrderService
|
from app.services.letzshop.order_service import LetzshopOrderService
|
||||||
|
from app.services.letzshop.vendor_sync_service import LetzshopVendorSyncService
|
||||||
from app.tasks.celery_tasks.base import DatabaseTask
|
from app.tasks.celery_tasks.base import DatabaseTask
|
||||||
from models.database.letzshop import LetzshopHistoricalImportJob
|
from models.database.letzshop import LetzshopHistoricalImportJob
|
||||||
|
|
||||||
@@ -270,3 +273,78 @@ def process_historical_import(self, job_id: int, vendor_id: int):
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
raise # Re-raise for Celery retry
|
raise # Re-raise for Celery retry
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Vendor Directory Sync
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(
|
||||||
|
bind=True,
|
||||||
|
base=DatabaseTask,
|
||||||
|
name="app.tasks.celery_tasks.letzshop.sync_vendor_directory",
|
||||||
|
max_retries=2,
|
||||||
|
default_retry_delay=300,
|
||||||
|
autoretry_for=(Exception,),
|
||||||
|
retry_backoff=True,
|
||||||
|
)
|
||||||
|
def sync_vendor_directory(self) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Celery task to sync Letzshop vendor directory.
|
||||||
|
|
||||||
|
Fetches all vendors from Letzshop's public GraphQL API and updates
|
||||||
|
the local letzshop_vendor_cache table.
|
||||||
|
|
||||||
|
This task should be scheduled to run periodically (e.g., daily)
|
||||||
|
via Celery beat.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Sync statistics including created, updated, and error counts.
|
||||||
|
"""
|
||||||
|
with self.get_db() as db:
|
||||||
|
try:
|
||||||
|
logger.info("Starting Letzshop vendor directory sync...")
|
||||||
|
|
||||||
|
sync_service = LetzshopVendorSyncService(db)
|
||||||
|
|
||||||
|
def progress_callback(page: int, fetched: int, total: int):
|
||||||
|
"""Log progress during sync."""
|
||||||
|
logger.info(f"Vendor sync progress: page {page}, {fetched}/{total} vendors")
|
||||||
|
|
||||||
|
stats = sync_service.sync_all_vendors(progress_callback=progress_callback)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Vendor directory sync completed: "
|
||||||
|
f"{stats.get('created', 0)} created, "
|
||||||
|
f"{stats.get('updated', 0)} updated, "
|
||||||
|
f"{stats.get('errors', 0)} errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send admin notification if there were errors
|
||||||
|
if stats.get("errors", 0) > 0:
|
||||||
|
admin_notification_service.notify_system_info(
|
||||||
|
db=db,
|
||||||
|
title="Letzshop Vendor Sync Completed with Errors",
|
||||||
|
message=(
|
||||||
|
f"Synced {stats.get('total_fetched', 0)} vendors. "
|
||||||
|
f"Errors: {stats.get('errors', 0)}"
|
||||||
|
),
|
||||||
|
details=stats,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Vendor directory sync failed: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Notify admins of failure
|
||||||
|
admin_notification_service.notify_critical_error(
|
||||||
|
db=db,
|
||||||
|
error_type="Vendor Directory Sync",
|
||||||
|
error_message=f"Failed to sync Letzshop vendor directory: {str(e)[:200]}",
|
||||||
|
details={"error": str(e)},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
raise # Re-raise for Celery retry
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
# app/tasks/letzshop_tasks.py
|
# app/tasks/letzshop_tasks.py
|
||||||
"""Background tasks for Letzshop historical order imports."""
|
"""Background tasks for Letzshop integration."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from app.core.database import SessionLocal
|
from app.core.database import SessionLocal
|
||||||
from app.services.admin_notification_service import admin_notification_service
|
from app.services.admin_notification_service import admin_notification_service
|
||||||
from app.services.letzshop import LetzshopClientError
|
from app.services.letzshop import LetzshopClientError
|
||||||
from app.services.letzshop.credentials_service import LetzshopCredentialsService
|
from app.services.letzshop.credentials_service import LetzshopCredentialsService
|
||||||
from app.services.letzshop.order_service import LetzshopOrderService
|
from app.services.letzshop.order_service import LetzshopOrderService
|
||||||
|
from app.services.letzshop.vendor_sync_service import LetzshopVendorSyncService
|
||||||
from models.database.letzshop import LetzshopHistoricalImportJob
|
from models.database.letzshop import LetzshopHistoricalImportJob
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -262,3 +263,80 @@ def process_historical_import(job_id: int, vendor_id: int):
|
|||||||
db.close()
|
db.close()
|
||||||
except Exception as close_error:
|
except Exception as close_error:
|
||||||
logger.error(f"Job {job_id}: Error closing database session: {close_error}")
|
logger.error(f"Job {job_id}: Error closing database session: {close_error}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Vendor Directory Sync
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def sync_letzshop_vendor_directory() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Sync Letzshop vendor directory to local cache.
|
||||||
|
|
||||||
|
This task fetches all vendors from Letzshop's public GraphQL API
|
||||||
|
and updates the local letzshop_vendor_cache table.
|
||||||
|
|
||||||
|
Should be run periodically (e.g., daily) via Celery beat.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with sync statistics.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
stats = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Starting Letzshop vendor directory sync task...")
|
||||||
|
|
||||||
|
sync_service = LetzshopVendorSyncService(db)
|
||||||
|
|
||||||
|
def progress_callback(page: int, fetched: int, total: int):
|
||||||
|
"""Log progress during sync."""
|
||||||
|
logger.info(f"Vendor sync progress: page {page}, {fetched}/{total} vendors")
|
||||||
|
|
||||||
|
stats = sync_service.sync_all_vendors(progress_callback=progress_callback)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Vendor directory sync completed: "
|
||||||
|
f"{stats.get('created', 0)} created, "
|
||||||
|
f"{stats.get('updated', 0)} updated, "
|
||||||
|
f"{stats.get('errors', 0)} errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send admin notification if there were errors
|
||||||
|
if stats.get("errors", 0) > 0:
|
||||||
|
admin_notification_service.notify_system_info(
|
||||||
|
db=db,
|
||||||
|
title="Letzshop Vendor Sync Completed with Errors",
|
||||||
|
message=(
|
||||||
|
f"Synced {stats.get('total_fetched', 0)} vendors. "
|
||||||
|
f"Errors: {stats.get('errors', 0)}"
|
||||||
|
),
|
||||||
|
details=stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Vendor directory sync failed: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Notify admins of failure
|
||||||
|
try:
|
||||||
|
admin_notification_service.notify_critical_error(
|
||||||
|
db=db,
|
||||||
|
error_type="Vendor Directory Sync",
|
||||||
|
error_message=f"Failed to sync Letzshop vendor directory: {str(e)[:200]}",
|
||||||
|
details={"error": str(e)},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if hasattr(db, "close") and callable(db.close):
|
||||||
|
try:
|
||||||
|
db.close()
|
||||||
|
except Exception as close_error:
|
||||||
|
logger.error(f"Error closing database session: {close_error}")
|
||||||
|
|||||||
430
app/templates/admin/letzshop-vendor-directory.html
Normal file
430
app/templates/admin/letzshop-vendor-directory.html
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
{# app/templates/admin/letzshop-vendor-directory.html #}
|
||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
|
||||||
|
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||||
|
{% from 'shared/macros/pagination.html' import pagination_controls %}
|
||||||
|
|
||||||
|
{% block title %}Letzshop Vendor Directory{% endblock %}
|
||||||
|
{% block alpine_data %}letzshopVendorDirectory(){% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Page Header -->
|
||||||
|
{% call page_header_flex(title='Letzshop Vendor Directory', subtitle='Browse and import vendors from Letzshop marketplace') %}
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
@click="triggerSync()"
|
||||||
|
:disabled="syncing"
|
||||||
|
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<span x-show="!syncing" x-html="$icon('arrow-path', 'w-4 h-4 mr-2')"></span>
|
||||||
|
<span x-show="syncing" class="w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
||||||
|
<span x-text="syncing ? 'Syncing...' : 'Sync from Letzshop'"></span>
|
||||||
|
</button>
|
||||||
|
{{ refresh_button(loading_var='loading', onclick='loadVendors()', variant='secondary') }}
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
<!-- Success/Error Messages -->
|
||||||
|
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-600 text-green-700 dark:text-green-300 rounded-lg flex items-center">
|
||||||
|
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 flex-shrink-0')"></span>
|
||||||
|
<span x-text="successMessage"></span>
|
||||||
|
<button @click="successMessage = ''" class="ml-auto">
|
||||||
|
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ error_state('Error', show_condition='error && !loading') }}
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Total Vendors</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 dark:text-white" x-text="stats.total_vendors || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
|
||||||
|
<span x-html="$icon('building-storefront', 'w-5 h-5 text-blue-600 dark:text-blue-400')"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Active</p>
|
||||||
|
<p class="text-2xl font-bold text-green-600 dark:text-green-400" x-text="stats.active_vendors || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="w-10 h-10 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
|
||||||
|
<span x-html="$icon('check-circle', 'w-5 h-5 text-green-600 dark:text-green-400')"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Claimed</p>
|
||||||
|
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400" x-text="stats.claimed_vendors || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
||||||
|
<span x-html="$icon('user-check', 'w-5 h-5 text-purple-600 dark:text-purple-400')"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Unclaimed</p>
|
||||||
|
<p class="text-2xl font-bold text-amber-600 dark:text-amber-400" x-text="stats.unclaimed_vendors || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div class="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-lg flex items-center justify-center">
|
||||||
|
<span x-html="$icon('user-plus', 'w-5 h-5 text-amber-600 dark:text-amber-400')"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2" x-show="stats.last_synced_at">
|
||||||
|
Last sync: <span x-text="formatDate(stats.last_synced_at)"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<!-- Search -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="filters.search"
|
||||||
|
@input.debounce.300ms="loadVendors()"
|
||||||
|
placeholder="Search by name..."
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<!-- City -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="filters.city"
|
||||||
|
@input.debounce.300ms="loadVendors()"
|
||||||
|
placeholder="Filter by city..."
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<!-- Category -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="filters.category"
|
||||||
|
@input.debounce.300ms="loadVendors()"
|
||||||
|
placeholder="Filter by category..."
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<!-- Only Unclaimed -->
|
||||||
|
<div class="flex items-end">
|
||||||
|
<label class="inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
x-model="filters.only_unclaimed"
|
||||||
|
@change="loadVendors()"
|
||||||
|
class="sr-only peer"
|
||||||
|
>
|
||||||
|
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-purple-600"></div>
|
||||||
|
<span class="ms-3 text-sm font-medium text-gray-700 dark:text-gray-300">Only Unclaimed</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div x-show="loading" class="flex justify-center items-center py-12">
|
||||||
|
<div class="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vendors Table -->
|
||||||
|
<div x-show="!loading" x-cloak class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div x-show="vendors.length === 0" class="text-center py-12">
|
||||||
|
<span x-html="$icon('building-storefront', 'w-12 h-12 mx-auto text-gray-400')"></span>
|
||||||
|
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No vendors found</h3>
|
||||||
|
<p class="mt-2 text-gray-500 dark:text-gray-400">
|
||||||
|
<span x-show="stats.total_vendors === 0">Click "Sync from Letzshop" to import vendors.</span>
|
||||||
|
<span x-show="stats.total_vendors > 0">Try adjusting your filters.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div x-show="vendors.length > 0" class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-900/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Vendor</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Contact</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Location</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Categories</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<template x-for="vendor in vendors" :key="vendor.id">
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0 w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
||||||
|
<span class="text-sm font-semibold text-purple-600 dark:text-purple-400" x-text="vendor.name?.charAt(0).toUpperCase()"></span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="vendor.name"></div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400" x-text="vendor.company_name"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="text-sm text-gray-900 dark:text-white" x-text="vendor.email || '-'"></div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400" x-text="vendor.phone || ''"></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="text-sm text-gray-900 dark:text-white" x-text="vendor.city || '-'"></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<template x-for="cat in (vendor.categories || []).slice(0, 2)" :key="cat">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200" x-text="cat"></span>
|
||||||
|
</template>
|
||||||
|
<span x-show="(vendor.categories || []).length > 2" class="text-xs text-gray-500">+<span x-text="vendor.categories.length - 2"></span></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span
|
||||||
|
x-show="vendor.is_claimed"
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('check', 'w-3 h-3 mr-1')"></span>
|
||||||
|
Claimed
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
x-show="!vendor.is_claimed"
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
Available
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<a
|
||||||
|
:href="vendor.letzshop_url"
|
||||||
|
target="_blank"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
title="View on Letzshop"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('arrow-top-right-on-square', 'w-5 h-5')"></span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
@click="showVendorDetail(vendor)"
|
||||||
|
class="text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-300"
|
||||||
|
title="View Details"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
x-show="!vendor.is_claimed"
|
||||||
|
@click="openCreateVendorModal(vendor)"
|
||||||
|
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300"
|
||||||
|
title="Create Platform Vendor"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('plus-circle', 'w-5 h-5')"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div x-show="vendors.length > 0" class="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Showing <span x-text="((page - 1) * limit) + 1"></span> to <span x-text="Math.min(page * limit, total)"></span> of <span x-text="total"></span> vendors
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="page--; loadVendors()"
|
||||||
|
:disabled="page <= 1"
|
||||||
|
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span class="px-3 py-1 text-sm">Page <span x-text="page"></span></span>
|
||||||
|
<button
|
||||||
|
@click="page++; loadVendors()"
|
||||||
|
:disabled="!hasMore"
|
||||||
|
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vendor Detail Modal -->
|
||||||
|
<div
|
||||||
|
x-show="showDetailModal"
|
||||||
|
x-cloak
|
||||||
|
class="fixed inset-0 z-50 overflow-y-auto"
|
||||||
|
@keydown.escape.window="showDetailModal = false"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
|
||||||
|
<div x-show="showDetailModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75" @click="showDetailModal = false"></div>
|
||||||
|
|
||||||
|
<div x-show="showDetailModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" class="relative inline-block w-full max-w-2xl p-6 my-8 text-left align-middle bg-white dark:bg-gray-800 rounded-xl shadow-xl">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="selectedVendor?.name"></h3>
|
||||||
|
<button @click="showDetailModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
<span x-html="$icon('x', 'w-6 h-6')"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="selectedVendor" class="space-y-4">
|
||||||
|
<!-- Company Name -->
|
||||||
|
<div x-show="selectedVendor?.company_name">
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Company</p>
|
||||||
|
<p class="text-gray-900 dark:text-white" x-text="selectedVendor?.company_name"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Email</p>
|
||||||
|
<p class="text-gray-900 dark:text-white" x-text="selectedVendor?.email || '-'"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Phone</p>
|
||||||
|
<p class="text-gray-900 dark:text-white" x-text="selectedVendor?.phone || '-'"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</p>
|
||||||
|
<p class="text-gray-900 dark:text-white">
|
||||||
|
<span x-text="selectedVendor?.city || '-'"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categories -->
|
||||||
|
<div x-show="selectedVendor?.categories?.length">
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Categories</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<template x-for="cat in (selectedVendor?.categories || [])" :key="cat">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300" x-text="cat"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Website -->
|
||||||
|
<div x-show="selectedVendor?.website">
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Website</p>
|
||||||
|
<a :href="selectedVendor?.website" target="_blank" class="text-purple-600 hover:text-purple-800 dark:text-purple-400" x-text="selectedVendor?.website"></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Letzshop URL -->
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Letzshop Page</p>
|
||||||
|
<a :href="selectedVendor?.letzshop_url" target="_blank" class="text-purple-600 hover:text-purple-800 dark:text-purple-400" x-text="selectedVendor?.letzshop_url"></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
|
<button @click="showDetailModal = false" class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
x-show="!selectedVendor?.is_claimed"
|
||||||
|
@click="showDetailModal = false; openCreateVendorModal(selectedVendor)"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 rounded-lg"
|
||||||
|
>
|
||||||
|
Create Vendor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Vendor Modal -->
|
||||||
|
<div
|
||||||
|
x-show="showCreateModal"
|
||||||
|
x-cloak
|
||||||
|
class="fixed inset-0 z-50 overflow-y-auto"
|
||||||
|
@keydown.escape.window="showCreateModal = false"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
|
||||||
|
<div x-show="showCreateModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75" @click="showCreateModal = false"></div>
|
||||||
|
|
||||||
|
<div x-show="showCreateModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" class="relative inline-block w-full max-w-md p-6 my-8 text-left align-middle bg-white dark:bg-gray-800 rounded-xl shadow-xl">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Create Vendor from Letzshop</h3>
|
||||||
|
<button @click="showCreateModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
<span x-html="$icon('x', 'w-6 h-6')"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Create a platform vendor from <strong x-text="createVendorData?.name"></strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Company Selection -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Select Company <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
x-model="createVendorData.company_id"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">-- Select a company --</option>
|
||||||
|
<template x-for="company in companies" :key="company.id">
|
||||||
|
<option :value="company.id" x-text="company.name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The vendor will be created under this company</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div x-show="createError" class="p-3 bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 rounded-lg text-sm">
|
||||||
|
<span x-text="createError"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="pt-4 flex justify-end gap-3">
|
||||||
|
<button @click="showCreateModal = false" class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="createVendor()"
|
||||||
|
:disabled="!createVendorData.company_id || creating"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg"
|
||||||
|
>
|
||||||
|
<span x-show="!creating">Create Vendor</span>
|
||||||
|
<span x-show="creating" class="flex items-center">
|
||||||
|
<span class="w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
||||||
|
Creating...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script src="{{ url_for('static', path='admin/js/letzshop-vendor-directory.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -172,6 +172,153 @@ class LetzshopSyncLog(Base, TimestampMixin):
|
|||||||
return f"<LetzshopSyncLog(id={self.id}, type='{self.operation_type}', status='{self.status}')>"
|
return f"<LetzshopSyncLog(id={self.id}, type='{self.operation_type}', status='{self.status}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class LetzshopVendorCache(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Cache of Letzshop marketplace vendor directory.
|
||||||
|
|
||||||
|
This table stores vendor data fetched from Letzshop's public GraphQL API,
|
||||||
|
allowing users to browse and claim existing Letzshop shops during signup.
|
||||||
|
|
||||||
|
Data is periodically synced from Letzshop (e.g., daily via Celery task).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "letzshop_vendor_cache"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Letzshop identifiers
|
||||||
|
letzshop_id = Column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
"""Unique ID from Letzshop (e.g., 'lpkedYMRup')."""
|
||||||
|
|
||||||
|
slug = Column(String(200), unique=True, nullable=False, index=True)
|
||||||
|
"""URL slug (e.g., 'nicks-diecast-corner')."""
|
||||||
|
|
||||||
|
# Basic info
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
"""Vendor display name."""
|
||||||
|
|
||||||
|
company_name = Column(String(255), nullable=True)
|
||||||
|
"""Legal company name."""
|
||||||
|
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
"""Whether vendor is active on Letzshop."""
|
||||||
|
|
||||||
|
# Descriptions (multilingual)
|
||||||
|
description_en = Column(Text, nullable=True)
|
||||||
|
description_fr = Column(Text, nullable=True)
|
||||||
|
description_de = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Contact information
|
||||||
|
email = Column(String(255), nullable=True)
|
||||||
|
phone = Column(String(50), nullable=True)
|
||||||
|
fax = Column(String(50), nullable=True)
|
||||||
|
website = Column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# Location
|
||||||
|
street = Column(String(255), nullable=True)
|
||||||
|
street_number = Column(String(50), nullable=True)
|
||||||
|
city = Column(String(100), nullable=True)
|
||||||
|
zipcode = Column(String(20), nullable=True)
|
||||||
|
country_iso = Column(String(5), default="LU")
|
||||||
|
latitude = Column(String(20), nullable=True)
|
||||||
|
longitude = Column(String(20), nullable=True)
|
||||||
|
|
||||||
|
# Categories (stored as JSON array of names)
|
||||||
|
categories = Column(JSON, default=list)
|
||||||
|
"""List of category names, e.g., ['Fashion', 'Shoes']."""
|
||||||
|
|
||||||
|
# Images
|
||||||
|
background_image_url = Column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# Social media (stored as JSON array of URLs)
|
||||||
|
social_media_links = Column(JSON, default=list)
|
||||||
|
"""List of social media URLs."""
|
||||||
|
|
||||||
|
# Opening hours (multilingual text)
|
||||||
|
opening_hours_en = Column(Text, nullable=True)
|
||||||
|
opening_hours_fr = Column(Text, nullable=True)
|
||||||
|
opening_hours_de = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Representative
|
||||||
|
representative_name = Column(String(255), nullable=True)
|
||||||
|
representative_title = Column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# Claiming status (linked to our platform)
|
||||||
|
claimed_by_vendor_id = Column(
|
||||||
|
Integer, ForeignKey("vendors.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
"""If claimed, links to our Vendor record."""
|
||||||
|
|
||||||
|
claimed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
"""When the vendor was claimed on our platform."""
|
||||||
|
|
||||||
|
# Sync metadata
|
||||||
|
last_synced_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
"""When this record was last updated from Letzshop."""
|
||||||
|
|
||||||
|
raw_data = Column(JSON, nullable=True)
|
||||||
|
"""Full raw response from Letzshop API for reference."""
|
||||||
|
|
||||||
|
# Relationship to claimed vendor
|
||||||
|
claimed_vendor = relationship("Vendor", foreign_keys=[claimed_by_vendor_id])
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_vendor_cache_city", "city"),
|
||||||
|
Index("idx_vendor_cache_claimed", "claimed_by_vendor_id"),
|
||||||
|
Index("idx_vendor_cache_active", "is_active"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<LetzshopVendorCache(id={self.id}, slug='{self.slug}', name='{self.name}')>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_claimed(self) -> bool:
|
||||||
|
"""Check if this vendor has been claimed on our platform."""
|
||||||
|
return self.claimed_by_vendor_id is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def letzshop_url(self) -> str:
|
||||||
|
"""Get the Letzshop profile URL."""
|
||||||
|
return f"https://letzshop.lu/vendors/{self.slug}"
|
||||||
|
|
||||||
|
def get_description(self, lang: str = "en") -> str | None:
|
||||||
|
"""Get description in specified language with fallback."""
|
||||||
|
descriptions = {
|
||||||
|
"en": self.description_en,
|
||||||
|
"fr": self.description_fr,
|
||||||
|
"de": self.description_de,
|
||||||
|
}
|
||||||
|
# Try requested language, then fallback order
|
||||||
|
for try_lang in [lang, "en", "fr", "de"]:
|
||||||
|
if descriptions.get(try_lang):
|
||||||
|
return descriptions[try_lang]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_opening_hours(self, lang: str = "en") -> str | None:
|
||||||
|
"""Get opening hours in specified language with fallback."""
|
||||||
|
hours = {
|
||||||
|
"en": self.opening_hours_en,
|
||||||
|
"fr": self.opening_hours_fr,
|
||||||
|
"de": self.opening_hours_de,
|
||||||
|
}
|
||||||
|
for try_lang in [lang, "en", "fr", "de"]:
|
||||||
|
if hours.get(try_lang):
|
||||||
|
return hours[try_lang]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_full_address(self) -> str | None:
|
||||||
|
"""Get formatted full address."""
|
||||||
|
parts = []
|
||||||
|
if self.street:
|
||||||
|
addr = self.street
|
||||||
|
if self.street_number:
|
||||||
|
addr += f" {self.street_number}"
|
||||||
|
parts.append(addr)
|
||||||
|
if self.zipcode or self.city:
|
||||||
|
parts.append(f"{self.zipcode or ''} {self.city or ''}".strip())
|
||||||
|
return ", ".join(parts) if parts else None
|
||||||
|
|
||||||
|
|
||||||
class LetzshopHistoricalImportJob(Base, TimestampMixin):
|
class LetzshopHistoricalImportJob(Base, TimestampMixin):
|
||||||
"""
|
"""
|
||||||
Track progress of historical order imports from Letzshop.
|
Track progress of historical order imports from Letzshop.
|
||||||
|
|||||||
@@ -507,3 +507,120 @@ class LetzshopHistoricalImportStartResponse(BaseModel):
|
|||||||
job_id: int
|
job_id: int
|
||||||
status: str = "pending"
|
status: str = "pending"
|
||||||
message: str = "Historical import job started"
|
message: str = "Historical import job started"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Vendor Directory Schemas (Letzshop Marketplace Cache)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class LetzshopCachedVendorItem(BaseModel):
|
||||||
|
"""Schema for a cached Letzshop vendor in list view."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
letzshop_id: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
company_name: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
website: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
categories: list[str] = []
|
||||||
|
is_active: bool = True
|
||||||
|
is_claimed: bool = False
|
||||||
|
claimed_by_vendor_id: int | None = None
|
||||||
|
last_synced_at: datetime | None = None
|
||||||
|
letzshop_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class LetzshopCachedVendorDetail(BaseModel):
|
||||||
|
"""Schema for detailed cached Letzshop vendor."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
letzshop_id: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
company_name: str | None = None
|
||||||
|
description_en: str | None = None
|
||||||
|
description_fr: str | None = None
|
||||||
|
description_de: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
fax: str | None = None
|
||||||
|
website: str | None = None
|
||||||
|
street: str | None = None
|
||||||
|
street_number: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
zipcode: str | None = None
|
||||||
|
country_iso: str | None = None
|
||||||
|
latitude: str | None = None
|
||||||
|
longitude: str | None = None
|
||||||
|
categories: list[str] = []
|
||||||
|
background_image_url: str | None = None
|
||||||
|
social_media_links: list[str] = []
|
||||||
|
opening_hours_en: str | None = None
|
||||||
|
opening_hours_fr: str | None = None
|
||||||
|
opening_hours_de: str | None = None
|
||||||
|
representative_name: str | None = None
|
||||||
|
representative_title: str | None = None
|
||||||
|
is_active: bool = True
|
||||||
|
is_claimed: bool = False
|
||||||
|
claimed_by_vendor_id: int | None = None
|
||||||
|
claimed_at: datetime | None = None
|
||||||
|
last_synced_at: datetime | None = None
|
||||||
|
letzshop_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class LetzshopVendorDirectoryStats(BaseModel):
|
||||||
|
"""Schema for vendor directory cache statistics."""
|
||||||
|
|
||||||
|
total_vendors: int = 0
|
||||||
|
active_vendors: int = 0
|
||||||
|
claimed_vendors: int = 0
|
||||||
|
unclaimed_vendors: int = 0
|
||||||
|
unique_cities: int = 0
|
||||||
|
last_synced_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LetzshopVendorDirectoryStatsResponse(BaseModel):
|
||||||
|
"""Response schema for vendor directory stats endpoint."""
|
||||||
|
|
||||||
|
success: bool = True
|
||||||
|
stats: LetzshopVendorDirectoryStats
|
||||||
|
|
||||||
|
|
||||||
|
class LetzshopCachedVendorListResponse(BaseModel):
|
||||||
|
"""Response schema for vendor directory list endpoint."""
|
||||||
|
|
||||||
|
success: bool = True
|
||||||
|
vendors: list[LetzshopCachedVendorItem]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
limit: int
|
||||||
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
|
class LetzshopCachedVendorDetailResponse(BaseModel):
|
||||||
|
"""Response schema for vendor directory detail endpoint."""
|
||||||
|
|
||||||
|
success: bool = True
|
||||||
|
vendor: LetzshopCachedVendorDetail
|
||||||
|
|
||||||
|
|
||||||
|
class LetzshopVendorDirectorySyncResponse(BaseModel):
|
||||||
|
"""Response schema for vendor directory sync trigger."""
|
||||||
|
|
||||||
|
success: bool = True
|
||||||
|
message: str
|
||||||
|
task_id: str | None = None
|
||||||
|
mode: str = "celery"
|
||||||
|
|
||||||
|
|
||||||
|
class LetzshopCreateVendorFromCacheResponse(BaseModel):
|
||||||
|
"""Response schema for creating vendor from Letzshop cache."""
|
||||||
|
|
||||||
|
success: bool = True
|
||||||
|
message: str
|
||||||
|
vendor: dict[str, Any] | None = None
|
||||||
|
letzshop_vendor_slug: str
|
||||||
|
|||||||
204
static/admin/js/letzshop-vendor-directory.js
Normal file
204
static/admin/js/letzshop-vendor-directory.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
// static/admin/js/letzshop-vendor-directory.js
|
||||||
|
/**
|
||||||
|
* Admin Letzshop Vendor Directory page logic
|
||||||
|
* Browse and import vendors from Letzshop marketplace
|
||||||
|
*/
|
||||||
|
|
||||||
|
const letzshopVendorDirectoryLog = window.LogConfig.loggers.letzshopVendorDirectory ||
|
||||||
|
window.LogConfig.createLogger('letzshopVendorDirectory', false);
|
||||||
|
|
||||||
|
letzshopVendorDirectoryLog.info('Loading...');
|
||||||
|
|
||||||
|
function letzshopVendorDirectory() {
|
||||||
|
letzshopVendorDirectoryLog.info('letzshopVendorDirectory() called');
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Inherit base layout state
|
||||||
|
...data(),
|
||||||
|
|
||||||
|
// Set page identifier for sidebar highlighting
|
||||||
|
currentPage: 'letzshop-vendor-directory',
|
||||||
|
|
||||||
|
// Data
|
||||||
|
vendors: [],
|
||||||
|
stats: {},
|
||||||
|
companies: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
hasMore: false,
|
||||||
|
|
||||||
|
// State
|
||||||
|
loading: true,
|
||||||
|
syncing: false,
|
||||||
|
creating: false,
|
||||||
|
error: '',
|
||||||
|
successMessage: '',
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
filters: {
|
||||||
|
search: '',
|
||||||
|
city: '',
|
||||||
|
category: '',
|
||||||
|
only_unclaimed: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
showDetailModal: false,
|
||||||
|
showCreateModal: false,
|
||||||
|
selectedVendor: null,
|
||||||
|
createVendorData: {
|
||||||
|
slug: '',
|
||||||
|
name: '',
|
||||||
|
company_id: '',
|
||||||
|
},
|
||||||
|
createError: '',
|
||||||
|
|
||||||
|
// Init
|
||||||
|
async init() {
|
||||||
|
// Guard against multiple initialization
|
||||||
|
if (window._letzshopVendorDirectoryInitialized) return;
|
||||||
|
window._letzshopVendorDirectoryInitialized = true;
|
||||||
|
|
||||||
|
letzshopVendorDirectoryLog.info('init() called');
|
||||||
|
await Promise.all([
|
||||||
|
this.loadStats(),
|
||||||
|
this.loadVendors(),
|
||||||
|
this.loadCompanies(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
// API calls
|
||||||
|
async loadStats() {
|
||||||
|
try {
|
||||||
|
const data = await apiClient.get('/admin/letzshop/vendor-directory/stats');
|
||||||
|
if (data.success) {
|
||||||
|
this.stats = data.stats;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
letzshopVendorDirectoryLog.error('Failed to load stats:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadVendors() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: this.page,
|
||||||
|
limit: this.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.filters.search) params.append('search', this.filters.search);
|
||||||
|
if (this.filters.city) params.append('city', this.filters.city);
|
||||||
|
if (this.filters.category) params.append('category', this.filters.category);
|
||||||
|
if (this.filters.only_unclaimed) params.append('only_unclaimed', 'true');
|
||||||
|
|
||||||
|
const data = await apiClient.get(`/admin/letzshop/vendor-directory/vendors?${params}`);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.vendors = data.vendors;
|
||||||
|
this.total = data.total;
|
||||||
|
this.hasMore = data.has_more;
|
||||||
|
} else {
|
||||||
|
this.error = data.detail || 'Failed to load vendors';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = 'Failed to load vendors';
|
||||||
|
letzshopVendorDirectoryLog.error('Failed to load vendors:', e);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadCompanies() {
|
||||||
|
try {
|
||||||
|
const data = await apiClient.get('/admin/companies?limit=100');
|
||||||
|
if (data.companies) {
|
||||||
|
this.companies = data.companies;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
letzshopVendorDirectoryLog.error('Failed to load companies:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async triggerSync() {
|
||||||
|
this.syncing = true;
|
||||||
|
this.error = '';
|
||||||
|
this.successMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiClient.post('/admin/letzshop/vendor-directory/sync');
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.successMessage = data.message + (data.mode === 'celery' ? ` (Task ID: ${data.task_id})` : '');
|
||||||
|
// Reload data after a delay to allow sync to complete
|
||||||
|
setTimeout(() => {
|
||||||
|
this.loadStats();
|
||||||
|
this.loadVendors();
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
this.error = data.detail || 'Failed to trigger sync';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = 'Failed to trigger sync';
|
||||||
|
letzshopVendorDirectoryLog.error('Failed to trigger sync:', e);
|
||||||
|
} finally {
|
||||||
|
this.syncing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createVendor() {
|
||||||
|
if (!this.createVendorData.company_id || !this.createVendorData.slug) return;
|
||||||
|
|
||||||
|
this.creating = true;
|
||||||
|
this.createError = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiClient.post(
|
||||||
|
`/admin/letzshop/vendor-directory/vendors/${this.createVendorData.slug}/create-vendor?company_id=${this.createVendorData.company_id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showCreateModal = false;
|
||||||
|
this.successMessage = data.message;
|
||||||
|
this.loadVendors();
|
||||||
|
this.loadStats();
|
||||||
|
} else {
|
||||||
|
this.createError = data.detail || 'Failed to create vendor';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.createError = 'Failed to create vendor';
|
||||||
|
letzshopVendorDirectoryLog.error('Failed to create vendor:', e);
|
||||||
|
} finally {
|
||||||
|
this.creating = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Modal handlers
|
||||||
|
showVendorDetail(vendor) {
|
||||||
|
this.selectedVendor = vendor;
|
||||||
|
this.showDetailModal = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
openCreateVendorModal(vendor) {
|
||||||
|
this.createVendorData = {
|
||||||
|
slug: vendor.slug,
|
||||||
|
name: vendor.name,
|
||||||
|
company_id: '',
|
||||||
|
};
|
||||||
|
this.createError = '';
|
||||||
|
this.showCreateModal = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
letzshopVendorDirectoryLog.info('Loaded');
|
||||||
Reference in New Issue
Block a user