fix: correct tojson|safe usage in templates and update validator

- Remove |safe from |tojson in HTML attributes (x-data) - quotes must
  become " for browsers to parse correctly
- Update LANG-002 and LANG-003 architecture rules to document correct
  |tojson usage patterns:
  - HTML attributes: |tojson (no |safe)
  - Script blocks: |tojson|safe
- Fix validator to warn when |tojson|safe is used in x-data (breaks
  HTML attribute parsing)
- Improve code quality across services, APIs, and tests

🤖 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 22:59:51 +01:00
parent 94d268f330
commit 9920430b9e
123 changed files with 1408 additions and 840 deletions

View File

@@ -45,7 +45,6 @@ from app.exceptions import (
InsufficientVendorPermissionsException,
InvalidTokenException,
UnauthorizedVendorAccessException,
VendorAccessDeniedException,
VendorNotFoundException,
VendorOwnerOnlyException,
)
@@ -306,15 +305,15 @@ def get_current_vendor_api(
# Require vendor context in token
if not hasattr(user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
raise InvalidTokenException(
"Token missing vendor information. Please login again."
)
vendor_id = user.token_vendor_id
# Verify user still has access to this vendor
if not user.is_member_of(vendor_id):
logger.warning(
f"User {user.username} lost access to vendor_id={vendor_id}"
)
logger.warning(f"User {user.username} lost access to vendor_id={vendor_id}")
raise InsufficientPermissionsException(
"Access to vendor has been revoked. Please login again."
)
@@ -605,7 +604,9 @@ def require_vendor_permission(permission: str):
) -> User:
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
raise InvalidTokenException(
"Token missing vendor information. Please login again."
)
vendor_id = current_user.token_vendor_id
@@ -649,7 +650,9 @@ def require_vendor_owner(
"""
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
raise InvalidTokenException(
"Token missing vendor information. Please login again."
)
vendor_id = current_user.token_vendor_id
@@ -695,7 +698,9 @@ def require_any_vendor_permission(*permissions: str):
) -> User:
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
raise InvalidTokenException(
"Token missing vendor information. Please login again."
)
vendor_id = current_user.token_vendor_id
@@ -748,7 +753,9 @@ def require_all_vendor_permissions(*permissions: str):
) -> User:
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
raise InvalidTokenException(
"Token missing vendor information. Please login again."
)
vendor_id = current_user.token_vendor_id

View File

@@ -151,9 +151,7 @@ router.include_router(
)
# Include test runner endpoints
router.include_router(
tests.router, prefix="/tests", tags=["admin-tests"]
)
router.include_router(tests.router, prefix="/tests", tags=["admin-tests"])
# Export the router
__all__ = ["router"]

View File

@@ -63,7 +63,9 @@ def _convert_import_to_response(job) -> BackgroundTaskResponse:
started_at=job.started_at.isoformat() if job.started_at else None,
completed_at=job.completed_at.isoformat() if job.completed_at else None,
duration_seconds=duration,
description=f"Import from {job.marketplace}: {job.source_url[:50]}..." if len(job.source_url) > 50 else f"Import from {job.marketplace}: {job.source_url}",
description=f"Import from {job.marketplace}: {job.source_url[:50]}..."
if len(job.source_url) > 50
else f"Import from {job.marketplace}: {job.source_url}",
triggered_by=job.user.username if job.user else None,
error_message=job.error_message,
details={
@@ -108,7 +110,9 @@ def _convert_test_run_to_response(run) -> BackgroundTaskResponse:
@router.get("/tasks", response_model=list[BackgroundTaskResponse])
async def list_background_tasks(
status: str | None = Query(None, description="Filter by status"),
task_type: str | None = Query(None, description="Filter by type (import, test_run)"),
task_type: str | None = Query(
None, description="Filter by type (import, test_run)"
),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_api),
@@ -122,12 +126,16 @@ async def list_background_tasks(
# Get import jobs
if task_type is None or task_type == "import":
import_jobs = background_tasks_service.get_import_jobs(db, status=status, limit=limit)
import_jobs = background_tasks_service.get_import_jobs(
db, status=status, limit=limit
)
tasks.extend([_convert_import_to_response(job) for job in import_jobs])
# Get test runs
if task_type is None or task_type == "test_run":
test_runs = background_tasks_service.get_test_runs(db, status=status, limit=limit)
test_runs = background_tasks_service.get_test_runs(
db, status=status, limit=limit
)
tasks.extend([_convert_test_run_to_response(run) for run in test_runs])
# Sort by start time (most recent first)

View File

@@ -329,9 +329,7 @@ async def assign_violation(
"user_id": assignment.user_id,
"assigned_at": assignment.assigned_at.isoformat(),
"assigned_by": assignment.assigned_by,
"due_date": (
assignment.due_date.isoformat() if assignment.due_date else None
),
"due_date": (assignment.due_date.isoformat() if assignment.due_date else None),
"priority": assignment.priority,
}

View File

@@ -75,7 +75,7 @@ def create_company_with_owner(
owner_username=owner_user.username,
owner_email=owner_user.email,
temporary_password=temp_password or "N/A (Existing user)",
login_url=f"http://localhost:8000/admin/login",
login_url="http://localhost:8000/admin/login",
)

View File

@@ -46,9 +46,13 @@ def get_admin_dashboard(
users=UserStatsResponse(**user_stats),
vendors=VendorStatsResponse(
total=vendor_stats.get("total", vendor_stats.get("total_vendors", 0)),
verified=vendor_stats.get("verified", vendor_stats.get("verified_vendors", 0)),
verified=vendor_stats.get(
"verified", vendor_stats.get("verified_vendors", 0)
),
pending=vendor_stats.get("pending", vendor_stats.get("pending_vendors", 0)),
inactive=vendor_stats.get("inactive", vendor_stats.get("inactive_vendors", 0)),
inactive=vendor_stats.get(
"inactive", vendor_stats.get("inactive_vendors", 0)
),
),
recent_vendors=admin_service.get_recent_vendors(db, limit=5),
recent_imports=admin_service.get_recent_import_jobs(db, limit=10),
@@ -109,9 +113,13 @@ def get_platform_statistics(
users=UserStatsResponse(**user_stats),
vendors=VendorStatsResponse(
total=vendor_stats.get("total", vendor_stats.get("total_vendors", 0)),
verified=vendor_stats.get("verified", vendor_stats.get("verified_vendors", 0)),
verified=vendor_stats.get(
"verified", vendor_stats.get("verified_vendors", 0)
),
pending=vendor_stats.get("pending", vendor_stats.get("pending_vendors", 0)),
inactive=vendor_stats.get("inactive", vendor_stats.get("inactive_vendors", 0)),
inactive=vendor_stats.get(
"inactive", vendor_stats.get("inactive_vendors", 0)
),
),
products=ProductStatsResponse(**product_stats),
orders=OrderStatsBasicResponse(**order_stats),

View File

@@ -68,7 +68,9 @@ def get_credentials_service(db: Session) -> LetzshopCredentialsService:
def list_vendors_letzshop_status(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
configured_only: bool = Query(False, description="Only show vendors with Letzshop configured"),
configured_only: bool = Query(
False, description="Only show vendors with Letzshop configured"
),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
@@ -119,8 +121,9 @@ def get_vendor_credentials(
credentials = creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ResourceNotFoundException(
"LetzshopCredentials", str(vendor_id),
message=f"Letzshop credentials not configured for vendor {vendor.name}"
"LetzshopCredentials",
str(vendor_id),
message=f"Letzshop credentials not configured for vendor {vendor.name}",
)
return LetzshopCredentialsResponse(
@@ -215,8 +218,9 @@ def update_vendor_credentials(
db.commit()
except CredentialsNotFoundError:
raise ResourceNotFoundException(
"LetzshopCredentials", str(vendor_id),
message=f"Letzshop credentials not configured for vendor {vendor.name}"
"LetzshopCredentials",
str(vendor_id),
message=f"Letzshop credentials not configured for vendor {vendor.name}",
)
return LetzshopCredentialsResponse(
@@ -255,8 +259,9 @@ def delete_vendor_credentials(
deleted = creds_service.delete_credentials(vendor_id)
if not deleted:
raise ResourceNotFoundException(
"LetzshopCredentials", str(vendor_id),
message=f"Letzshop credentials not configured for vendor {vendor.name}"
"LetzshopCredentials",
str(vendor_id),
message=f"Letzshop credentials not configured for vendor {vendor.name}",
)
db.commit()
@@ -445,7 +450,9 @@ def trigger_vendor_sync(
orders_imported += 1
except Exception as e:
errors.append(f"Error processing shipment {shipment.get('id')}: {e}")
errors.append(
f"Error processing shipment {shipment.get('id')}: {e}"
)
db.commit()

View File

@@ -12,7 +12,7 @@ Provides endpoints for:
import logging
from fastapi import APIRouter, Depends, Query, Response
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
@@ -190,9 +190,10 @@ def download_log_file(
"""
from pathlib import Path
from app.core.config import settings
from fastapi.responses import FileResponse
from app.core.config import settings
# Determine log file path
log_file_path = settings.log_file
if log_file_path:

View File

@@ -149,7 +149,9 @@ class AdminProductDetail(BaseModel):
def get_products(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
search: str | None = Query(None, description="Search by title, GTIN, SKU, or brand"),
search: str | None = Query(
None, description="Search by title, GTIN, SKU, or brand"
),
marketplace: str | None = Query(None, description="Filter by marketplace"),
vendor_name: str | None = Query(None, description="Filter by vendor name"),
availability: str | None = Query(None, description="Filter by availability"),

View File

@@ -79,9 +79,7 @@ def get_setting(
setting = admin_settings_service.get_setting_by_key(db, key)
if not setting:
raise ResourceNotFoundException(
resource_type="Setting", identifier=key
)
raise ResourceNotFoundException(resource_type="Setting", identifier=key)
return AdminSettingResponse.model_validate(setting)

View File

@@ -65,7 +65,9 @@ class RunTestsRequest(BaseModel):
"""Request model for running tests"""
test_path: str = Field("tests", description="Path to tests to run")
extra_args: list[str] | None = Field(None, description="Additional pytest arguments")
extra_args: list[str] | None = Field(
None, description="Additional pytest arguments"
)
class TestDashboardStatsResponse(BaseModel):
@@ -205,6 +207,7 @@ async def get_run(
if not run:
from app.exceptions.base import ResourceNotFoundException
raise ResourceNotFoundException("TestRun", str(run_id))
return TestRunResponse(
@@ -231,7 +234,9 @@ async def get_run(
@router.get("/runs/{run_id}/results", response_model=list[TestResultResponse])
async def get_run_results(
run_id: int,
outcome: str | None = Query(None, description="Filter by outcome (passed, failed, error, skipped)"),
outcome: str | None = Query(
None, description="Filter by outcome (passed, failed, error, skipped)"
),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_api),
):

View File

@@ -99,7 +99,9 @@ def create_user(
full_name=user.full_name,
is_email_verified=user.is_email_verified,
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
vendor_memberships_count=len(user.vendor_memberships) if user.vendor_memberships else 0,
vendor_memberships_count=len(user.vendor_memberships)
if user.vendor_memberships
else 0,
)
@@ -151,7 +153,9 @@ def get_user_details(
full_name=user.full_name,
is_email_verified=user.is_email_verified,
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
vendor_memberships_count=len(user.vendor_memberships) if user.vendor_memberships else 0,
vendor_memberships_count=len(user.vendor_memberships)
if user.vendor_memberships
else 0,
)
@@ -192,7 +196,9 @@ def update_user(
full_name=user.full_name,
is_email_verified=user.is_email_verified,
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
vendor_memberships_count=len(user.vendor_memberships) if user.vendor_memberships else 0,
vendor_memberships_count=len(user.vendor_memberships)
if user.vendor_memberships
else 0,
)

View File

@@ -229,4 +229,6 @@ async def delete_vendor_theme(
# Global exception handler converts them to proper HTTP responses
result = vendor_theme_service.delete_theme(db, vendor_code)
db.commit()
return ThemeDeleteResponse(message=result.get("message", "Theme deleted successfully"))
return ThemeDeleteResponse(
message=result.get("message", "Theme deleted successfully")
)

View File

@@ -318,7 +318,9 @@ def delete_vendor(
@router.get("/{vendor_identifier}/export/letzshop")
def export_vendor_products_letzshop(
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
language: str = Query("en", description="Language for title/description (en, fr, de)"),
language: str = Query(
"en", description="Language for title/description (en, fr, de)"
),
include_inactive: bool = Query(False, description="Include inactive products"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),

View File

@@ -7,6 +7,7 @@ These endpoints handle:
- Getting current language info
- Listing available languages
"""
import logging
from fastapi import APIRouter, Request, Response

View File

@@ -39,7 +39,9 @@ def get_vendor_analytics(
period=data["period"],
start_date=data["start_date"],
imports=VendorAnalyticsImports(count=data["imports"]["count"]),
catalog=VendorAnalyticsCatalog(products_added=data["catalog"]["products_added"]),
catalog=VendorAnalyticsCatalog(
products_added=data["catalog"]["products_added"]
),
inventory=VendorAnalyticsInventory(
total_locations=data["inventory"]["total_locations"]
),

View File

@@ -25,7 +25,6 @@ from app.exceptions import InvalidCredentialsException
from app.services.auth_service import auth_service
from middleware.vendor_context import get_current_vendor
from models.database.user import User
from models.database.vendor import Vendor
from models.schema.auth import LogoutResponse, UserLogin, VendorUserResponse
router = APIRouter(prefix="/auth")
@@ -95,9 +94,7 @@ def vendor_login(
f"User {user.username} attempted login to vendor {vendor.vendor_code} "
f"but is not authorized"
)
raise InvalidCredentialsException(
"You do not have access to this vendor"
)
raise InvalidCredentialsException("You do not have access to this vendor")
else:
# No vendor context - find which vendor this user belongs to
vendor, vendor_role = auth_service.find_user_vendor(user)

View File

@@ -90,7 +90,9 @@ def get_customer_orders(
- Return order details
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return CustomerOrdersResponse(orders=[], message="Customer orders coming in Slice 5")
return CustomerOrdersResponse(
orders=[], message="Customer orders coming in Slice 5"
)
@router.put("/{customer_id}", response_model=CustomerMessageResponse)

View File

@@ -5,6 +5,7 @@ Vendor inventory management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
from fastapi import APIRouter, Depends, Query
@@ -36,7 +37,9 @@ def set_inventory(
db: Session = Depends(get_db),
):
"""Set exact inventory quantity (replaces existing)."""
result = inventory_service.set_inventory(db, current_user.token_vendor_id, inventory)
result = inventory_service.set_inventory(
db, current_user.token_vendor_id, inventory
)
db.commit()
return result
@@ -48,7 +51,9 @@ def adjust_inventory(
db: Session = Depends(get_db),
):
"""Adjust inventory (positive to add, negative to remove)."""
result = inventory_service.adjust_inventory(db, current_user.token_vendor_id, adjustment)
result = inventory_service.adjust_inventory(
db, current_user.token_vendor_id, adjustment
)
db.commit()
return result
@@ -60,7 +65,9 @@ def reserve_inventory(
db: Session = Depends(get_db),
):
"""Reserve inventory for an order."""
result = inventory_service.reserve_inventory(db, current_user.token_vendor_id, reservation)
result = inventory_service.reserve_inventory(
db, current_user.token_vendor_id, reservation
)
db.commit()
return result
@@ -72,7 +79,9 @@ def release_reservation(
db: Session = Depends(get_db),
):
"""Release reserved inventory (cancel order)."""
result = inventory_service.release_reservation(db, current_user.token_vendor_id, reservation)
result = inventory_service.release_reservation(
db, current_user.token_vendor_id, reservation
)
db.commit()
return result
@@ -84,7 +93,9 @@ def fulfill_reservation(
db: Session = Depends(get_db),
):
"""Fulfill reservation (complete order, remove from stock)."""
result = inventory_service.fulfill_reservation(db, current_user.token_vendor_id, reservation)
result = inventory_service.fulfill_reservation(
db, current_user.token_vendor_id, reservation
)
db.commit()
return result
@@ -96,7 +107,9 @@ def get_product_inventory(
db: Session = Depends(get_db),
):
"""Get inventory summary for a product."""
return inventory_service.get_product_inventory(db, current_user.token_vendor_id, product_id)
return inventory_service.get_product_inventory(
db, current_user.token_vendor_id, product_id
)
@router.get("/inventory", response_model=InventoryListResponse)

View File

@@ -323,9 +323,7 @@ def get_order(
order_service = get_order_service(db)
try:
order = order_service.get_order_or_raise(
current_user.token_vendor_id, order_id
)
order = order_service.get_order_or_raise(current_user.token_vendor_id, order_id)
except OrderNotFoundError:
raise ResourceNotFoundException("LetzshopOrder", str(order_id))
@@ -396,7 +394,9 @@ def import_orders(
orders_imported += 1
except Exception as e:
errors.append(f"Error processing shipment {shipment.get('id')}: {e}")
errors.append(
f"Error processing shipment {shipment.get('id')}: {e}"
)
db.commit()
creds_service.update_sync_status(
@@ -475,9 +475,7 @@ def confirm_order(
return FulfillmentOperationResponse(
success=True,
message=f"Confirmed {len(inventory_unit_ids)} inventory units",
confirmed_units=[
u.get("id") for u in result.get("inventoryUnits", [])
],
confirmed_units=[u.get("id") for u in result.get("inventoryUnits", [])],
)
except LetzshopClientError as e:
@@ -699,7 +697,9 @@ def list_fulfillment_queue(
@router.get("/export")
def export_products_letzshop(
language: str = Query("en", description="Language for title/description (en, fr, de)"),
language: str = Query(
"en", description="Language for title/description (en, fr, de)"
),
include_inactive: bool = Query(False, description="Include inactive products"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),

View File

@@ -181,18 +181,20 @@ def toggle_product_active(
db: Session = Depends(get_db),
):
"""Toggle product active status."""
product = product_service.get_product(
db, current_user.token_vendor_id, product_id
)
product = product_service.get_product(db, current_user.token_vendor_id, product_id)
product.is_active = not product.is_active
db.commit()
db.refresh(product)
status = "activated" if product.is_active else "deactivated"
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
logger.info(
f"Product {product_id} {status} for vendor {current_user.token_vendor_code}"
)
return ProductToggleResponse(message=f"Product {status}", is_active=product.is_active)
return ProductToggleResponse(
message=f"Product {status}", is_active=product.is_active
)
@router.put("/{product_id}/toggle-featured", response_model=ProductToggleResponse)
@@ -202,15 +204,17 @@ def toggle_product_featured(
db: Session = Depends(get_db),
):
"""Toggle product featured status."""
product = product_service.get_product(
db, current_user.token_vendor_id, product_id
)
product = product_service.get_product(db, current_user.token_vendor_id, product_id)
product.is_featured = not product.is_featured
db.commit()
db.refresh(product)
status = "featured" if product.is_featured else "unfeatured"
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
logger.info(
f"Product {product_id} {status} for vendor {current_user.token_vendor_code}"
)
return ProductToggleResponse(message=f"Product {status}", is_featured=product.is_featured)
return ProductToggleResponse(
message=f"Product {status}", is_featured=product.is_featured
)

View File

@@ -54,9 +54,15 @@ class DatabaseLogHandler(logging.Handler):
stack_trace = None
if record.exc_info:
exception_type = record.exc_info[0].__name__ if record.exc_info[0] else None
exception_message = str(record.exc_info[1]) if record.exc_info[1] else None
stack_trace = "".join(traceback.format_exception(*record.exc_info))
exception_type = (
record.exc_info[0].__name__ if record.exc_info[0] else None
)
exception_message = (
str(record.exc_info[1]) if record.exc_info[1] else None
)
stack_trace = "".join(
traceback.format_exception(*record.exc_info)
)
# Extract context from record (if middleware added it)
user_id = getattr(record, "user_id", None)
@@ -95,7 +101,6 @@ class DatabaseLogHandler(logging.Handler):
continue
# For other errors or final attempt, silently skip
# Don't print to stderr to avoid log spam during imports
pass
finally:
db.close()
@@ -206,9 +211,7 @@ def setup_logging():
detailed_formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - [%(module)s:%(funcName)s:%(lineno)d] - %(message)s"
)
simple_formatter = logging.Formatter(
"%(asctime)s - %(levelname)s - %(message)s"
)
simple_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
# Console handler (simple format)
console_handler = logging.StreamHandler(sys.stdout)
@@ -217,10 +220,7 @@ def setup_logging():
# Rotating file handler (detailed format)
file_handler = RotatingFileHandler(
log_file,
maxBytes=max_bytes,
backupCount=backup_count,
encoding="utf-8"
log_file, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
)
file_handler.setFormatter(detailed_formatter)
logger.addHandler(file_handler)
@@ -232,7 +232,10 @@ def setup_logging():
logger.addHandler(db_handler)
except Exception as e:
# If database handler fails, just use file logging
print(f"Warning: Database logging handler could not be initialized: {e}", file=sys.stderr)
print(
f"Warning: Database logging handler could not be initialized: {e}",
file=sys.stderr,
)
# Configure specific loggers to reduce noise
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
@@ -245,7 +248,7 @@ def setup_logging():
logger.info(f"Log File: {log_file}")
logger.info(f"Max File Size: {max_bytes / (1024 * 1024):.1f} MB")
logger.info(f"Backup Count: {backup_count}")
logger.info(f"Database Logging: Enabled (WARNING and above)")
logger.info("Database Logging: Enabled (WARNING and above)")
logger.info("=" * 80)
return logging.getLogger(__name__)

View File

@@ -46,6 +46,16 @@ from .base import (
WizamartException,
)
# Cart exceptions
from .cart import (
CartItemNotFoundException,
CartValidationException,
EmptyCartException,
InsufficientInventoryForCartException,
InvalidCartQuantityException,
ProductNotAvailableForCartException,
)
# Code quality exceptions
from .code_quality import (
InvalidViolationStatusException,
@@ -57,16 +67,6 @@ from .code_quality import (
ViolationOperationException,
)
# Cart exceptions
from .cart import (
CartItemNotFoundException,
CartValidationException,
EmptyCartException,
InsufficientInventoryForCartException,
InvalidCartQuantityException,
ProductNotAvailableForCartException,
)
# Company exceptions
from .company import (
CompanyAlreadyExistsException,

View File

@@ -366,11 +366,11 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
if context_type == RequestContext.VENDOR_DASHBOARD:
# Extract vendor code from the request path
# Path format: /vendor/{vendor_code}/...
path_parts = request.url.path.split('/')
path_parts = request.url.path.split("/")
vendor_code = None
# Find vendor code in path
if len(path_parts) >= 3 and path_parts[1] == 'vendor':
if len(path_parts) >= 3 and path_parts[1] == "vendor":
vendor_code = path_parts[2]
# Fallback: try to get from request state

View File

@@ -408,9 +408,7 @@ async def admin_user_create_page(
)
@router.get(
"/users/{user_id}", response_class=HTMLResponse, include_in_schema=False
)
@router.get("/users/{user_id}", response_class=HTMLResponse, include_in_schema=False)
async def admin_user_detail_page(
request: Request,
user_id: int = Path(..., description="User ID"),
@@ -562,7 +560,9 @@ async def admin_letzshop_page(
# ============================================================================
@router.get("/marketplace-products", response_class=HTMLResponse, include_in_schema=False)
@router.get(
"/marketplace-products", response_class=HTMLResponse, include_in_schema=False
)
async def admin_marketplace_products_page(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),

View File

@@ -35,7 +35,7 @@ from middleware.auth import AuthManager
from models.database.company import Company
from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.user import User
from models.database.vendor import Role, Vendor, VendorUser
from models.database.vendor import Role, Vendor
from models.schema.marketplace_import_job import MarketplaceImportJobResponse
from models.schema.vendor import VendorCreate
@@ -143,7 +143,9 @@ class AdminService:
# Apply pagination
skip = (page - 1) * per_page
users = query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all()
users = (
query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all()
)
return users, total, pages
@@ -199,7 +201,9 @@ class AdminService:
"""
user = (
db.query(User)
.options(joinedload(User.owned_companies), joinedload(User.vendor_memberships))
.options(
joinedload(User.owned_companies), joinedload(User.vendor_memberships)
)
.filter(User.id == user_id)
.first()
)
@@ -243,12 +247,16 @@ class AdminService:
# Check email uniqueness if changing
if email and email != user.email:
if db.query(User).filter(User.email == email).first():
raise UserAlreadyExistsException("Email already registered", field="email")
raise UserAlreadyExistsException(
"Email already registered", field="email"
)
# Check username uniqueness if changing
if username and username != user.username:
if db.query(User).filter(User.username == username).first():
raise UserAlreadyExistsException("Username already taken", field="username")
raise UserAlreadyExistsException(
"Username already taken", field="username"
)
# Update fields
if email is not None:
@@ -322,7 +330,9 @@ class AdminService:
search_term = f"%{query.lower()}%"
users = (
db.query(User)
.filter(or_(User.username.ilike(search_term), User.email.ilike(search_term)))
.filter(
or_(User.username.ilike(search_term), User.email.ilike(search_term))
)
.limit(limit)
.all()
)
@@ -360,14 +370,20 @@ class AdminService:
"""
try:
# Validate company exists
company = db.query(Company).filter(Company.id == vendor_data.company_id).first()
company = (
db.query(Company).filter(Company.id == vendor_data.company_id).first()
)
if not company:
raise ValidationException(f"Company with ID {vendor_data.company_id} not found")
raise ValidationException(
f"Company with ID {vendor_data.company_id} not found"
)
# Check if vendor code already exists
existing_vendor = (
db.query(Vendor)
.filter(func.upper(Vendor.vendor_code) == vendor_data.vendor_code.upper())
.filter(
func.upper(Vendor.vendor_code) == vendor_data.vendor_code.upper()
)
.first()
)
if existing_vendor:
@@ -613,7 +629,13 @@ class AdminService:
update_data["tax_number"] = None
# Convert empty strings to None for contact fields (empty = inherit)
contact_fields = ["contact_email", "contact_phone", "website", "business_address", "tax_number"]
contact_fields = [
"contact_email",
"contact_phone",
"website",
"business_address",
"tax_number",
]
for field in contact_fields:
if field in update_data and update_data[field] == "":
update_data[field] = None

View File

@@ -48,7 +48,9 @@ class BackgroundTasksService:
def get_import_stats(self, db: Session) -> dict:
"""Get import job statistics"""
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
today_start = datetime.now(UTC).replace(
hour=0, minute=0, second=0, microsecond=0
)
stats = db.query(
func.count(MarketplaceImportJob.id).label("total"),
@@ -57,7 +59,12 @@ class BackgroundTasksService:
).label("running"),
func.sum(
func.case(
(MarketplaceImportJob.status.in_(["completed", "completed_with_errors"]), 1),
(
MarketplaceImportJob.status.in_(
["completed", "completed_with_errors"]
),
1,
),
else_=0,
)
).label("completed"),
@@ -83,12 +90,18 @@ class BackgroundTasksService:
def get_test_run_stats(self, db: Session) -> dict:
"""Get test run statistics"""
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
today_start = datetime.now(UTC).replace(
hour=0, minute=0, second=0, microsecond=0
)
stats = db.query(
func.count(TestRun.id).label("total"),
func.sum(func.case((TestRun.status == "running", 1), else_=0)).label("running"),
func.sum(func.case((TestRun.status == "passed", 1), else_=0)).label("completed"),
func.sum(func.case((TestRun.status == "running", 1), else_=0)).label(
"running"
),
func.sum(func.case((TestRun.status == "passed", 1), else_=0)).label(
"completed"
),
func.sum(
func.case((TestRun.status.in_(["failed", "error"]), 1), else_=0)
).label("failed"),

View File

@@ -8,7 +8,6 @@ This service handles CRUD operations for companies and company-vendor relationsh
import logging
import secrets
import string
from typing import List, Optional
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload
@@ -26,7 +25,6 @@ class CompanyService:
def __init__(self):
"""Initialize company service."""
pass
def create_company_with_owner(
self, db: Session, company_data: CompanyCreate
@@ -106,11 +104,15 @@ class CompanyService:
Raises:
CompanyNotFoundException: If company not found
"""
company = db.execute(
select(Company)
.where(Company.id == company_id)
.options(joinedload(Company.vendors))
).unique().scalar_one_or_none()
company = (
db.execute(
select(Company)
.where(Company.id == company_id)
.options(joinedload(Company.vendors))
)
.unique()
.scalar_one_or_none()
)
if not company:
raise CompanyNotFoundException(company_id)
@@ -125,7 +127,7 @@ class CompanyService:
search: str | None = None,
is_active: bool | None = None,
is_verified: bool | None = None,
) -> tuple[List[Company], int]:
) -> tuple[list[Company], int]:
"""
Get paginated list of companies with optional filters.
@@ -209,7 +211,9 @@ class CompanyService:
db.flush()
logger.info(f"Deleted company ID {company_id} and associated vendors")
def toggle_verification(self, db: Session, company_id: int, is_verified: bool) -> Company:
def toggle_verification(
self, db: Session, company_id: int, is_verified: bool
) -> Company:
"""
Toggle company verification status.
@@ -227,9 +231,7 @@ class CompanyService:
company = self.get_company_by_id(db, company_id)
company.is_verified = is_verified
db.flush()
logger.info(
f"Company ID {company_id} verification set to {is_verified}"
)
logger.info(f"Company ID {company_id} verification set to {is_verified}")
return company
@@ -251,9 +253,7 @@ class CompanyService:
company = self.get_company_by_id(db, company_id)
company.is_active = is_active
db.flush()
logger.info(
f"Company ID {company_id} active status set to {is_active}"
)
logger.info(f"Company ID {company_id} active status set to {is_active}")
return company

View File

@@ -526,7 +526,9 @@ class ContentPageService:
return (
db.query(ContentPage)
.filter(and_(*filters) if filters else True)
.order_by(ContentPage.vendor_id, ContentPage.display_order, ContentPage.title)
.order_by(
ContentPage.vendor_id, ContentPage.display_order, ContentPage.title
)
.all()
)

View File

@@ -30,20 +30,14 @@ class LetzshopClientError(Exception):
class LetzshopAuthError(LetzshopClientError):
"""Raised when authentication fails."""
pass
class LetzshopAPIError(LetzshopClientError):
"""Raised when the API returns an error response."""
pass
class LetzshopConnectionError(LetzshopClientError):
"""Raised when connection to the API fails."""
pass
# ============================================================================
# GraphQL Queries

View File

@@ -6,7 +6,7 @@ Handles secure storage and retrieval of per-vendor Letzshop API credentials.
"""
import logging
from datetime import datetime, timezone
from datetime import UTC, datetime
from sqlalchemy.orm import Session
@@ -24,14 +24,10 @@ DEFAULT_ENDPOINT = "https://letzshop.lu/graphql"
class CredentialsError(Exception):
"""Base exception for credentials errors."""
pass
class CredentialsNotFoundError(CredentialsError):
"""Raised when credentials are not found for a vendor."""
pass
class LetzshopCredentialsService:
"""
@@ -54,9 +50,7 @@ class LetzshopCredentialsService:
# CRUD Operations
# ========================================================================
def get_credentials(
self, vendor_id: int
) -> VendorLetzshopCredentials | None:
def get_credentials(self, vendor_id: int) -> VendorLetzshopCredentials | None:
"""
Get Letzshop credentials for a vendor.
@@ -72,9 +66,7 @@ class LetzshopCredentialsService:
.first()
)
def get_credentials_or_raise(
self, vendor_id: int
) -> VendorLetzshopCredentials:
def get_credentials_or_raise(self, vendor_id: int) -> VendorLetzshopCredentials:
"""
Get Letzshop credentials for a vendor or raise an exception.
@@ -293,9 +285,7 @@ class LetzshopCredentialsService:
# Connection Testing
# ========================================================================
def test_connection(
self, vendor_id: int
) -> tuple[bool, float | None, str | None]:
def test_connection(self, vendor_id: int) -> tuple[bool, float | None, str | None]:
"""
Test the connection for a vendor's credentials.
@@ -364,7 +354,7 @@ class LetzshopCredentialsService:
if credentials is None:
return None
credentials.last_sync_at = datetime.now(timezone.utc)
credentials.last_sync_at = datetime.now(UTC)
credentials.last_sync_status = status
credentials.last_sync_error = error

View File

@@ -7,7 +7,7 @@ architecture rules (API-002: endpoints should not contain business logic).
"""
import logging
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func
@@ -21,21 +21,16 @@ from models.database.letzshop import (
)
from models.database.vendor import Vendor
logger = logging.getLogger(__name__)
class VendorNotFoundError(Exception):
"""Raised when a vendor is not found."""
pass
class OrderNotFoundError(Exception):
"""Raised when a Letzshop order is not found."""
pass
class LetzshopOrderService:
"""Service for Letzshop order database operations."""
@@ -114,17 +109,23 @@ class LetzshopOrderService:
or 0
)
vendor_overviews.append({
"vendor_id": vendor.id,
"vendor_name": vendor.name,
"vendor_code": vendor.vendor_code,
"is_configured": credentials is not None,
"auto_sync_enabled": credentials.auto_sync_enabled if credentials else False,
"last_sync_at": credentials.last_sync_at if credentials else None,
"last_sync_status": credentials.last_sync_status if credentials else None,
"pending_orders": pending_orders,
"total_orders": total_orders,
})
vendor_overviews.append(
{
"vendor_id": vendor.id,
"vendor_name": vendor.name,
"vendor_code": vendor.vendor_code,
"is_configured": credentials is not None,
"auto_sync_enabled": credentials.auto_sync_enabled
if credentials
else False,
"last_sync_at": credentials.last_sync_at if credentials else None,
"last_sync_status": credentials.last_sync_status
if credentials
else None,
"pending_orders": pending_orders,
"total_orders": total_orders,
}
)
return vendor_overviews, total
@@ -210,9 +211,7 @@ class LetzshopOrderService:
letzshop_order_number=order_data.get("number"),
letzshop_state=shipment_data.get("state"),
customer_email=order_data.get("email"),
total_amount=str(
order_data.get("totalPrice", {}).get("amount", "")
),
total_amount=str(order_data.get("totalPrice", {}).get("amount", "")),
currency=order_data.get("totalPrice", {}).get("currency", "EUR"),
raw_order_data=shipment_data,
inventory_units=[
@@ -236,13 +235,13 @@ class LetzshopOrderService:
def mark_order_confirmed(self, order: LetzshopOrder) -> LetzshopOrder:
"""Mark an order as confirmed."""
order.confirmed_at = datetime.now(timezone.utc)
order.confirmed_at = datetime.now(UTC)
order.sync_status = "confirmed"
return order
def mark_order_rejected(self, order: LetzshopOrder) -> LetzshopOrder:
"""Mark an order as rejected."""
order.rejected_at = datetime.now(timezone.utc)
order.rejected_at = datetime.now(UTC)
order.sync_status = "rejected"
return order
@@ -255,7 +254,7 @@ class LetzshopOrderService:
"""Set tracking information for an order."""
order.tracking_number = tracking_number
order.tracking_carrier = tracking_carrier
order.tracking_set_at = datetime.now(timezone.utc)
order.tracking_set_at = datetime.now(UTC)
order.sync_status = "shipped"
return order

View File

@@ -4,10 +4,10 @@ Service for exporting products to Letzshop CSV format.
Generates Google Shopping compatible CSV files for Letzshop marketplace.
"""
import csv
import io
import logging
from typing import BinaryIO
from sqlalchemy.orm import Session, joinedload
@@ -140,7 +140,9 @@ class LetzshopExportService:
)
if marketplace:
query = query.filter(MarketplaceProduct.marketplace.ilike(f"%{marketplace}%"))
query = query.filter(
MarketplaceProduct.marketplace.ilike(f"%{marketplace}%")
)
if limit:
query = query.limit(limit)
@@ -193,7 +195,9 @@ class LetzshopExportService:
def _product_to_row(self, product: Product, language: str) -> dict:
"""Convert a Product (with MarketplaceProduct) to a CSV row."""
mp = product.marketplace_product
return self._marketplace_product_to_row(mp, language, vendor_sku=product.vendor_sku)
return self._marketplace_product_to_row(
mp, language, vendor_sku=product.vendor_sku
)
def _marketplace_product_to_row(
self,

View File

@@ -11,7 +11,6 @@ This module provides functions for:
"""
import logging
import os
from datetime import UTC, datetime, timedelta
from pathlib import Path
@@ -58,7 +57,9 @@ class LogService:
conditions.append(ApplicationLog.level == filters.level.upper())
if filters.logger_name:
conditions.append(ApplicationLog.logger_name.like(f"%{filters.logger_name}%"))
conditions.append(
ApplicationLog.logger_name.like(f"%{filters.logger_name}%")
)
if filters.module:
conditions.append(ApplicationLog.module.like(f"%{filters.module}%"))
@@ -215,7 +216,8 @@ class LogService:
except Exception as e:
logger.error(f"Failed to get log statistics: {e}")
raise AdminOperationException(
operation="get_log_statistics", reason=f"Database query failed: {str(e)}"
operation="get_log_statistics",
reason=f"Database query failed: {str(e)}",
)
def get_file_logs(
@@ -252,7 +254,7 @@ class LogService:
stat = log_file.stat()
# Read last N lines efficiently
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
with open(log_file, encoding="utf-8", errors="replace") as f:
# For large files, seek to end and read backwards
all_lines = f.readlines()
log_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
@@ -349,16 +351,21 @@ class LogService:
db.rollback()
logger.error(f"Failed to cleanup old logs: {e}")
raise AdminOperationException(
operation="cleanup_old_logs", reason=f"Delete operation failed: {str(e)}"
operation="cleanup_old_logs",
reason=f"Delete operation failed: {str(e)}",
)
def delete_log(self, db: Session, log_id: int) -> str:
"""Delete a specific log entry."""
try:
log_entry = db.query(ApplicationLog).filter(ApplicationLog.id == log_id).first()
log_entry = (
db.query(ApplicationLog).filter(ApplicationLog.id == log_id).first()
)
if not log_entry:
raise ResourceNotFoundException(resource_type="log", identifier=str(log_id))
raise ResourceNotFoundException(
resource_type="log", identifier=str(log_id)
)
db.delete(log_entry)
db.commit()

View File

@@ -8,7 +8,10 @@ from app.exceptions import (
ImportJobNotOwnedException,
ValidationException,
)
from models.database.marketplace_import_job import MarketplaceImportError, 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 (
@@ -136,7 +139,9 @@ class MarketplaceImportJobService:
except (ImportJobNotFoundException, UnauthorizedVendorAccessException):
raise
except Exception as e:
logger.error(f"Error getting import job {job_id} for vendor {vendor_id}: {str(e)}")
logger.error(
f"Error getting import job {job_id} for vendor {vendor_id}: {str(e)}"
)
raise ValidationException("Failed to retrieve import job")
def get_import_jobs(

View File

@@ -32,7 +32,9 @@ from app.exceptions import (
from app.utils.data_processing import GTINProcessor, PriceProcessor
from models.database.inventory import Inventory
from models.database.marketplace_product import MarketplaceProduct
from models.database.marketplace_product_translation import MarketplaceProductTranslation
from models.database.marketplace_product_translation import (
MarketplaceProductTranslation,
)
from models.schema.inventory import InventoryLocationResponse, InventorySummaryResponse
from models.schema.marketplace_product import (
MarketplaceProductCreate,
@@ -602,7 +604,6 @@ class MarketplaceProductService:
return normalized
# =========================================================================
# Admin-specific methods for marketplace product management
# =========================================================================
@@ -632,22 +633,28 @@ class MarketplaceProductService:
if search:
search_term = f"%{search}%"
query = query.outerjoin(MarketplaceProductTranslation).filter(
or_(
MarketplaceProductTranslation.title.ilike(search_term),
MarketplaceProduct.gtin.ilike(search_term),
MarketplaceProduct.sku.ilike(search_term),
MarketplaceProduct.brand.ilike(search_term),
MarketplaceProduct.mpn.ilike(search_term),
MarketplaceProduct.marketplace_product_id.ilike(search_term),
query = (
query.outerjoin(MarketplaceProductTranslation)
.filter(
or_(
MarketplaceProductTranslation.title.ilike(search_term),
MarketplaceProduct.gtin.ilike(search_term),
MarketplaceProduct.sku.ilike(search_term),
MarketplaceProduct.brand.ilike(search_term),
MarketplaceProduct.mpn.ilike(search_term),
MarketplaceProduct.marketplace_product_id.ilike(search_term),
)
)
).distinct()
.distinct()
)
if marketplace:
query = query.filter(MarketplaceProduct.marketplace == marketplace)
if vendor_name:
query = query.filter(MarketplaceProduct.vendor_name.ilike(f"%{vendor_name}%"))
query = query.filter(
MarketplaceProduct.vendor_name.ilike(f"%{vendor_name}%")
)
if availability:
query = query.filter(MarketplaceProduct.availability == availability)
@@ -787,8 +794,12 @@ class MarketplaceProductService:
"weight": product.weight,
"weight_unit": product.weight_unit,
"translations": translations,
"created_at": product.created_at.isoformat() if product.created_at else None,
"updated_at": product.updated_at.isoformat() if product.updated_at else None,
"created_at": product.created_at.isoformat()
if product.created_at
else None,
"updated_at": product.updated_at.isoformat()
if product.updated_at
else None,
}
def copy_to_vendor_catalog(
@@ -810,6 +821,7 @@ class MarketplaceProductService:
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
from app.exceptions import VendorNotFoundException
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
marketplace_products = (
@@ -839,11 +851,13 @@ class MarketplaceProductService:
if existing:
skipped += 1
details.append({
"id": mp.id,
"status": "skipped",
"reason": "Already exists in catalog",
})
details.append(
{
"id": mp.id,
"status": "skipped",
"reason": "Already exists in catalog",
}
)
continue
product = Product(
@@ -876,7 +890,9 @@ class MarketplaceProductService:
"details": details if len(details) <= 100 else None,
}
def _build_admin_product_item(self, product: MarketplaceProduct, title: str | None) -> dict:
def _build_admin_product_item(
self, product: MarketplaceProduct, title: str | None
) -> dict:
"""Build a product list item dict for admin view."""
return {
"id": product.id,
@@ -894,8 +910,12 @@ class MarketplaceProductService:
"is_active": product.is_active,
"is_digital": product.is_digital,
"product_type_enum": product.product_type_enum,
"created_at": product.created_at.isoformat() if product.created_at else None,
"updated_at": product.updated_at.isoformat() if product.updated_at else None,
"created_at": product.created_at.isoformat()
if product.created_at
else None,
"updated_at": product.updated_at.isoformat()
if product.updated_at
else None,
}

View File

@@ -160,7 +160,9 @@ class StatsService:
# Inventory stats
"total_inventory_quantity": int(total_inventory),
"reserved_inventory_quantity": int(reserved_inventory),
"available_inventory_quantity": int(total_inventory - reserved_inventory),
"available_inventory_quantity": int(
total_inventory - reserved_inventory
),
"inventory_locations_count": inventory_locations,
}

View File

@@ -77,11 +77,15 @@ class TestRunnerService:
"""Execute pytest and update the test run record"""
try:
# Build pytest command with JSON output
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as f:
json_report_path = f.name
pytest_args = [
"python", "-m", "pytest",
"python",
"-m",
"pytest",
test_path,
"--json-report",
f"--json-report-file={json_report_path}",
@@ -109,7 +113,7 @@ class TestRunnerService:
# Parse JSON report
try:
with open(json_report_path, 'r') as f:
with open(json_report_path) as f:
report = json.load(f)
self._process_json_report(db, test_run, report)
@@ -167,7 +171,7 @@ class TestRunnerService:
traceback = call_info["longrepr"]
# Extract error message from traceback
if isinstance(traceback, str):
lines = traceback.strip().split('\n')
lines = traceback.strip().split("\n")
if lines:
error_message = lines[-1][:500] # Last line, limited length
@@ -232,8 +236,12 @@ class TestRunnerService:
test_run.xpassed = count
test_run.total_tests = (
test_run.passed + test_run.failed + test_run.errors +
test_run.skipped + test_run.xfailed + test_run.xpassed
test_run.passed
+ test_run.failed
+ test_run.errors
+ test_run.skipped
+ test_run.xfailed
+ test_run.xpassed
)
def _get_git_commit(self) -> str | None:
@@ -266,12 +274,7 @@ class TestRunnerService:
def get_run_history(self, db: Session, limit: int = 20) -> list[TestRun]:
"""Get recent test run history"""
return (
db.query(TestRun)
.order_by(desc(TestRun.timestamp))
.limit(limit)
.all()
)
return db.query(TestRun).order_by(desc(TestRun.timestamp)).limit(limit).all()
def get_run_by_id(self, db: Session, run_id: int) -> TestRun | None:
"""Get a specific test run with results"""
@@ -282,8 +285,7 @@ class TestRunnerService:
return (
db.query(TestResult)
.filter(
TestResult.run_id == run_id,
TestResult.outcome.in_(["failed", "error"])
TestResult.run_id == run_id, TestResult.outcome.in_(["failed", "error"])
)
.all()
)
@@ -310,7 +312,9 @@ class TestRunnerService:
)
# Get test collection info (or calculate from latest run)
collection = db.query(TestCollection).order_by(desc(TestCollection.collected_at)).first()
collection = (
db.query(TestCollection).order_by(desc(TestCollection.collected_at)).first()
)
# Get trend data (last 10 runs)
trend_runs = (
@@ -324,7 +328,9 @@ class TestRunnerService:
# Calculate stats by category from latest run
by_category = {}
if latest_run:
results = db.query(TestResult).filter(TestResult.run_id == latest_run.id).all()
results = (
db.query(TestResult).filter(TestResult.run_id == latest_run.id).all()
)
for result in results:
# Categorize by test path
if "unit" in result.test_file:
@@ -351,7 +357,7 @@ class TestRunnerService:
db.query(
TestResult.test_name,
TestResult.test_file,
func.count(TestResult.id).label("failure_count")
func.count(TestResult.id).label("failure_count"),
)
.filter(TestResult.outcome.in_(["failed", "error"]))
.group_by(TestResult.test_name, TestResult.test_file)
@@ -368,11 +374,12 @@ class TestRunnerService:
"errors": latest_run.errors if latest_run else 0,
"skipped": latest_run.skipped if latest_run else 0,
"pass_rate": round(latest_run.pass_rate, 1) if latest_run else 0,
"duration_seconds": round(latest_run.duration_seconds, 2) if latest_run else 0,
"duration_seconds": round(latest_run.duration_seconds, 2)
if latest_run
else 0,
"coverage_percent": latest_run.coverage_percent if latest_run else None,
"last_run": latest_run.timestamp.isoformat() if latest_run else None,
"last_run_status": latest_run.status if latest_run else None,
# Collection stats
"total_test_files": collection.total_files if collection else 0,
"collected_tests": collection.total_tests if collection else 0,
@@ -380,8 +387,9 @@ class TestRunnerService:
"integration_tests": collection.integration_tests if collection else 0,
"performance_tests": collection.performance_tests if collection else 0,
"system_tests": collection.system_tests if collection else 0,
"last_collected": collection.collected_at.isoformat() if collection else None,
"last_collected": collection.collected_at.isoformat()
if collection
else None,
# Trend data
"trend": [
{
@@ -394,10 +402,8 @@ class TestRunnerService:
}
for run in reversed(trend_runs)
],
# By category
"by_category": by_category,
# Top failing tests
"top_failing": [
{
@@ -417,16 +423,20 @@ class TestRunnerService:
try:
# Run pytest --collect-only with JSON report
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as f:
json_report_path = f.name
result = subprocess.run(
[
"python", "-m", "pytest",
"python",
"-m",
"pytest",
"--collect-only",
"--json-report",
f"--json-report-file={json_report_path}",
"tests"
"tests",
],
cwd=str(self.project_root),
capture_output=True,
@@ -461,11 +471,17 @@ class TestRunnerService:
if "/unit/" in file_path or file_path.startswith("tests/unit"):
collection.unit_tests += count
elif "/integration/" in file_path or file_path.startswith("tests/integration"):
elif "/integration/" in file_path or file_path.startswith(
"tests/integration"
):
collection.integration_tests += count
elif "/performance/" in file_path or file_path.startswith("tests/performance"):
elif "/performance/" in file_path or file_path.startswith(
"tests/performance"
):
collection.performance_tests += count
elif "/system/" in file_path or file_path.startswith("tests/system"):
elif "/system/" in file_path or file_path.startswith(
"tests/system"
):
collection.system_tests += count
collection.test_files = [
@@ -476,7 +492,9 @@ class TestRunnerService:
# Cleanup
json_path.unlink(missing_ok=True)
logger.info(f"Collected {collection.total_tests} tests from {collection.total_files} files")
logger.info(
f"Collected {collection.total_tests} tests from {collection.total_files} files"
)
except Exception as e:
logger.error(f"Error collecting tests: {e}", exc_info=True)

View File

@@ -66,10 +66,7 @@ class VendorProductService:
total = query.count()
products = (
query.order_by(Product.updated_at.desc())
.offset(skip)
.limit(limit)
.all()
query.order_by(Product.updated_at.desc()).offset(skip).limit(limit).all()
)
result = []
@@ -138,8 +135,7 @@ class VendorProductService:
.all()
)
return [
{"id": v.id, "name": v.name, "vendor_code": v.vendor_code}
for v in vendors
{"id": v.id, "name": v.name, "vendor_code": v.vendor_code} for v in vendors
]
def get_product_detail(self, db: Session, product_id: int) -> dict:
@@ -212,8 +208,12 @@ class VendorProductService:
"marketplace_translations": mp_translations,
"vendor_translations": vendor_translations,
# Timestamps
"created_at": product.created_at.isoformat() if product.created_at else None,
"updated_at": product.updated_at.isoformat() if product.updated_at else None,
"created_at": product.created_at.isoformat()
if product.created_at
else None,
"updated_at": product.updated_at.isoformat()
if product.updated_at
else None,
}
def remove_product(self, db: Session, product_id: int) -> dict:
@@ -257,8 +257,12 @@ class VendorProductService:
"image_url": product.effective_primary_image_url,
"source_marketplace": mp.marketplace if mp else None,
"source_vendor": mp.vendor_name if mp else None,
"created_at": product.created_at.isoformat() if product.created_at else None,
"updated_at": product.updated_at.isoformat() if product.updated_at else None,
"created_at": product.created_at.isoformat()
if product.created_at
else None,
"updated_at": product.updated_at.isoformat()
if product.updated_at
else None,
}

View File

@@ -67,20 +67,26 @@ class VendorService:
try:
# Validate company_id is provided
if not hasattr(vendor_data, 'company_id') or not vendor_data.company_id:
if not hasattr(vendor_data, "company_id") or not vendor_data.company_id:
raise InvalidVendorDataException(
"company_id is required to create a vendor", field="company_id"
)
# Get company and verify ownership
company = db.query(Company).filter(Company.id == vendor_data.company_id).first()
company = (
db.query(Company).filter(Company.id == vendor_data.company_id).first()
)
if not company:
raise InvalidVendorDataException(
f"Company with ID {vendor_data.company_id} not found", field="company_id"
f"Company with ID {vendor_data.company_id} not found",
field="company_id",
)
# Check if user is company owner or admin
if current_user.role != "admin" and company.owner_user_id != current_user.id:
if (
current_user.role != "admin"
and company.owner_user_id != current_user.id
):
raise UnauthorizedVendorAccessException(
f"company-{vendor_data.company_id}", current_user.id
)
@@ -163,9 +169,7 @@ class VendorService:
)
query = query.filter(
(Vendor.is_active == True)
& (
(Vendor.is_verified == True) | (Vendor.id.in_(owned_vendor_ids))
)
& ((Vendor.is_verified == True) | (Vendor.id.in_(owned_vendor_ids)))
)
else:
# Admin can apply filters
@@ -238,6 +242,7 @@ class VendorService:
VendorNotFoundException: If vendor not found
"""
from sqlalchemy.orm import joinedload
from models.database.company import Company
vendor = (
@@ -272,6 +277,7 @@ class VendorService:
VendorNotFoundException: If vendor not found or inactive
"""
from sqlalchemy.orm import joinedload
from models.database.company import Company
vendor = (
@@ -305,6 +311,7 @@ class VendorService:
VendorNotFoundException: If vendor not found
"""
from sqlalchemy.orm import joinedload
from models.database.company import Company
# Try as integer ID first

View File

@@ -2,7 +2,6 @@
"""Background tasks for test runner."""
import logging
from datetime import UTC, datetime
from app.core.database import SessionLocal
from app.services.test_runner_service import test_runner_service

View File

@@ -58,7 +58,7 @@
{% set positions = {'left': 'left-0', 'right': 'right-0'} %}
{# Uses languageSelector() function per LANG-002 architecture rule #}
<div
x-data="languageSelector('{{ current }}', {{ langs | tojson | safe }})"
x-data="languageSelector('{{ current }}', {{ langs | tojson }})"
class="relative inline-block"
>
<button
@@ -121,7 +121,7 @@
{% set positions = {'left': 'left-0', 'right': 'right-0'} %}
{# Uses languageSelector() function per LANG-002 architecture rule #}
<div
x-data="languageSelector('{{ current }}', {{ langs | tojson | safe }})"
x-data="languageSelector('{{ current }}', {{ langs | tojson }})"
class="relative"
>
<button
@@ -179,7 +179,7 @@
{% set current = current_language if current_language in langs else langs[0] %}
{# Uses languageSelector() function per LANG-002 architecture rule #}
<div
x-data="languageSelector('{{ current }}', {{ langs | tojson | safe }})"
x-data="languageSelector('{{ current }}', {{ langs | tojson }})"
class="inline-flex items-center gap-1 p-1 bg-gray-100 dark:bg-gray-700 rounded-lg"
>
<template x-for="lang in languages" :key="lang">

View File

@@ -136,7 +136,7 @@
{# Language Selector #}
{% set enabled_langs = vendor.storefront_languages if vendor and vendor.storefront_languages else ['fr', 'de', 'en'] %}
{% if enabled_langs|length > 1 %}
<div class="relative" x-data="languageSelector('{{ request.state.language|default("fr") }}', {{ enabled_langs|tojson|safe }})">
<div class="relative" x-data="languageSelector('{{ request.state.language|default('fr') }}', {{ enabled_langs|tojson }})">
<button
@click="isLangOpen = !isLangOpen"
@click.outside="isLangOpen = false"

View File

@@ -20,7 +20,9 @@ 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
from models.database.marketplace_product_translation import (
MarketplaceProductTranslation,
)
logger = logging.getLogger(__name__)
@@ -215,9 +217,7 @@ class CSVProcessor:
return processed_data
def _extract_translation_data(
self, product_data: dict[str, Any]
) -> dict[str, Any]:
def _extract_translation_data(self, product_data: dict[str, Any]) -> dict[str, Any]:
"""Extract translation fields from product data.
Returns a dict with title, description, etc. that belong
@@ -363,11 +363,22 @@ class CSVProcessor:
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()
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
@@ -419,7 +430,11 @@ class CSVProcessor:
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")
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"):
@@ -524,7 +539,8 @@ class CSVProcessor:
row_number=row_number,
error_type="processing_error",
error_message=str(e),
identifier=row_dict.get("marketplace_product_id") or row_dict.get("id"),
identifier=row_dict.get("marketplace_product_id")
or row_dict.get("id"),
row_data=row_dict,
)
errors += 1

View File

@@ -54,16 +54,18 @@ class GTINProcessor:
# Standard lengths - return as-is (already valid)
return gtin_clean
elif length > 14:
if length > 14:
# Too long - truncate to EAN-13
logger.debug(f"GTIN too long ({length} digits), truncating: {gtin_clean}")
return gtin_clean[-13:]
elif 0 < length < 14:
if 0 < length < 14:
# Non-standard length - pad to EAN-13 (European standard)
# EAN-13 is the international standard used in Europe and most of the world
# UPC-A (12 digits) is primarily US/Canada
logger.debug(f"GTIN non-standard ({length} digits), padding to EAN-13: {gtin_clean}")
logger.debug(
f"GTIN non-standard ({length} digits), padding to EAN-13: {gtin_clean}"
)
return gtin_clean.zfill(13)
logger.warning(f"Invalid GTIN format: '{gtin_value}'")

View File

@@ -25,8 +25,6 @@ _ENCRYPTION_SALT = b"wizamart_encryption_salt_v1"
class EncryptionError(Exception):
"""Raised when encryption or decryption fails."""
pass
class EncryptionService:
"""

View File

@@ -14,6 +14,7 @@ Supported languages:
- de: German
- lb: Luxembourgish
"""
import json
import logging
from functools import lru_cache
@@ -97,7 +98,7 @@ def load_translations(language: str) -> dict:
return {}
try:
with open(locale_file, "r", encoding="utf-8") as f:
with open(locale_file, encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in translation file {locale_file}: {e}")
@@ -139,7 +140,11 @@ def get_nested_value(data: dict, key_path: str, default: str = None) -> str:
else:
return default if default is not None else key_path
return value if isinstance(value, str) else (default if default is not None else key_path)
return (
value
if isinstance(value, str)
else (default if default is not None else key_path)
)
def translate(
@@ -180,7 +185,9 @@ def translate(
try:
text = text.format(**kwargs)
except KeyError as e:
logger.warning(f"Missing interpolation variable in translation '{key}': {e}")
logger.warning(
f"Missing interpolation variable in translation '{key}': {e}"
)
return text