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:
2025-12-13 13:33:03 +01:00
parent 3316894c27
commit c2f42c2913
14 changed files with 542 additions and 44 deletions

View File

@@ -19,6 +19,8 @@ from models.schema.marketplace_import_job import (
AdminMarketplaceImportJobListResponse,
AdminMarketplaceImportJobRequest,
AdminMarketplaceImportJobResponse,
MarketplaceImportErrorListResponse,
MarketplaceImportErrorResponse,
MarketplaceImportJobRequest,
MarketplaceImportJobResponse,
)
@@ -128,3 +130,36 @@ def get_marketplace_import_job(
"""Get a single marketplace import job by ID (Admin only)."""
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)
@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,
)

View File

@@ -8,7 +8,7 @@ from app.exceptions import (
ImportJobNotOwnedException,
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.vendor import Vendor
from models.schema.marketplace_import_job import (
@@ -271,5 +271,50 @@ class MarketplaceImportJobService:
raise ImportJobNotFoundException(job_id)
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()

View File

@@ -73,6 +73,7 @@ async def process_marketplace_import(
batch_size=batch_size,
db=db,
language=language, # Pass language for translations
import_job_id=job_id, # Pass job ID for error tracking
)
# Update job with results

View File

@@ -221,34 +221,93 @@
</div>
</div>
<!-- Translations Card -->
<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">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Translations
</h3>
<div class="space-y-6">
<template x-for="(trans, lang) in (product?.translations || {})" :key="lang">
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 last:border-b-0 last:pb-0">
<div class="flex items-center gap-2 mb-2">
<span class="px-2 py-0.5 text-xs font-semibold uppercase bg-gray-100 dark:bg-gray-700 rounded" x-text="lang"></span>
<!-- 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" x-data="{ activeTab: Object.keys(product?.translations || {})[0] || '' }">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
Translations
</h3>
<span class="text-sm text-gray-500 dark:text-gray-400">
<span x-text="Object.keys(product?.translations || {}).length"></span> language(s)
</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 class="space-y-2">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400">Title</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="trans?.title || '-'">-</p>
<!-- Short Description -->
<div x-show="trans?.short_description" 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">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 x-show="trans?.short_description">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400">Short Description</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="trans?.short_description">-</p>
</div>
<div x-show="trans?.description">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400">Description</p>
<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-sm text-gray-700 dark:text-gray-300" x-text="trans?.short_description"></p>
</div>
<!-- Description -->
<div x-show="trans?.description" 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">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 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>
</template>
</div>
</div>
</template>
</div>
<!-- Timestamps -->

View File

@@ -625,11 +625,64 @@
</div>
</div>
{# Error Details #}
<div x-show="{{ job_var }}?.error_details?.length > 0">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Error Details</p>
<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">
<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>
{# Detailed Import Errors #}
<div x-show="{{ job_var }}?.error_count > 0">
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Import Errors</p>
<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>

View File

@@ -18,6 +18,7 @@ import requests
from sqlalchemy import literal
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_translation import MarketplaceProductTranslation
@@ -280,6 +281,7 @@ class CSVProcessor:
batch_size: int,
db: Session,
language: str = "en",
import_job_id: int | None = None,
) -> dict[str, Any]:
"""
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
db: Database session
language: Language code for translations (default: 'en')
import_job_id: ID of the import job for error tracking (optional)
Returns:
Dictionary with processing results
@@ -315,6 +318,8 @@ class CSVProcessor:
# Process in batches
for i in range(0, len(df), 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_df,
marketplace,
@@ -323,6 +328,8 @@ class CSVProcessor:
i // batch_size + 1,
language=language,
source_file=source_file,
import_job_id=import_job_id,
base_row_num=base_row_num,
)
imported += batch_result["imported"]
@@ -341,6 +348,39 @@ class CSVProcessor:
"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(
self,
batch_df: pd.DataFrame,
@@ -350,6 +390,8 @@ class CSVProcessor:
batch_num: int,
language: str = "en",
source_file: str | None = None,
import_job_id: int | None = None,
base_row_num: int = 2,
) -> dict[str, int]:
"""Process a batch of CSV rows with marketplace information."""
imported = 0
@@ -361,10 +403,13 @@ class CSVProcessor:
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:
# 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
translation_data = self._extract_translation_data(product_data)
@@ -373,17 +418,40 @@ class CSVProcessor:
product_data["marketplace"] = marketplace
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
if not product_data.get("marketplace_product_id"):
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
continue
# Title is now required in translation_data
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
continue
@@ -448,7 +516,17 @@ class CSVProcessor:
)
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
continue