From 9920430b9e65a2fc6c2e4b207276a5b838aef4fc Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sat, 13 Dec 2025 22:59:51 +0100 Subject: [PATCH] fix: correct tojson|safe usage in templates and update validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .architecture-rules/language.yaml | 42 +- app/api/deps.py | 25 +- app/api/v1/admin/__init__.py | 4 +- app/api/v1/admin/background_tasks.py | 16 +- app/api/v1/admin/code_quality.py | 4 +- app/api/v1/admin/companies.py | 2 +- app/api/v1/admin/dashboard.py | 16 +- app/api/v1/admin/letzshop.py | 23 +- app/api/v1/admin/logs.py | 5 +- app/api/v1/admin/products.py | 4 +- app/api/v1/admin/settings.py | 4 +- app/api/v1/admin/tests.py | 9 +- app/api/v1/admin/users.py | 12 +- app/api/v1/admin/vendor_themes.py | 4 +- app/api/v1/admin/vendors.py | 4 +- app/api/v1/shared/language.py | 1 + app/api/v1/vendor/analytics.py | 4 +- app/api/v1/vendor/auth.py | 5 +- app/api/v1/vendor/customers.py | 4 +- app/api/v1/vendor/inventory.py | 25 +- app/api/v1/vendor/letzshop.py | 16 +- app/api/v1/vendor/products.py | 24 +- app/core/logging.py | 29 +- app/exceptions/__init__.py | 20 +- app/exceptions/handler.py | 4 +- app/routes/admin_pages.py | 8 +- app/services/admin_service.py | 42 +- app/services/background_tasks_service.py | 23 +- app/services/company_service.py | 30 +- app/services/content_page_service.py | 4 +- app/services/letzshop/client_service.py | 6 - app/services/letzshop/credentials_service.py | 20 +- app/services/letzshop/order_service.py | 45 +- app/services/letzshop_export_service.py | 10 +- app/services/log_service.py | 21 +- .../marketplace_import_job_service.py | 9 +- app/services/marketplace_product_service.py | 64 +- app/services/stats_service.py | 4 +- app/services/test_runner_service.py | 78 ++- app/services/vendor_product_service.py | 24 +- app/services/vendor_service.py | 21 +- app/tasks/test_runner_tasks.py | 1 - .../shared/macros/language_selector.html | 6 +- app/templates/shop/base.html | 2 +- app/utils/csv_processor.py | 38 +- app/utils/data_processing.py | 8 +- app/utils/encryption.py | 2 - app/utils/i18n.py | 13 +- middleware/language.py | 1 + models/database/__init__.py | 14 +- models/database/letzshop.py | 4 +- models/database/marketplace_import_job.py | 8 +- models/database/marketplace_product.py | 1 - models/database/product_translation.py | 4 +- models/database/test_run.py | 17 +- models/database/vendor.py | 13 +- models/schema/admin.py | 16 +- models/schema/letzshop.py | 7 +- models/schema/marketplace_product.py | 10 +- models/schema/media.py | 1 - models/schema/notification.py | 5 +- models/schema/payment.py | 5 +- models/schema/vendor.py | 61 +- scripts/init_log_settings.py | 8 +- scripts/seed_demo.py | 19 +- scripts/test_logging_system.py | 25 +- scripts/validate_architecture.py | 591 ++++++++++++------ scripts/validators/base.py | 14 +- tests/conftest.py | 1 + tests/fixtures/auth_fixtures.py | 6 +- tests/fixtures/customer_fixtures.py | 1 + .../marketplace_import_job_fixtures.py | 1 + .../fixtures/marketplace_product_fixtures.py | 5 +- tests/fixtures/vendor_fixtures.py | 1 + tests/integration/api/v1/admin/test_auth.py | 14 +- .../api/v1/admin/test_dashboard.py | 9 +- .../integration/api/v1/admin/test_letzshop.py | 59 +- .../api/v1/admin/test_marketplace.py | 1 + .../integration/api/v1/admin/test_products.py | 5 +- tests/integration/api/v1/admin/test_users.py | 1 + .../api/v1/admin/test_vendor_products.py | 21 +- .../integration/api/v1/admin/test_vendors.py | 1 + .../api/v1/vendor/test_dashboard.py | 4 +- .../api/v1/vendor/test_inventory.py | 48 +- .../api/v1/vendor/test_letzshop.py | 11 +- .../api/v1/vendor/test_marketplace.py | 9 +- .../api/v1/vendor/test_products.py | 53 +- .../middleware/test_context_detection_flow.py | 7 +- .../middleware/test_middleware_stack.py | 16 +- .../middleware/test_vendor_context_flow.py | 4 +- .../security/test_authentication.py | 1 + .../security/test_authorization.py | 9 +- .../security/test_input_validation.py | 13 +- .../integration/workflows/test_integration.py | 5 +- tests/performance/test_api_performance.py | 9 +- tests/system/test_error_handling.py | 4 +- tests/unit/middleware/test_vendor_context.py | 21 +- tests/unit/models/database/test_customer.py | 1 + tests/unit/models/database/test_inventory.py | 5 +- .../database/test_marketplace_import_job.py | 9 +- .../database/test_marketplace_product.py | 5 +- tests/unit/models/database/test_order.py | 14 +- tests/unit/models/database/test_product.py | 20 +- tests/unit/models/database/test_team.py | 5 +- tests/unit/models/database/test_user.py | 1 + tests/unit/models/database/test_vendor.py | 1 + tests/unit/models/schema/test_auth.py | 1 + tests/unit/models/schema/test_customer.py | 9 +- tests/unit/models/schema/test_inventory.py | 8 +- .../schema/test_marketplace_import_job.py | 3 +- tests/unit/models/schema/test_order.py | 20 +- tests/unit/models/schema/test_product.py | 6 +- tests/unit/models/schema/test_vendor.py | 5 +- tests/unit/services/test_admin_service.py | 9 +- tests/unit/services/test_auth_service.py | 1 + tests/unit/services/test_inventory_service.py | 52 +- tests/unit/services/test_letzshop_service.py | 33 +- .../unit/services/test_marketplace_service.py | 21 +- tests/unit/services/test_product_service.py | 20 +- tests/unit/services/test_stats_service.py | 26 +- .../services/test_vendor_product_service.py | 5 +- tests/unit/services/test_vendor_service.py | 18 +- tests/unit/utils/test_csv_processor.py | 1 - 123 files changed, 1408 insertions(+), 840 deletions(-) diff --git a/.architecture-rules/language.yaml b/.architecture-rules/language.yaml index 6103af56..a928f471 100644 --- a/.architecture-rules/language.yaml +++ b/.architecture-rules/language.yaml @@ -48,12 +48,17 @@ language_rules: ... }"> - RIGHT (function with tojson|safe): -
+ RIGHT (function with tojson - NO safe in HTML attributes): +
+ + NOTE: Use |tojson (without |safe) in HTML attributes so quotes + become " which the browser correctly decodes. Using |safe + would output raw quotes that break the attribute parsing. The languageSelector function must be defined in: - static/shop/js/shop-layout.js (for storefront) - static/vendor/js/init-alpine.js (for vendor dashboard) + - static/admin/js/init-alpine.js (for admin dashboard) pattern: file_pattern: "app/templates/**/*.html" anti_patterns: @@ -64,28 +69,33 @@ language_rules: - "partials/header.html" - id: "LANG-003" - name: "Use tojson|safe for Python lists in JavaScript" + name: "Use tojson correctly for Python lists in templates" severity: "error" description: | - When passing Python lists to JavaScript in templates, always use tojson|safe. - - tojson converts Python list to JSON - - safe prevents HTML escaping of quotes + When passing Python lists to JavaScript, use tojson appropriately: - WRONG (raw output): + IN HTML ATTRIBUTES (x-data, data-*, etc.): + Use |tojson WITHOUT |safe - quotes become " which browsers decode: +
+ + + IN + + WRONG (raw output - Python syntax not valid JS): languages: {{ vendor.storefront_languages }} - + - WRONG (tojson without safe): - languages: {{ vendor.storefront_languages|tojson }} - - - RIGHT: - languages: {{ vendor.storefront_languages|tojson|safe }} - + WRONG (|safe in HTML attributes - breaks attribute parsing): +
+ pattern: file_pattern: "app/templates/**/*.html" required_with_jinja_array: - - "|tojson|safe" + - "|tojson" - id: "LANG-004" name: "Language selector function must be exported to window" diff --git a/app/api/deps.py b/app/api/deps.py index 1634d9b7..60009e20 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -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 diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index a319ddf7..4a38b7bd 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -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"] diff --git a/app/api/v1/admin/background_tasks.py b/app/api/v1/admin/background_tasks.py index 19efa712..c059906c 100644 --- a/app/api/v1/admin/background_tasks.py +++ b/app/api/v1/admin/background_tasks.py @@ -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) diff --git a/app/api/v1/admin/code_quality.py b/app/api/v1/admin/code_quality.py index 7ba1bb03..1d407f86 100644 --- a/app/api/v1/admin/code_quality.py +++ b/app/api/v1/admin/code_quality.py @@ -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, } diff --git a/app/api/v1/admin/companies.py b/app/api/v1/admin/companies.py index d9547aa3..25f234af 100644 --- a/app/api/v1/admin/companies.py +++ b/app/api/v1/admin/companies.py @@ -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", ) diff --git a/app/api/v1/admin/dashboard.py b/app/api/v1/admin/dashboard.py index 35fd9f03..ffcdf9d5 100644 --- a/app/api/v1/admin/dashboard.py +++ b/app/api/v1/admin/dashboard.py @@ -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), diff --git a/app/api/v1/admin/letzshop.py b/app/api/v1/admin/letzshop.py index 431e6e2b..30439953 100644 --- a/app/api/v1/admin/letzshop.py +++ b/app/api/v1/admin/letzshop.py @@ -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() diff --git a/app/api/v1/admin/logs.py b/app/api/v1/admin/logs.py index 7e1aaadc..fd8ae8bb 100644 --- a/app/api/v1/admin/logs.py +++ b/app/api/v1/admin/logs.py @@ -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: diff --git a/app/api/v1/admin/products.py b/app/api/v1/admin/products.py index 80800956..0d64d4e1 100644 --- a/app/api/v1/admin/products.py +++ b/app/api/v1/admin/products.py @@ -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"), diff --git a/app/api/v1/admin/settings.py b/app/api/v1/admin/settings.py index c703abe2..0ec2ae52 100644 --- a/app/api/v1/admin/settings.py +++ b/app/api/v1/admin/settings.py @@ -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) diff --git a/app/api/v1/admin/tests.py b/app/api/v1/admin/tests.py index 914e081c..e7af97c8 100644 --- a/app/api/v1/admin/tests.py +++ b/app/api/v1/admin/tests.py @@ -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), ): diff --git a/app/api/v1/admin/users.py b/app/api/v1/admin/users.py index 730249b8..72df3d72 100644 --- a/app/api/v1/admin/users.py +++ b/app/api/v1/admin/users.py @@ -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, ) diff --git a/app/api/v1/admin/vendor_themes.py b/app/api/v1/admin/vendor_themes.py index f4767cdb..38fcdf46 100644 --- a/app/api/v1/admin/vendor_themes.py +++ b/app/api/v1/admin/vendor_themes.py @@ -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") + ) diff --git a/app/api/v1/admin/vendors.py b/app/api/v1/admin/vendors.py index 01a450e8..62dc92a0 100644 --- a/app/api/v1/admin/vendors.py +++ b/app/api/v1/admin/vendors.py @@ -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), diff --git a/app/api/v1/shared/language.py b/app/api/v1/shared/language.py index 57e565b2..4fc23d39 100644 --- a/app/api/v1/shared/language.py +++ b/app/api/v1/shared/language.py @@ -7,6 +7,7 @@ These endpoints handle: - Getting current language info - Listing available languages """ + import logging from fastapi import APIRouter, Request, Response diff --git a/app/api/v1/vendor/analytics.py b/app/api/v1/vendor/analytics.py index c0fa0f8f..99e42602 100644 --- a/app/api/v1/vendor/analytics.py +++ b/app/api/v1/vendor/analytics.py @@ -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"] ), diff --git a/app/api/v1/vendor/auth.py b/app/api/v1/vendor/auth.py index 959a5f41..30527772 100644 --- a/app/api/v1/vendor/auth.py +++ b/app/api/v1/vendor/auth.py @@ -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) diff --git a/app/api/v1/vendor/customers.py b/app/api/v1/vendor/customers.py index 4de51dfd..6a88ee66 100644 --- a/app/api/v1/vendor/customers.py +++ b/app/api/v1/vendor/customers.py @@ -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) diff --git a/app/api/v1/vendor/inventory.py b/app/api/v1/vendor/inventory.py index ba443fff..42db57fc 100644 --- a/app/api/v1/vendor/inventory.py +++ b/app/api/v1/vendor/inventory.py @@ -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) diff --git a/app/api/v1/vendor/letzshop.py b/app/api/v1/vendor/letzshop.py index 373a245b..c4d10691 100644 --- a/app/api/v1/vendor/letzshop.py +++ b/app/api/v1/vendor/letzshop.py @@ -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), diff --git a/app/api/v1/vendor/products.py b/app/api/v1/vendor/products.py index 5a540dd0..af161380 100644 --- a/app/api/v1/vendor/products.py +++ b/app/api/v1/vendor/products.py @@ -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 + ) diff --git a/app/core/logging.py b/app/core/logging.py index 18757755..3066f84e 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -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__) diff --git a/app/exceptions/__init__.py b/app/exceptions/__init__.py index 7280116b..11cebb21 100644 --- a/app/exceptions/__init__.py +++ b/app/exceptions/__init__.py @@ -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, diff --git a/app/exceptions/handler.py b/app/exceptions/handler.py index e0203b02..4b659717 100644 --- a/app/exceptions/handler.py +++ b/app/exceptions/handler.py @@ -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 diff --git a/app/routes/admin_pages.py b/app/routes/admin_pages.py index 04a12992..5fb49d4d 100644 --- a/app/routes/admin_pages.py +++ b/app/routes/admin_pages.py @@ -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), diff --git a/app/services/admin_service.py b/app/services/admin_service.py index 68c31379..8a44cf9b 100644 --- a/app/services/admin_service.py +++ b/app/services/admin_service.py @@ -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 diff --git a/app/services/background_tasks_service.py b/app/services/background_tasks_service.py index c4f77f88..68c5b409 100644 --- a/app/services/background_tasks_service.py +++ b/app/services/background_tasks_service.py @@ -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"), diff --git a/app/services/company_service.py b/app/services/company_service.py index 4693ae6e..054653b9 100644 --- a/app/services/company_service.py +++ b/app/services/company_service.py @@ -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 diff --git a/app/services/content_page_service.py b/app/services/content_page_service.py index 0e1041d5..cbacc6b5 100644 --- a/app/services/content_page_service.py +++ b/app/services/content_page_service.py @@ -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() ) diff --git a/app/services/letzshop/client_service.py b/app/services/letzshop/client_service.py index 93963304..d713bc86 100644 --- a/app/services/letzshop/client_service.py +++ b/app/services/letzshop/client_service.py @@ -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 diff --git a/app/services/letzshop/credentials_service.py b/app/services/letzshop/credentials_service.py index d42a895a..1528cb07 100644 --- a/app/services/letzshop/credentials_service.py +++ b/app/services/letzshop/credentials_service.py @@ -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 diff --git a/app/services/letzshop/order_service.py b/app/services/letzshop/order_service.py index 467396b2..09018dfc 100644 --- a/app/services/letzshop/order_service.py +++ b/app/services/letzshop/order_service.py @@ -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 diff --git a/app/services/letzshop_export_service.py b/app/services/letzshop_export_service.py index 9723aaa4..513f2dd9 100644 --- a/app/services/letzshop_export_service.py +++ b/app/services/letzshop_export_service.py @@ -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, diff --git a/app/services/log_service.py b/app/services/log_service.py index c6a95f50..ec93704e 100644 --- a/app/services/log_service.py +++ b/app/services/log_service.py @@ -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() diff --git a/app/services/marketplace_import_job_service.py b/app/services/marketplace_import_job_service.py index 491cef2f..a36973c2 100644 --- a/app/services/marketplace_import_job_service.py +++ b/app/services/marketplace_import_job_service.py @@ -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( diff --git a/app/services/marketplace_product_service.py b/app/services/marketplace_product_service.py index 5fe6724e..dad389e4 100644 --- a/app/services/marketplace_product_service.py +++ b/app/services/marketplace_product_service.py @@ -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, } diff --git a/app/services/stats_service.py b/app/services/stats_service.py index e929e8fe..a5a66716 100644 --- a/app/services/stats_service.py +++ b/app/services/stats_service.py @@ -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, } diff --git a/app/services/test_runner_service.py b/app/services/test_runner_service.py index 275db6a3..573299d2 100644 --- a/app/services/test_runner_service.py +++ b/app/services/test_runner_service.py @@ -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) diff --git a/app/services/vendor_product_service.py b/app/services/vendor_product_service.py index 87b33761..6c07e22f 100644 --- a/app/services/vendor_product_service.py +++ b/app/services/vendor_product_service.py @@ -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, } diff --git a/app/services/vendor_service.py b/app/services/vendor_service.py index 42bdf1a6..754647f1 100644 --- a/app/services/vendor_service.py +++ b/app/services/vendor_service.py @@ -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 diff --git a/app/tasks/test_runner_tasks.py b/app/tasks/test_runner_tasks.py index cd626b7e..63afd900 100644 --- a/app/tasks/test_runner_tasks.py +++ b/app/tasks/test_runner_tasks.py @@ -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 diff --git a/app/templates/shared/macros/language_selector.html b/app/templates/shared/macros/language_selector.html index 8449b637..26b0f1c1 100644 --- a/app/templates/shared/macros/language_selector.html +++ b/app/templates/shared/macros/language_selector.html @@ -58,7 +58,7 @@ {% set positions = {'left': 'left-0', 'right': 'right-0'} %} {# Uses languageSelector() function per LANG-002 architecture rule #}