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

@@ -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')

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

View File

@@ -20,7 +20,7 @@ from .company import Company
from .content_page import ContentPage
from .customer import Customer, CustomerAddress
from .inventory import Inventory
from .marketplace_import_job import MarketplaceImportJob
from .marketplace_import_job import MarketplaceImportError, MarketplaceImportJob
from .marketplace_product import (
DigitalDeliveryMethod,
MarketplaceProduct,
@@ -83,6 +83,7 @@ __all__ = [
"ProductTranslation",
# Import
"MarketplaceImportJob",
"MarketplaceImportError",
# Inventory
"Inventory",
# Orders

View File

@@ -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 app.core.database import Base
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):
__tablename__ = "marketplace_import_jobs"
@@ -37,6 +86,12 @@ class MarketplaceImportJob(Base, TimestampMixin):
# Relationships
vendor = relationship("Vendor", back_populates="marketplace_import_jobs")
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
__table_args__ = (

View File

@@ -81,6 +81,28 @@ class AdminMarketplaceImportJobRequest(BaseModel):
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):
"""Response schema for marketplace import job."""

View File

@@ -54,6 +54,12 @@ function adminImports() {
showJobModal: false,
selectedJob: null,
// Job errors state
jobErrors: [],
jobErrorsTotal: 0,
jobErrorsPage: 1,
loadingErrors: false,
// Auto-refresh for active jobs
autoRefreshInterval: null,
@@ -275,6 +281,58 @@ function adminImports() {
closeJobModal() {
this.showJobModal = false;
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;
}
},
/**

View File

@@ -176,6 +176,53 @@ function adminMarketplaceProductDetail() {
} catch (e) {
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');
}
}
};
}

View File

@@ -189,7 +189,7 @@ class TestAdminLetzshopCredentialsAPI:
class TestAdminLetzshopConnectionAPI:
"""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(
self, mock_post, client, admin_headers, test_vendor
):
@@ -217,7 +217,7 @@ class TestAdminLetzshopConnectionAPI:
data = response.json()
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(
self, mock_post, client, admin_headers
):
@@ -287,7 +287,7 @@ class TestAdminLetzshopOrdersAPI:
assert data["total"] == 1
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(
self, mock_post, client, admin_headers, test_vendor
):

View File

@@ -164,7 +164,7 @@ class TestVendorLetzshopConnectionAPI:
assert data["success"] is False
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(
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["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(
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
@patch("app.services.letzshop.client.requests.Session.post")
@patch("app.services.letzshop.client_service.requests.Session.post")
def test_import_orders_success(
self,
mock_post,
@@ -384,7 +384,7 @@ class TestVendorLetzshopOrdersAPI:
class TestVendorLetzshopFulfillmentAPI:
"""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(
self,
mock_post,
@@ -438,7 +438,7 @@ class TestVendorLetzshopFulfillmentAPI:
data = response.json()
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(
self,
mock_post,