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
)