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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -7,6 +7,7 @@ These endpoints handle:
|
||||
- Getting current language info
|
||||
- Listing available languages
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
|
||||
4
app/api/v1/vendor/analytics.py
vendored
4
app/api/v1/vendor/analytics.py
vendored
@@ -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"]
|
||||
),
|
||||
|
||||
5
app/api/v1/vendor/auth.py
vendored
5
app/api/v1/vendor/auth.py
vendored
@@ -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)
|
||||
|
||||
4
app/api/v1/vendor/customers.py
vendored
4
app/api/v1/vendor/customers.py
vendored
@@ -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)
|
||||
|
||||
25
app/api/v1/vendor/inventory.py
vendored
25
app/api/v1/vendor/inventory.py
vendored
@@ -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)
|
||||
|
||||
16
app/api/v1/vendor/letzshop.py
vendored
16
app/api/v1/vendor/letzshop.py
vendored
@@ -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),
|
||||
|
||||
24
app/api/v1/vendor/products.py
vendored
24
app/api/v1/vendor/products.py
vendored
@@ -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
|
||||
)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}'")
|
||||
|
||||
@@ -25,8 +25,6 @@ _ENCRYPTION_SALT = b"wizamart_encryption_salt_v1"
|
||||
class EncryptionError(Exception):
|
||||
"""Raised when encryption or decryption fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class EncryptionService:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user