feat: add import error tracking and translation tabs
Import Error Tracking:
- Add MarketplaceImportError model to store detailed error information
- Store row number, identifier, error type, message, and row data for each error
- Add API endpoint GET /admin/marketplace-import-jobs/{job_id}/errors
- Add UI to view and browse import errors in job details modal
- Support pagination and error type filtering
Translation Tabs:
- Replace flat translation list with tabbed interface on product detail page
- Add language tabs with full language names
- Add copy-to-clipboard functionality for translation content
- Improved UX with better visual separation of translations
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
"""add marketplace import errors table
|
||||||
|
|
||||||
|
Revision ID: 91d02647efae
|
||||||
|
Revises: 987b4ecfa503
|
||||||
|
Create Date: 2025-12-13 13:13:46.969503
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '91d02647efae'
|
||||||
|
down_revision: Union[str, None] = '987b4ecfa503'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create marketplace_import_errors table to store detailed import error information
|
||||||
|
op.create_table('marketplace_import_errors',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('import_job_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('row_number', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('identifier', sa.String(), nullable=True),
|
||||||
|
sa.Column('error_type', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('error_message', sa.Text(), nullable=False),
|
||||||
|
sa.Column('row_data', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['import_job_id'], ['marketplace_import_jobs.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_import_error_job_id', 'marketplace_import_errors', ['import_job_id'], unique=False)
|
||||||
|
op.create_index('idx_import_error_type', 'marketplace_import_errors', ['error_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_marketplace_import_errors_id'), 'marketplace_import_errors', ['id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f('ix_marketplace_import_errors_id'), table_name='marketplace_import_errors')
|
||||||
|
op.drop_index('idx_import_error_type', table_name='marketplace_import_errors')
|
||||||
|
op.drop_index('idx_import_error_job_id', table_name='marketplace_import_errors')
|
||||||
|
op.drop_table('marketplace_import_errors')
|
||||||
@@ -19,6 +19,8 @@ from models.schema.marketplace_import_job import (
|
|||||||
AdminMarketplaceImportJobListResponse,
|
AdminMarketplaceImportJobListResponse,
|
||||||
AdminMarketplaceImportJobRequest,
|
AdminMarketplaceImportJobRequest,
|
||||||
AdminMarketplaceImportJobResponse,
|
AdminMarketplaceImportJobResponse,
|
||||||
|
MarketplaceImportErrorListResponse,
|
||||||
|
MarketplaceImportErrorResponse,
|
||||||
MarketplaceImportJobRequest,
|
MarketplaceImportJobRequest,
|
||||||
MarketplaceImportJobResponse,
|
MarketplaceImportJobResponse,
|
||||||
)
|
)
|
||||||
@@ -128,3 +130,36 @@ def get_marketplace_import_job(
|
|||||||
"""Get a single marketplace import job by ID (Admin only)."""
|
"""Get a single marketplace import job by ID (Admin only)."""
|
||||||
job = marketplace_import_job_service.get_import_job_by_id_admin(db, job_id)
|
job = marketplace_import_job_service.get_import_job_by_id_admin(db, job_id)
|
||||||
return marketplace_import_job_service.convert_to_admin_response_model(job)
|
return marketplace_import_job_service.convert_to_admin_response_model(job)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{job_id}/errors", response_model=MarketplaceImportErrorListResponse)
|
||||||
|
def get_import_job_errors(
|
||||||
|
job_id: int,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
error_type: str | None = Query(None, description="Filter by error type"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""Get import errors for a specific job (Admin only).
|
||||||
|
|
||||||
|
Returns detailed error information including row number, identifier,
|
||||||
|
error type, error message, and raw row data for review.
|
||||||
|
"""
|
||||||
|
# Verify job exists
|
||||||
|
marketplace_import_job_service.get_import_job_by_id_admin(db, job_id)
|
||||||
|
|
||||||
|
# Get errors from service
|
||||||
|
errors, total = marketplace_import_job_service.get_import_job_errors(
|
||||||
|
db=db,
|
||||||
|
job_id=job_id,
|
||||||
|
error_type=error_type,
|
||||||
|
page=page,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
return MarketplaceImportErrorListResponse(
|
||||||
|
errors=[MarketplaceImportErrorResponse.model_validate(e) for e in errors],
|
||||||
|
total=total,
|
||||||
|
import_job_id=job_id,
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from app.exceptions import (
|
|||||||
ImportJobNotOwnedException,
|
ImportJobNotOwnedException,
|
||||||
ValidationException,
|
ValidationException,
|
||||||
)
|
)
|
||||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
from models.database.marketplace_import_job import MarketplaceImportError, MarketplaceImportJob
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
from models.database.vendor import Vendor
|
from models.database.vendor import Vendor
|
||||||
from models.schema.marketplace_import_job import (
|
from models.schema.marketplace_import_job import (
|
||||||
@@ -271,5 +271,50 @@ class MarketplaceImportJobService:
|
|||||||
raise ImportJobNotFoundException(job_id)
|
raise ImportJobNotFoundException(job_id)
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
def get_import_job_errors(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
job_id: int,
|
||||||
|
error_type: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> tuple[list[MarketplaceImportError], int]:
|
||||||
|
"""
|
||||||
|
Get import errors for a specific job with pagination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
job_id: Import job ID
|
||||||
|
error_type: Optional filter by error type
|
||||||
|
page: Page number (1-indexed)
|
||||||
|
limit: Number of items per page
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (list of errors, total count)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = db.query(MarketplaceImportError).filter(
|
||||||
|
MarketplaceImportError.import_job_id == job_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if error_type:
|
||||||
|
query = query.filter(MarketplaceImportError.error_type == error_type)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
errors = (
|
||||||
|
query.order_by(MarketplaceImportError.row_number)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors, total
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting import job errors for job {job_id}: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve import errors")
|
||||||
|
|
||||||
|
|
||||||
marketplace_import_job_service = MarketplaceImportJobService()
|
marketplace_import_job_service = MarketplaceImportJobService()
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ async def process_marketplace_import(
|
|||||||
batch_size=batch_size,
|
batch_size=batch_size,
|
||||||
db=db,
|
db=db,
|
||||||
language=language, # Pass language for translations
|
language=language, # Pass language for translations
|
||||||
|
import_job_id=job_id, # Pass job ID for error tracking
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update job with results
|
# Update job with results
|
||||||
|
|||||||
@@ -221,34 +221,93 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Translations Card -->
|
<!-- Translations Card with Tabs -->
|
||||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="product?.translations && Object.keys(product.translations).length > 0">
|
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="product?.translations && Object.keys(product.translations).length > 0" x-data="{ activeTab: Object.keys(product?.translations || {})[0] || '' }">
|
||||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
<div class="flex items-center justify-between mb-4">
|
||||||
Translations
|
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||||
</h3>
|
Translations
|
||||||
<div class="space-y-6">
|
</h3>
|
||||||
<template x-for="(trans, lang) in (product?.translations || {})" :key="lang">
|
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 last:border-b-0 last:pb-0">
|
<span x-text="Object.keys(product?.translations || {}).length"></span> language(s)
|
||||||
<div class="flex items-center gap-2 mb-2">
|
</span>
|
||||||
<span class="px-2 py-0.5 text-xs font-semibold uppercase bg-gray-100 dark:bg-gray-700 rounded" x-text="lang"></span>
|
</div>
|
||||||
|
|
||||||
|
<!-- Language Tabs -->
|
||||||
|
<div class="border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||||
|
<nav class="-mb-px flex space-x-4 overflow-x-auto" aria-label="Translation tabs">
|
||||||
|
<template x-for="(trans, lang) in (product?.translations || {})" :key="lang">
|
||||||
|
<button
|
||||||
|
@click="activeTab = lang"
|
||||||
|
:class="{
|
||||||
|
'border-purple-500 text-purple-600 dark:text-purple-400': activeTab === lang,
|
||||||
|
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300': activeTab !== lang
|
||||||
|
}"
|
||||||
|
class="whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<span class="uppercase" x-text="lang"></span>
|
||||||
|
<span class="ml-1 text-xs text-gray-400" x-text="getLanguageName(lang)"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<template x-for="(trans, lang) in (product?.translations || {})" :key="'content-' + lang">
|
||||||
|
<div x-show="activeTab === lang" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Title</p>
|
||||||
|
<button
|
||||||
|
@click="copyToClipboard(trans?.title)"
|
||||||
|
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('clipboard', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-base font-medium text-gray-900 dark:text-gray-100" x-text="trans?.title || 'No title'"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
|
||||||
<div>
|
<!-- Short Description -->
|
||||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400">Title</p>
|
<div x-show="trans?.short_description" class="bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="trans?.title || '-'">-</p>
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Short Description</p>
|
||||||
|
<button
|
||||||
|
@click="copyToClipboard(trans?.short_description)"
|
||||||
|
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('clipboard', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="trans?.short_description">
|
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="trans?.short_description"></p>
|
||||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400">Short Description</p>
|
</div>
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="trans?.short_description">-</p>
|
|
||||||
</div>
|
<!-- Description -->
|
||||||
<div x-show="trans?.description">
|
<div x-show="trans?.description" class="bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg">
|
||||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400">Description</p>
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none" x-html="trans?.description || '-'"></div>
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Description</p>
|
||||||
|
<button
|
||||||
|
@click="copyToClipboard(trans?.description)"
|
||||||
|
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('clipboard', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none max-h-96 overflow-y-auto" x-html="trans?.description || 'No description'"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state if no content -->
|
||||||
|
<div x-show="!trans?.title && !trans?.short_description && !trans?.description" class="text-center py-8">
|
||||||
|
<span x-html="$icon('document-text', 'w-12 h-12 mx-auto text-gray-300 dark:text-gray-600')"></span>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No translation content for this language</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Timestamps -->
|
<!-- Timestamps -->
|
||||||
|
|||||||
@@ -625,11 +625,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Error Details #}
|
{# Detailed Import Errors #}
|
||||||
<div x-show="{{ job_var }}?.error_details?.length > 0">
|
<div x-show="{{ job_var }}?.error_count > 0">
|
||||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Error Details</p>
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg max-h-48 overflow-y-auto">
|
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Import Errors</p>
|
||||||
<pre class="text-xs text-red-700 dark:text-red-300 whitespace-pre-wrap font-mono" x-text="JSON.stringify({{ job_var }}?.error_details, null, 2)"></pre>
|
<button
|
||||||
|
x-show="!jobErrors || jobErrors.length === 0"
|
||||||
|
@click="loadJobErrors({{ job_var }}?.id)"
|
||||||
|
:disabled="loadingErrors"
|
||||||
|
class="px-3 py-1 text-xs font-medium text-purple-600 dark:text-purple-400 border border-purple-300 dark:border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span x-show="loadingErrors" x-html="$icon('spinner', 'w-3 h-3 mr-1 inline')"></span>
|
||||||
|
<span x-text="loadingErrors ? 'Loading...' : 'View Errors'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Errors List #}
|
||||||
|
<div x-show="jobErrors && jobErrors.length > 0" class="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
<template x-for="error in jobErrors" :key="error.id">
|
||||||
|
<div class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-300 rounded" x-text="error.error_type"></span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">Row <span x-text="error.row_number"></span></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-red-700 dark:text-red-300" x-text="error.error_message"></p>
|
||||||
|
<p x-show="error.identifier" class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
ID: <span class="font-mono" x-text="error.identifier"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
x-show="error.row_data"
|
||||||
|
@click="error._expanded = !error._expanded"
|
||||||
|
class="ml-2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
:title="error._expanded ? 'Hide row data' : 'Show row data'"
|
||||||
|
>
|
||||||
|
<span x-html="$icon(error._expanded ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{# Expandable row data #}
|
||||||
|
<div x-show="error._expanded && error.row_data" x-collapse class="mt-2 pt-2 border-t border-red-200 dark:border-red-700">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Row Data:</p>
|
||||||
|
<pre class="text-xs text-gray-600 dark:text-gray-300 font-mono whitespace-pre-wrap bg-white dark:bg-gray-800 p-2 rounded" x-text="JSON.stringify(error.row_data, null, 2)"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Pagination for errors #}
|
||||||
|
<div x-show="jobErrorsTotal > jobErrors?.length" class="mt-3 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>Showing <span x-text="jobErrors?.length"></span> of <span x-text="jobErrorsTotal"></span> errors</span>
|
||||||
|
<button
|
||||||
|
@click="loadMoreJobErrors({{ job_var }}?.id)"
|
||||||
|
:disabled="loadingErrors"
|
||||||
|
class="text-purple-600 dark:text-purple-400 hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import requests
|
|||||||
from sqlalchemy import literal
|
from sqlalchemy import literal
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from models.database.marketplace_import_job import MarketplaceImportError
|
||||||
from models.database.marketplace_product import MarketplaceProduct
|
from models.database.marketplace_product import MarketplaceProduct
|
||||||
from models.database.marketplace_product_translation import MarketplaceProductTranslation
|
from models.database.marketplace_product_translation import MarketplaceProductTranslation
|
||||||
|
|
||||||
@@ -280,6 +281,7 @@ class CSVProcessor:
|
|||||||
batch_size: int,
|
batch_size: int,
|
||||||
db: Session,
|
db: Session,
|
||||||
language: str = "en",
|
language: str = "en",
|
||||||
|
import_job_id: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Process CSV from URL with marketplace and vendor information.
|
Process CSV from URL with marketplace and vendor information.
|
||||||
@@ -291,6 +293,7 @@ class CSVProcessor:
|
|||||||
batch_size: Number of rows to process in each batch
|
batch_size: Number of rows to process in each batch
|
||||||
db: Database session
|
db: Database session
|
||||||
language: Language code for translations (default: 'en')
|
language: Language code for translations (default: 'en')
|
||||||
|
import_job_id: ID of the import job for error tracking (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with processing results
|
Dictionary with processing results
|
||||||
@@ -315,6 +318,8 @@ class CSVProcessor:
|
|||||||
# Process in batches
|
# Process in batches
|
||||||
for i in range(0, len(df), batch_size):
|
for i in range(0, len(df), batch_size):
|
||||||
batch_df = df.iloc[i : i + batch_size]
|
batch_df = df.iloc[i : i + batch_size]
|
||||||
|
# Calculate base row number for this batch (1-indexed for user-friendly display)
|
||||||
|
base_row_num = i + 2 # +2 for header row and 1-indexing
|
||||||
batch_result = await self._process_marketplace_batch(
|
batch_result = await self._process_marketplace_batch(
|
||||||
batch_df,
|
batch_df,
|
||||||
marketplace,
|
marketplace,
|
||||||
@@ -323,6 +328,8 @@ class CSVProcessor:
|
|||||||
i // batch_size + 1,
|
i // batch_size + 1,
|
||||||
language=language,
|
language=language,
|
||||||
source_file=source_file,
|
source_file=source_file,
|
||||||
|
import_job_id=import_job_id,
|
||||||
|
base_row_num=base_row_num,
|
||||||
)
|
)
|
||||||
|
|
||||||
imported += batch_result["imported"]
|
imported += batch_result["imported"]
|
||||||
@@ -341,6 +348,39 @@ class CSVProcessor:
|
|||||||
"language": language,
|
"language": language,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _create_import_error(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
import_job_id: int,
|
||||||
|
row_number: int,
|
||||||
|
error_type: str,
|
||||||
|
error_message: str,
|
||||||
|
identifier: str | None = None,
|
||||||
|
row_data: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Create an import error record in the database."""
|
||||||
|
# Limit row_data size to prevent huge JSON storage
|
||||||
|
if row_data:
|
||||||
|
# Keep only key fields for review
|
||||||
|
limited_data = {
|
||||||
|
k: v for k, v in row_data.items()
|
||||||
|
if k in [
|
||||||
|
"marketplace_product_id", "title", "gtin", "mpn", "sku",
|
||||||
|
"brand", "price", "availability", "link"
|
||||||
|
] and v is not None and str(v).strip()
|
||||||
|
}
|
||||||
|
row_data = limited_data if limited_data else None
|
||||||
|
|
||||||
|
error_record = MarketplaceImportError(
|
||||||
|
import_job_id=import_job_id,
|
||||||
|
row_number=row_number,
|
||||||
|
identifier=identifier,
|
||||||
|
error_type=error_type,
|
||||||
|
error_message=error_message,
|
||||||
|
row_data=row_data,
|
||||||
|
)
|
||||||
|
db.add(error_record)
|
||||||
|
|
||||||
async def _process_marketplace_batch(
|
async def _process_marketplace_batch(
|
||||||
self,
|
self,
|
||||||
batch_df: pd.DataFrame,
|
batch_df: pd.DataFrame,
|
||||||
@@ -350,6 +390,8 @@ class CSVProcessor:
|
|||||||
batch_num: int,
|
batch_num: int,
|
||||||
language: str = "en",
|
language: str = "en",
|
||||||
source_file: str | None = None,
|
source_file: str | None = None,
|
||||||
|
import_job_id: int | None = None,
|
||||||
|
base_row_num: int = 2,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""Process a batch of CSV rows with marketplace information."""
|
"""Process a batch of CSV rows with marketplace information."""
|
||||||
imported = 0
|
imported = 0
|
||||||
@@ -361,10 +403,13 @@ class CSVProcessor:
|
|||||||
f"{marketplace} -> {vendor_name}"
|
f"{marketplace} -> {vendor_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
for index, row in batch_df.iterrows():
|
for batch_idx, (index, row) in enumerate(batch_df.iterrows()):
|
||||||
|
row_number = base_row_num + batch_idx
|
||||||
|
row_dict = row.to_dict()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Convert row to dictionary and clean up
|
# Convert row to dictionary and clean up
|
||||||
product_data = self._clean_row_data(row.to_dict())
|
product_data = self._clean_row_data(row_dict)
|
||||||
|
|
||||||
# Extract translation fields BEFORE processing product
|
# Extract translation fields BEFORE processing product
|
||||||
translation_data = self._extract_translation_data(product_data)
|
translation_data = self._extract_translation_data(product_data)
|
||||||
@@ -373,17 +418,40 @@ class CSVProcessor:
|
|||||||
product_data["marketplace"] = marketplace
|
product_data["marketplace"] = marketplace
|
||||||
product_data["vendor_name"] = vendor_name
|
product_data["vendor_name"] = vendor_name
|
||||||
|
|
||||||
|
# Get identifier for error tracking
|
||||||
|
identifier = product_data.get("marketplace_product_id") or product_data.get("gtin") or product_data.get("mpn")
|
||||||
|
|
||||||
# Validate required fields
|
# Validate required fields
|
||||||
if not product_data.get("marketplace_product_id"):
|
if not product_data.get("marketplace_product_id"):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Row {index}: Missing marketplace_product_id, skipping"
|
f"Row {row_number}: Missing marketplace_product_id, skipping"
|
||||||
)
|
)
|
||||||
|
if import_job_id:
|
||||||
|
self._create_import_error(
|
||||||
|
db=db,
|
||||||
|
import_job_id=import_job_id,
|
||||||
|
row_number=row_number,
|
||||||
|
error_type="missing_id",
|
||||||
|
error_message="Missing marketplace_product_id - product cannot be identified",
|
||||||
|
identifier=identifier,
|
||||||
|
row_data=row_dict,
|
||||||
|
)
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Title is now required in translation_data
|
# Title is now required in translation_data
|
||||||
if not translation_data.get("title"):
|
if not translation_data.get("title"):
|
||||||
logger.warning(f"Row {index}: Missing title, skipping")
|
logger.warning(f"Row {row_number}: Missing title, skipping")
|
||||||
|
if import_job_id:
|
||||||
|
self._create_import_error(
|
||||||
|
db=db,
|
||||||
|
import_job_id=import_job_id,
|
||||||
|
row_number=row_number,
|
||||||
|
error_type="missing_title",
|
||||||
|
error_message="Missing title - product title is required",
|
||||||
|
identifier=product_data.get("marketplace_product_id"),
|
||||||
|
row_data=row_dict,
|
||||||
|
)
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -448,7 +516,17 @@ class CSVProcessor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing row: {e}")
|
logger.error(f"Error processing row {row_number}: {e}")
|
||||||
|
if import_job_id:
|
||||||
|
self._create_import_error(
|
||||||
|
db=db,
|
||||||
|
import_job_id=import_job_id,
|
||||||
|
row_number=row_number,
|
||||||
|
error_type="processing_error",
|
||||||
|
error_message=str(e),
|
||||||
|
identifier=row_dict.get("marketplace_product_id") or row_dict.get("id"),
|
||||||
|
row_data=row_dict,
|
||||||
|
)
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from .company import Company
|
|||||||
from .content_page import ContentPage
|
from .content_page import ContentPage
|
||||||
from .customer import Customer, CustomerAddress
|
from .customer import Customer, CustomerAddress
|
||||||
from .inventory import Inventory
|
from .inventory import Inventory
|
||||||
from .marketplace_import_job import MarketplaceImportJob
|
from .marketplace_import_job import MarketplaceImportError, MarketplaceImportJob
|
||||||
from .marketplace_product import (
|
from .marketplace_product import (
|
||||||
DigitalDeliveryMethod,
|
DigitalDeliveryMethod,
|
||||||
MarketplaceProduct,
|
MarketplaceProduct,
|
||||||
@@ -83,6 +83,7 @@ __all__ = [
|
|||||||
"ProductTranslation",
|
"ProductTranslation",
|
||||||
# Import
|
# Import
|
||||||
"MarketplaceImportJob",
|
"MarketplaceImportJob",
|
||||||
|
"MarketplaceImportError",
|
||||||
# Inventory
|
# Inventory
|
||||||
"Inventory",
|
"Inventory",
|
||||||
# Orders
|
# Orders
|
||||||
|
|||||||
@@ -1,10 +1,59 @@
|
|||||||
from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, String, Text
|
from sqlalchemy import JSON, Column, DateTime, ForeignKey, Index, Integer, String, Text
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
from models.database.base import TimestampMixin
|
from models.database.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class MarketplaceImportError(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Stores detailed information about individual import errors.
|
||||||
|
|
||||||
|
Each row that fails during import creates an error record with:
|
||||||
|
- Row number from the source file
|
||||||
|
- Identifier (marketplace_product_id if available)
|
||||||
|
- Error type and message
|
||||||
|
- Raw row data for review
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "marketplace_import_errors"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
import_job_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("marketplace_import_jobs.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Error location
|
||||||
|
row_number = Column(Integer, nullable=False)
|
||||||
|
|
||||||
|
# Identifier from the row (if available)
|
||||||
|
identifier = Column(String) # marketplace_product_id, gtin, mpn, etc.
|
||||||
|
|
||||||
|
# Error details
|
||||||
|
error_type = Column(String(50), nullable=False) # missing_title, missing_id, parse_error, etc.
|
||||||
|
error_message = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
# Raw row data for review (JSON)
|
||||||
|
row_data = Column(JSON)
|
||||||
|
|
||||||
|
# Relationship
|
||||||
|
import_job = relationship("MarketplaceImportJob", back_populates="errors")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_import_error_job_id", "import_job_id"),
|
||||||
|
Index("idx_import_error_type", "error_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (
|
||||||
|
f"<MarketplaceImportError(id={self.id}, job_id={self.import_job_id}, "
|
||||||
|
f"row={self.row_number}, type='{self.error_type}')>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MarketplaceImportJob(Base, TimestampMixin):
|
class MarketplaceImportJob(Base, TimestampMixin):
|
||||||
__tablename__ = "marketplace_import_jobs"
|
__tablename__ = "marketplace_import_jobs"
|
||||||
|
|
||||||
@@ -37,6 +86,12 @@ class MarketplaceImportJob(Base, TimestampMixin):
|
|||||||
# Relationships
|
# Relationships
|
||||||
vendor = relationship("Vendor", back_populates="marketplace_import_jobs")
|
vendor = relationship("Vendor", back_populates="marketplace_import_jobs")
|
||||||
user = relationship("User", foreign_keys=[user_id])
|
user = relationship("User", foreign_keys=[user_id])
|
||||||
|
errors = relationship(
|
||||||
|
"MarketplaceImportError",
|
||||||
|
back_populates="import_job",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="MarketplaceImportError.row_number",
|
||||||
|
)
|
||||||
|
|
||||||
# Indexes for performance
|
# Indexes for performance
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
@@ -81,6 +81,28 @@ class AdminMarketplaceImportJobRequest(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class MarketplaceImportErrorResponse(BaseModel):
|
||||||
|
"""Response schema for individual import error."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
row_number: int
|
||||||
|
identifier: str | None = None
|
||||||
|
error_type: str
|
||||||
|
error_message: str
|
||||||
|
row_data: dict | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MarketplaceImportErrorListResponse(BaseModel):
|
||||||
|
"""Response schema for list of import errors."""
|
||||||
|
|
||||||
|
errors: list[MarketplaceImportErrorResponse]
|
||||||
|
total: int
|
||||||
|
import_job_id: int
|
||||||
|
|
||||||
|
|
||||||
class MarketplaceImportJobResponse(BaseModel):
|
class MarketplaceImportJobResponse(BaseModel):
|
||||||
"""Response schema for marketplace import job."""
|
"""Response schema for marketplace import job."""
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ function adminImports() {
|
|||||||
showJobModal: false,
|
showJobModal: false,
|
||||||
selectedJob: null,
|
selectedJob: null,
|
||||||
|
|
||||||
|
// Job errors state
|
||||||
|
jobErrors: [],
|
||||||
|
jobErrorsTotal: 0,
|
||||||
|
jobErrorsPage: 1,
|
||||||
|
loadingErrors: false,
|
||||||
|
|
||||||
// Auto-refresh for active jobs
|
// Auto-refresh for active jobs
|
||||||
autoRefreshInterval: null,
|
autoRefreshInterval: null,
|
||||||
|
|
||||||
@@ -275,6 +281,58 @@ function adminImports() {
|
|||||||
closeJobModal() {
|
closeJobModal() {
|
||||||
this.showJobModal = false;
|
this.showJobModal = false;
|
||||||
this.selectedJob = null;
|
this.selectedJob = null;
|
||||||
|
// Clear errors state
|
||||||
|
this.jobErrors = [];
|
||||||
|
this.jobErrorsTotal = 0;
|
||||||
|
this.jobErrorsPage = 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load errors for a specific job
|
||||||
|
*/
|
||||||
|
async loadJobErrors(jobId) {
|
||||||
|
if (!jobId) return;
|
||||||
|
|
||||||
|
this.loadingErrors = true;
|
||||||
|
this.jobErrorsPage = 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/admin/marketplace-import-jobs/${jobId}/errors?page=1&limit=20`
|
||||||
|
);
|
||||||
|
this.jobErrors = response.errors || [];
|
||||||
|
this.jobErrorsTotal = response.total || 0;
|
||||||
|
adminImportsLog.debug('Loaded job errors:', this.jobErrors.length);
|
||||||
|
} catch (error) {
|
||||||
|
adminImportsLog.error('Failed to load job errors:', error);
|
||||||
|
this.error = error.message || 'Failed to load import errors';
|
||||||
|
} finally {
|
||||||
|
this.loadingErrors = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load more errors (pagination)
|
||||||
|
*/
|
||||||
|
async loadMoreJobErrors(jobId) {
|
||||||
|
if (!jobId || this.loadingErrors) return;
|
||||||
|
|
||||||
|
this.loadingErrors = true;
|
||||||
|
this.jobErrorsPage++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/admin/marketplace-import-jobs/${jobId}/errors?page=${this.jobErrorsPage}&limit=20`
|
||||||
|
);
|
||||||
|
const newErrors = response.errors || [];
|
||||||
|
this.jobErrors = [...this.jobErrors, ...newErrors];
|
||||||
|
adminImportsLog.debug('Loaded more job errors:', newErrors.length);
|
||||||
|
} catch (error) {
|
||||||
|
adminImportsLog.error('Failed to load more job errors:', error);
|
||||||
|
this.jobErrorsPage--; // Revert page on failure
|
||||||
|
} finally {
|
||||||
|
this.loadingErrors = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -176,6 +176,53 @@ function adminMarketplaceProductDetail() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return dateString;
|
return dateString;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full language name from ISO code
|
||||||
|
*/
|
||||||
|
getLanguageName(code) {
|
||||||
|
const languages = {
|
||||||
|
'en': 'English',
|
||||||
|
'de': 'German',
|
||||||
|
'fr': 'French',
|
||||||
|
'lb': 'Luxembourgish',
|
||||||
|
'es': 'Spanish',
|
||||||
|
'it': 'Italian',
|
||||||
|
'nl': 'Dutch',
|
||||||
|
'pt': 'Portuguese',
|
||||||
|
'pl': 'Polish',
|
||||||
|
'cs': 'Czech',
|
||||||
|
'da': 'Danish',
|
||||||
|
'sv': 'Swedish',
|
||||||
|
'fi': 'Finnish',
|
||||||
|
'no': 'Norwegian',
|
||||||
|
'hu': 'Hungarian',
|
||||||
|
'ro': 'Romanian',
|
||||||
|
'bg': 'Bulgarian',
|
||||||
|
'el': 'Greek',
|
||||||
|
'sk': 'Slovak',
|
||||||
|
'sl': 'Slovenian',
|
||||||
|
'hr': 'Croatian',
|
||||||
|
'lt': 'Lithuanian',
|
||||||
|
'lv': 'Latvian',
|
||||||
|
'et': 'Estonian'
|
||||||
|
};
|
||||||
|
return languages[code?.toLowerCase()] || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy text to clipboard
|
||||||
|
*/
|
||||||
|
async copyToClipboard(text) {
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
Utils.showToast('Copied to clipboard', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
adminMarketplaceProductDetailLog.error('Failed to copy to clipboard:', err);
|
||||||
|
Utils.showToast('Failed to copy to clipboard', 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ class TestAdminLetzshopCredentialsAPI:
|
|||||||
class TestAdminLetzshopConnectionAPI:
|
class TestAdminLetzshopConnectionAPI:
|
||||||
"""Test admin Letzshop connection testing endpoints."""
|
"""Test admin Letzshop connection testing endpoints."""
|
||||||
|
|
||||||
@patch("app.services.letzshop.client.requests.Session.post")
|
@patch("app.services.letzshop.client_service.requests.Session.post")
|
||||||
def test_test_vendor_connection(
|
def test_test_vendor_connection(
|
||||||
self, mock_post, client, admin_headers, test_vendor
|
self, mock_post, client, admin_headers, test_vendor
|
||||||
):
|
):
|
||||||
@@ -217,7 +217,7 @@ class TestAdminLetzshopConnectionAPI:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
|
|
||||||
@patch("app.services.letzshop.client.requests.Session.post")
|
@patch("app.services.letzshop.client_service.requests.Session.post")
|
||||||
def test_test_api_key_directly(
|
def test_test_api_key_directly(
|
||||||
self, mock_post, client, admin_headers
|
self, mock_post, client, admin_headers
|
||||||
):
|
):
|
||||||
@@ -287,7 +287,7 @@ class TestAdminLetzshopOrdersAPI:
|
|||||||
assert data["total"] == 1
|
assert data["total"] == 1
|
||||||
assert data["orders"][0]["customer_email"] == "admin-test@example.com"
|
assert data["orders"][0]["customer_email"] == "admin-test@example.com"
|
||||||
|
|
||||||
@patch("app.services.letzshop.client.requests.Session.post")
|
@patch("app.services.letzshop.client_service.requests.Session.post")
|
||||||
def test_trigger_vendor_sync(
|
def test_trigger_vendor_sync(
|
||||||
self, mock_post, client, admin_headers, test_vendor
|
self, mock_post, client, admin_headers, test_vendor
|
||||||
):
|
):
|
||||||
|
|||||||
10
tests/integration/api/v1/vendor/test_letzshop.py
vendored
10
tests/integration/api/v1/vendor/test_letzshop.py
vendored
@@ -164,7 +164,7 @@ class TestVendorLetzshopConnectionAPI:
|
|||||||
assert data["success"] is False
|
assert data["success"] is False
|
||||||
assert "not configured" in data["error_details"]
|
assert "not configured" in data["error_details"]
|
||||||
|
|
||||||
@patch("app.services.letzshop.client.requests.Session.post")
|
@patch("app.services.letzshop.client_service.requests.Session.post")
|
||||||
def test_test_connection_success(
|
def test_test_connection_success(
|
||||||
self, mock_post, client, vendor_user_headers, test_vendor_with_vendor_user
|
self, mock_post, client, vendor_user_headers, test_vendor_with_vendor_user
|
||||||
):
|
):
|
||||||
@@ -192,7 +192,7 @@ class TestVendorLetzshopConnectionAPI:
|
|||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
assert data["response_time_ms"] is not None
|
assert data["response_time_ms"] is not None
|
||||||
|
|
||||||
@patch("app.services.letzshop.client.requests.Session.post")
|
@patch("app.services.letzshop.client_service.requests.Session.post")
|
||||||
def test_test_api_key_without_saving(
|
def test_test_api_key_without_saving(
|
||||||
self, mock_post, client, vendor_user_headers, test_vendor_with_vendor_user
|
self, mock_post, client, vendor_user_headers, test_vendor_with_vendor_user
|
||||||
):
|
):
|
||||||
@@ -319,7 +319,7 @@ class TestVendorLetzshopOrdersAPI:
|
|||||||
|
|
||||||
assert response.status_code == 422 # Validation error
|
assert response.status_code == 422 # Validation error
|
||||||
|
|
||||||
@patch("app.services.letzshop.client.requests.Session.post")
|
@patch("app.services.letzshop.client_service.requests.Session.post")
|
||||||
def test_import_orders_success(
|
def test_import_orders_success(
|
||||||
self,
|
self,
|
||||||
mock_post,
|
mock_post,
|
||||||
@@ -384,7 +384,7 @@ class TestVendorLetzshopOrdersAPI:
|
|||||||
class TestVendorLetzshopFulfillmentAPI:
|
class TestVendorLetzshopFulfillmentAPI:
|
||||||
"""Test vendor Letzshop fulfillment endpoints."""
|
"""Test vendor Letzshop fulfillment endpoints."""
|
||||||
|
|
||||||
@patch("app.services.letzshop.client.requests.Session.post")
|
@patch("app.services.letzshop.client_service.requests.Session.post")
|
||||||
def test_confirm_order(
|
def test_confirm_order(
|
||||||
self,
|
self,
|
||||||
mock_post,
|
mock_post,
|
||||||
@@ -438,7 +438,7 @@ class TestVendorLetzshopFulfillmentAPI:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
|
|
||||||
@patch("app.services.letzshop.client.requests.Session.post")
|
@patch("app.services.letzshop.client_service.requests.Session.post")
|
||||||
def test_set_tracking(
|
def test_set_tracking(
|
||||||
self,
|
self,
|
||||||
mock_post,
|
mock_post,
|
||||||
|
|||||||
Reference in New Issue
Block a user