From 8a367077e168b600a06930c45801a9ab89db0e3c Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Thu, 4 Dec 2025 22:24:45 +0100 Subject: [PATCH] refactor: migrate vendor APIs to token-based context and consolidate architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Vendor-in-Token Architecture (Complete Migration) - Migrate all vendor API endpoints from require_vendor_context() to token_vendor_id - Update permission dependencies to extract vendor from JWT token - Add vendor exceptions: VendorAccessDeniedException, VendorOwnerOnlyException, InsufficientVendorPermissionsException - Shop endpoints retain require_vendor_context() for URL-based detection - Add AUTH-004 architecture rule enforcing vendor context patterns - Fix marketplace router missing /marketplace prefix ## Exception Pattern Fixes (API-003/API-004) - Services raise domain exceptions, endpoints let them bubble up - Add code_quality and content_page exception modules - Move business logic from endpoints to services (admin, auth, content_page) - Fix exception handling in admin, shop, and vendor endpoints ## Tailwind CSS Consolidation - Consolidate CSS to per-area files (admin, vendor, shop, platform) - Remove shared/cdn-fallback.html and shared/css/tailwind.min.css - Update all templates to use area-specific Tailwind output files - Remove Node.js config (package.json, postcss.config.js, tailwind.config.js) ## Documentation & Cleanup - Update vendor-in-token-architecture.md with completed migration status - Update architecture-rules.md with new rules - Move migration docs to docs/development/migration/ - Remove duplicate/obsolete documentation files - Merge pytest.ini settings into pyproject.toml 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .architecture-rules.yaml | 70 +- Makefile | 45 +- app/api/deps.py | 100 +- app/api/v1/admin/auth.py | 6 +- app/api/v1/admin/code_quality.py | 179 +- app/api/v1/admin/content_pages.py | 46 +- app/api/v1/admin/logs.py | 45 +- app/api/v1/admin/settings.py | 15 +- app/api/v1/admin/users.py | 209 +- app/api/v1/shop/auth.py | 50 +- app/api/v1/shop/cart.py | 83 +- app/api/v1/shop/content_pages.py | 7 +- app/api/v1/shop/products.py | 53 +- app/api/v1/vendor/analytics.py | 16 +- app/api/v1/vendor/auth.py | 83 +- app/api/v1/vendor/content_pages.py | 63 +- app/api/v1/vendor/customers.py | 25 +- app/api/v1/vendor/dashboard.py | 18 +- app/api/v1/vendor/inventory.py | 51 +- app/api/v1/vendor/marketplace.py | 37 +- app/api/v1/vendor/media.py | 29 +- app/api/v1/vendor/notifications.py | 33 +- app/api/v1/vendor/payments.py | 29 +- app/api/v1/vendor/products.py | 71 +- app/api/v1/vendor/profile.py | 21 +- app/api/v1/vendor/settings.py | 25 +- app/exceptions/__init__.py | 37 +- app/exceptions/admin.py | 34 + app/exceptions/code_quality.py | 95 + app/exceptions/content_page.py | 82 + app/exceptions/vendor.py | 40 + app/services/admin_service.py | 242 + app/services/auth_service.py | 79 + app/services/code_quality_service.py | 13 +- app/services/content_page_service.py | 212 + app/templates/admin/base.html | 8 +- app/templates/admin/login.html | 2 +- app/templates/admin/monitoring.html | 3 +- app/templates/admin/test-auth-flow.html | 884 +- .../admin/test-vendors-users-migration.html | 1257 +- app/templates/platform/base.html | 47 +- app/templates/shared/cdn-fallback.html | 28 - app/templates/shop/account/addresses.html | 26 +- .../shop/account/forgot-password.html | 7 +- app/templates/shop/account/login.html | 7 +- app/templates/shop/account/orders.html | 26 +- app/templates/shop/account/profile.html | 26 +- app/templates/shop/account/register.html | 7 +- app/templates/shop/base.html | 5 +- app/templates/shop/checkout.html | 26 +- app/templates/shop/errors/base.html | 201 +- app/templates/shop/search.html | 26 +- app/templates/vendor/base.html | 8 +- app/templates/vendor/login.html | 2 +- docs/backend/vendor-in-token-architecture.md | 50 +- docs/development/architecture-rules.md | 95 +- .../migration/database-migrations.md | 4 +- .../makefile-refactoring-complete.md | 0 .../migration/tailwind-migration-plan.md | 402 + docs/frontend/admin/page-templates.md | 24 +- docs/frontend/shared/sidebar.md | 677 +- docs/frontend/shared/ui-components.md | 586 +- docs/frontend/tailwind-css.md | 376 +- docs/frontend/tailwind-migration-plan.md | 361 - docs/frontend/ui-components.md | 387 - middleware/vendor_context.py | 6 +- mkdocs.yml | 10 +- models/schema/admin.py | 35 + models/schema/auth.py | 56 + models/schema/product.py | 14 + package.json | 55 - postcss.config.js | 9 - pyproject.toml | 41 +- pytest.ini | 64 - scripts/validate_architecture.py | 78 +- static/admin/css/tailwind.css | 258 +- static/admin/css/tailwind.output.css | 69357 +--------------- static/platform/css/tailwind.css | 286 + static/platform/css/tailwind.output.css | 4231 + static/shared/css/tailwind.min.css | 1 - static/shop/css/tailwind.css | 274 + static/shop/css/tailwind.output.css | 4345 + static/vendor/css/tailwind.css | 254 + static/vendor/css/tailwind.output.css | 69357 +--------------- tailwind.config.js | 233 - 85 files changed, 21787 insertions(+), 134978 deletions(-) create mode 100644 app/exceptions/code_quality.py create mode 100644 app/exceptions/content_page.py delete mode 100644 app/templates/shared/cdn-fallback.html rename docs/development/{ => migration}/makefile-refactoring-complete.md (100%) create mode 100644 docs/development/migration/tailwind-migration-plan.md delete mode 100644 docs/frontend/tailwind-migration-plan.md delete mode 100644 docs/frontend/ui-components.md delete mode 100644 package.json delete mode 100644 postcss.config.js delete mode 100644 pytest.ini create mode 100644 static/platform/css/tailwind.css create mode 100644 static/platform/css/tailwind.output.css delete mode 100644 static/shared/css/tailwind.min.css create mode 100644 static/shop/css/tailwind.css create mode 100644 static/shop/css/tailwind.output.css create mode 100644 static/vendor/css/tailwind.css delete mode 100644 tailwind.config.js diff --git a/.architecture-rules.yaml b/.architecture-rules.yaml index 5e85c959..3ce7fde2 100644 --- a/.architecture-rules.yaml +++ b/.architecture-rules.yaml @@ -95,10 +95,28 @@ api_endpoint_rules: description: | Protected endpoints must use Depends() for authentication. Use get_current_user, get_current_admin, etc. + + Auto-excluded files: + - */auth.py - Authentication endpoints (login, logout, register) are intentionally public + + Public endpoint markers (place on line before or after decorator): + - # public - Descriptive marker for intentionally unauthenticated endpoints + - # noqa: API-004 - Standard noqa style to suppress warning + + Example: + # public - Stripe webhook receives external callbacks + @router.post("/webhook/stripe") + def stripe_webhook(request: Request): + ... pattern: file_pattern: "app/api/v1/**/*.py" required_if_not_public: - "Depends(get_current_" + auto_exclude_files: + - "*/auth.py" + public_markers: + - "# public" + - "# noqa: api-004" - id: "API-005" name: "Multi-tenant endpoints must scope queries to vendor_id" @@ -466,11 +484,30 @@ template_rules: - id: "TPL-001" name: "Admin templates must extend admin/base.html" severity: "error" - description: "All admin templates must extend the base template for consistency" + description: | + All admin templates must extend the base template for consistency. + + Auto-excluded files: + - login.html - Standalone login page (no sidebar/navigation) + - errors/*.html - Error pages extend errors/base.html instead + - test-*.html - Test/development templates + + Standalone template markers (place in first 5 lines): + - {# standalone #} - Mark template as intentionally standalone + - {# noqa: TPL-001 #} - Standard noqa style to suppress error + - - HTML comment style pattern: file_pattern: "app/templates/admin/**/*.html" required_patterns: - "{% extends ['\"]admin/base\\.html['\"] %}" + auto_exclude_files: + - "login.html" + - "errors/" + - "test-" + standalone_markers: + - "{# standalone #}" + - "{# noqa: tpl-001 #}" + - "" exceptions: - "base.html" - "partials/" @@ -660,6 +697,37 @@ auth_rules: file_pattern: "app/services/auth_service.py" required: "bcrypt" + - id: "AUTH-004" + name: "Vendor context pattern - use appropriate dependency for endpoint type" + severity: "error" + description: | + Two vendor context patterns exist - use the appropriate one: + + 1. SHOP ENDPOINTS (public, no authentication required): + - Use: vendor: Vendor = Depends(require_vendor_context()) + - Vendor is detected from URL/subdomain/domain + - File pattern: app/api/v1/shop/**/*.py + - Mark as public with: # public + + 2. VENDOR API ENDPOINTS (authenticated): + - Use: current_user.token_vendor_id from JWT token + - Or use permission dependencies: require_vendor_permission(), require_vendor_owner + - These dependencies get vendor from token and set request.state.vendor + - File pattern: app/api/v1/vendor/**/*.py + + DEPRECATED for vendor APIs: + - require_vendor_context() - only for shop endpoints + - getattr(request.state, "vendor", None) without permission dependency + + See: docs/backend/vendor-in-token-architecture.md + pattern: + file_pattern: "app/api/v1/vendor/**/*.py" + anti_patterns: + - "require_vendor_context\\(\\)" + file_pattern: "app/api/v1/shop/**/*.py" + required_patterns: + - "require_vendor_context\\(\\)|# public" + # ============================================================================ # CODE QUALITY RULES # ============================================================================ diff --git a/Makefile b/Makefile index f85c6422..89df468e 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Wizamart Multi-Tenant E-Commerce Platform Makefile # Cross-platform compatible (Windows & Linux) -.PHONY: install install-dev install-docs install-all dev test test-coverage lint format check docker-build docker-up docker-down clean help npm-install tailwind-dev tailwind-build arch-check arch-check-file arch-check-object +.PHONY: install install-dev install-docs install-all dev test test-coverage lint format check docker-build docker-up docker-down clean help tailwind-install tailwind-dev tailwind-build tailwind-watch arch-check arch-check-file arch-check-object # Detect OS ifeq ($(OS),Windows_NT) @@ -282,29 +282,40 @@ docs-check: $(PYTHON) -m mkdocs build --strict --verbose # ============================================================================= -# FRONTEND / TAILWIND CSS +# FRONTEND / TAILWIND CSS (Standalone CLI - No Node.js Required) # ============================================================================= -npm-install: - @echo "Installing npm dependencies..." - npm install - @echo "npm dependencies installed" +# Tailwind CLI binary location +TAILWIND_CLI := $(HOME)/.local/bin/tailwindcss + +tailwind-install: + @echo "Installing Tailwind CSS standalone CLI..." + @mkdir -p $(HOME)/.local/bin + @curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 + @chmod +x tailwindcss-linux-x64 + @mv tailwindcss-linux-x64 $(TAILWIND_CLI) + @echo "Tailwind CLI installed: $$($(TAILWIND_CLI) --help | head -1)" tailwind-dev: - @echo "Building Tailwind CSS (development - all classes)..." - npm run tailwind:admin - npm run tailwind:vendor - @echo "Tailwind CSS built (admin + vendor)" + @echo "Building Tailwind CSS (development)..." + $(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css + $(TAILWIND_CLI) -i static/vendor/css/tailwind.css -o static/vendor/css/tailwind.output.css + $(TAILWIND_CLI) -i static/shop/css/tailwind.css -o static/shop/css/tailwind.output.css + $(TAILWIND_CLI) -i static/platform/css/tailwind.css -o static/platform/css/tailwind.output.css + @echo "Tailwind CSS built (admin + vendor + shop + platform)" tailwind-build: - @echo "Building Tailwind CSS (production - purged)..." - npm run build - @echo "Tailwind CSS built for production" + @echo "Building Tailwind CSS (production - minified)..." + $(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --minify + $(TAILWIND_CLI) -i static/vendor/css/tailwind.css -o static/vendor/css/tailwind.output.css --minify + $(TAILWIND_CLI) -i static/shop/css/tailwind.css -o static/shop/css/tailwind.output.css --minify + $(TAILWIND_CLI) -i static/platform/css/tailwind.css -o static/platform/css/tailwind.output.css --minify + @echo "Tailwind CSS built and minified for production" tailwind-watch: @echo "Watching Tailwind CSS for changes..." @echo "Note: This watches admin CSS only. Run in separate terminal." - npx tailwindcss build static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --watch + $(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --watch # ============================================================================= # DOCKER @@ -425,10 +436,10 @@ help: @echo " docs-serve - Start documentation server" @echo " docs-build - Build documentation" @echo "" - @echo "=== FRONTEND / TAILWIND ===" - @echo " npm-install - Install npm dependencies" + @echo "=== FRONTEND / TAILWIND (No Node.js Required) ===" + @echo " tailwind-install - Install Tailwind standalone CLI" @echo " tailwind-dev - Build Tailwind CSS (development)" - @echo " tailwind-build - Build Tailwind CSS (production)" + @echo " tailwind-build - Build Tailwind CSS (production, minified)" @echo " tailwind-watch - Watch and rebuild on changes" @echo "" @echo "=== DOCKER ===" diff --git a/app/api/deps.py b/app/api/deps.py index d597a6a6..ae6026b6 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -42,10 +42,14 @@ from app.core.database import get_db from app.exceptions import ( AdminRequiredException, InsufficientPermissionsException, + InsufficientVendorPermissionsException, InvalidTokenException, UnauthorizedVendorAccessException, + VendorAccessDeniedException, VendorNotFoundException, + VendorOwnerOnlyException, ) +from app.services.vendor_service import vendor_service from middleware.auth import AuthManager from middleware.rate_limiter import RateLimiter from models.database.user import User @@ -545,12 +549,16 @@ def require_vendor_permission(permission: str): """ Dependency factory to require a specific vendor permission. + Uses token_vendor_id from JWT token (authenticated vendor API pattern). + The vendor object is loaded and stored in request.state.vendor for endpoint use. + Usage: @router.get("/products") def list_products( - vendor: Vendor = Depends(get_vendor_from_code), + request: Request, user: User = Depends(require_vendor_permission(VendorPermissions.PRODUCTS_VIEW.value)) ): + vendor = request.state.vendor # Vendor is set by this dependency ... """ @@ -559,10 +567,17 @@ def require_vendor_permission(permission: str): db: Session = Depends(get_db), current_user: User = Depends(get_current_vendor_from_cookie_or_header), ) -> User: - # Get vendor from request state (set by middleware) - vendor = getattr(request.state, "vendor", None) - if not vendor: - raise VendorAccessDeniedException("No vendor context") + # Get vendor ID from JWT token + if not hasattr(current_user, "token_vendor_id"): + raise InvalidTokenException("Token missing vendor information. Please login again.") + + vendor_id = current_user.token_vendor_id + + # Load vendor from database (raises VendorNotFoundException if not found) + vendor = vendor_service.get_vendor_by_id(db, vendor_id) + + # Store vendor in request state for endpoint use + request.state.vendor = vendor # Check if user has permission if not current_user.has_vendor_permission(vendor.id, permission): @@ -584,16 +599,29 @@ def require_vendor_owner( """ Dependency to require vendor owner role. + Uses token_vendor_id from JWT token (authenticated vendor API pattern). + The vendor object is loaded and stored in request.state.vendor for endpoint use. + Usage: @router.delete("/team/{user_id}") def remove_team_member( + request: Request, user: User = Depends(require_vendor_owner) ): + vendor = request.state.vendor # Vendor is set by this dependency ... """ - vendor = getattr(request.state, "vendor", None) - if not vendor: - raise VendorAccessDeniedException("No vendor context") + # Get vendor ID from JWT token + if not hasattr(current_user, "token_vendor_id"): + raise InvalidTokenException("Token missing vendor information. Please login again.") + + vendor_id = current_user.token_vendor_id + + # Load vendor from database (raises VendorNotFoundException if not found) + vendor = vendor_service.get_vendor_by_id(db, vendor_id) + + # Store vendor in request state for endpoint use + request.state.vendor = vendor if not current_user.is_owner_of(vendor.id): raise VendorOwnerOnlyException( @@ -608,14 +636,19 @@ def require_any_vendor_permission(*permissions: str): """ Dependency factory to require ANY of the specified permissions. + Uses token_vendor_id from JWT token (authenticated vendor API pattern). + The vendor object is loaded and stored in request.state.vendor for endpoint use. + Usage: @router.get("/dashboard") def dashboard( + request: Request, user: User = Depends(require_any_vendor_permission( VendorPermissions.DASHBOARD_VIEW.value, VendorPermissions.REPORTS_VIEW.value )) ): + vendor = request.state.vendor # Vendor is set by this dependency ... """ @@ -624,9 +657,17 @@ def require_any_vendor_permission(*permissions: str): db: Session = Depends(get_db), current_user: User = Depends(get_current_vendor_from_cookie_or_header), ) -> User: - vendor = getattr(request.state, "vendor", None) - if not vendor: - raise VendorAccessDeniedException("No vendor context") + # Get vendor ID from JWT token + if not hasattr(current_user, "token_vendor_id"): + raise InvalidTokenException("Token missing vendor information. Please login again.") + + vendor_id = current_user.token_vendor_id + + # Load vendor from database (raises VendorNotFoundException if not found) + vendor = vendor_service.get_vendor_by_id(db, vendor_id) + + # Store vendor in request state for endpoint use + request.state.vendor = vendor # Check if user has ANY of the required permissions has_permission = any( @@ -648,14 +689,19 @@ def require_all_vendor_permissions(*permissions: str): """ Dependency factory to require ALL of the specified permissions. + Uses token_vendor_id from JWT token (authenticated vendor API pattern). + The vendor object is loaded and stored in request.state.vendor for endpoint use. + Usage: @router.post("/products/bulk-delete") def bulk_delete_products( + request: Request, user: User = Depends(require_all_vendor_permissions( VendorPermissions.PRODUCTS_VIEW.value, VendorPermissions.PRODUCTS_DELETE.value )) ): + vendor = request.state.vendor # Vendor is set by this dependency ... """ @@ -664,9 +710,17 @@ def require_all_vendor_permissions(*permissions: str): db: Session = Depends(get_db), current_user: User = Depends(get_current_vendor_from_cookie_or_header), ) -> User: - vendor = getattr(request.state, "vendor", None) - if not vendor: - raise VendorAccessDeniedException("No vendor context") + # Get vendor ID from JWT token + if not hasattr(current_user, "token_vendor_id"): + raise InvalidTokenException("Token missing vendor information. Please login again.") + + vendor_id = current_user.token_vendor_id + + # Load vendor from database (raises VendorNotFoundException if not found) + vendor = vendor_service.get_vendor_by_id(db, vendor_id) + + # Store vendor in request state for endpoint use + request.state.vendor = vendor # Check if user has ALL required permissions missing_permissions = [ @@ -688,17 +742,29 @@ def require_all_vendor_permissions(*permissions: str): def get_user_permissions( request: Request, + db: Session = Depends(get_db), current_user: User = Depends(get_current_vendor_from_cookie_or_header), ) -> list: """ Get all permissions for current user in current vendor. - Returns empty list if no vendor context. + Uses token_vendor_id from JWT token (authenticated vendor API pattern). + Also sets request.state.vendor for endpoint use. + + Returns empty list if no vendor context in token. """ - vendor = getattr(request.state, "vendor", None) - if not vendor: + # Get vendor ID from JWT token + if not hasattr(current_user, "token_vendor_id"): return [] + vendor_id = current_user.token_vendor_id + + # Load vendor from database + vendor = vendor_service.get_vendor_by_id(db, vendor_id) + + # Store vendor in request state for endpoint use + request.state.vendor = vendor + # If owner, return all permissions if current_user.is_owner_of(vendor.id): from app.core.permissions import VendorPermissions diff --git a/app/api/v1/admin/auth.py b/app/api/v1/admin/auth.py index a739e5cd..fb48d185 100644 --- a/app/api/v1/admin/auth.py +++ b/app/api/v1/admin/auth.py @@ -20,7 +20,7 @@ from app.core.environment import should_use_secure_cookies from app.exceptions import InvalidCredentialsException from app.services.auth_service import auth_service from models.database.user import User -from models.schema.auth import LoginResponse, UserLogin, UserResponse +from models.schema.auth import LoginResponse, LogoutResponse, UserLogin, UserResponse router = APIRouter(prefix="/auth") logger = logging.getLogger(__name__) @@ -97,7 +97,7 @@ def get_current_admin(current_user: User = Depends(get_current_admin_api)): return current_user -@router.post("/logout") +@router.post("/logout", response_model=LogoutResponse) def admin_logout(response: Response): """ Admin logout endpoint. @@ -115,4 +115,4 @@ def admin_logout(response: Response): logger.debug("Deleted admin_token cookie") - return {"message": "Logged out successfully"} + return LogoutResponse(message="Logged out successfully") diff --git a/app/api/v1/admin/code_quality.py b/app/api/v1/admin/code_quality.py index 96dcd049..f4b376c7 100644 --- a/app/api/v1/admin/code_quality.py +++ b/app/api/v1/admin/code_quality.py @@ -5,12 +5,13 @@ RESTful API for architecture validation and violation management from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db +from app.exceptions import ViolationNotFoundException from app.services.code_quality_service import code_quality_service from models.database.user import User @@ -136,25 +137,23 @@ async def trigger_scan( Trigger a new architecture scan Requires authentication. Runs the validator script and stores results. + Domain exceptions (ScanTimeoutException, ScanParseException) bubble up to global handler. """ - try: - scan = code_quality_service.run_scan( - db, triggered_by=f"manual:{current_user.username}" - ) + scan = code_quality_service.run_scan( + db, triggered_by=f"manual:{current_user.username}" + ) - return ScanResponse( - id=scan.id, - timestamp=scan.timestamp.isoformat(), - total_files=scan.total_files, - total_violations=scan.total_violations, - errors=scan.errors, - warnings=scan.warnings, - duration_seconds=scan.duration_seconds, - triggered_by=scan.triggered_by, - git_commit_hash=scan.git_commit_hash, - ) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Scan failed: {str(e)}") + return ScanResponse( + id=scan.id, + timestamp=scan.timestamp.isoformat(), + total_files=scan.total_files, + total_violations=scan.total_violations, + errors=scan.errors, + warnings=scan.warnings, + duration_seconds=scan.duration_seconds, + triggered_by=scan.triggered_by, + git_commit_hash=scan.git_commit_hash, + ) @router.get("/scans", response_model=list[ScanResponse]) @@ -269,7 +268,7 @@ async def get_violation( violation = code_quality_service.get_violation_by_id(db, violation_id) if not violation: - raise HTTPException(status_code=404, detail="Violation not found") + raise ViolationNotFoundException(violation_id) # Format assignments assignments = [ @@ -331,29 +330,26 @@ async def assign_violation( Updates violation status to 'assigned'. """ - try: - assignment = code_quality_service.assign_violation( - db, - violation_id=violation_id, - user_id=request.user_id, - assigned_by=current_user.id, - due_date=request.due_date, - priority=request.priority, - ) + assignment = code_quality_service.assign_violation( + db, + violation_id=violation_id, + user_id=request.user_id, + assigned_by=current_user.id, + due_date=request.due_date, + priority=request.priority, + ) - return { - "id": assignment.id, - "violation_id": assignment.violation_id, - "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 - ), - "priority": assignment.priority, - } - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) + return { + "id": assignment.id, + "violation_id": assignment.violation_id, + "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 + ), + "priority": assignment.priority, + } @router.post("/violations/{violation_id}/resolve") @@ -367,28 +363,24 @@ async def resolve_violation( Mark violation as resolved Records resolution timestamp and user. + ViolationNotFoundException bubbles up if violation doesn't exist. """ - try: - violation = code_quality_service.resolve_violation( - db, - violation_id=violation_id, - resolved_by=current_user.id, - resolution_note=request.resolution_note, - ) + violation = code_quality_service.resolve_violation( + db, + violation_id=violation_id, + resolved_by=current_user.id, + resolution_note=request.resolution_note, + ) - return { - "id": violation.id, - "status": violation.status, - "resolved_at": ( - violation.resolved_at.isoformat() if violation.resolved_at else None - ), - "resolved_by": violation.resolved_by, - "resolution_note": violation.resolution_note, - } - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) + return { + "id": violation.id, + "status": violation.status, + "resolved_at": ( + violation.resolved_at.isoformat() if violation.resolved_at else None + ), + "resolved_by": violation.resolved_by, + "resolution_note": violation.resolution_note, + } @router.post("/violations/{violation_id}/ignore") @@ -402,28 +394,24 @@ async def ignore_violation( Mark violation as ignored (won't fix) Records reason for ignoring. + ViolationNotFoundException bubbles up if violation doesn't exist. """ - try: - violation = code_quality_service.ignore_violation( - db, - violation_id=violation_id, - ignored_by=current_user.id, - reason=request.reason, - ) + violation = code_quality_service.ignore_violation( + db, + violation_id=violation_id, + ignored_by=current_user.id, + reason=request.reason, + ) - return { - "id": violation.id, - "status": violation.status, - "resolved_at": ( - violation.resolved_at.isoformat() if violation.resolved_at else None - ), - "resolved_by": violation.resolved_by, - "resolution_note": violation.resolution_note, - } - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) + return { + "id": violation.id, + "status": violation.status, + "resolved_at": ( + violation.resolved_at.isoformat() if violation.resolved_at else None + ), + "resolved_by": violation.resolved_by, + "resolution_note": violation.resolution_note, + } @router.post("/violations/{violation_id}/comments") @@ -438,23 +426,20 @@ async def add_comment( For team collaboration and discussion. """ - try: - comment = code_quality_service.add_comment( - db, - violation_id=violation_id, - user_id=current_user.id, - comment=request.comment, - ) + comment = code_quality_service.add_comment( + db, + violation_id=violation_id, + user_id=current_user.id, + comment=request.comment, + ) - return { - "id": comment.id, - "violation_id": comment.violation_id, - "user_id": comment.user_id, - "comment": comment.comment, - "created_at": comment.created_at.isoformat(), - } - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) + return { + "id": comment.id, + "violation_id": comment.violation_id, + "user_id": comment.user_id, + "comment": comment.comment, + "created_at": comment.created_at.isoformat(), + } @router.get("/stats", response_model=DashboardStatsResponse) diff --git a/app/api/v1/admin/content_pages.py b/app/api/v1/admin/content_pages.py index 6305a7b7..49551a62 100644 --- a/app/api/v1/admin/content_pages.py +++ b/app/api/v1/admin/content_pages.py @@ -10,7 +10,7 @@ Platform administrators can: import logging -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, Field from sqlalchemy.orm import Session @@ -170,28 +170,9 @@ def list_all_pages( Filter by vendor_id to see specific vendor pages. """ - if vendor_id: - pages = content_page_service.list_all_vendor_pages( - db, vendor_id=vendor_id, include_unpublished=include_unpublished - ) - else: - # Get all pages (both platform and vendor) - from sqlalchemy import and_ - - from models.database.content_page import ContentPage - - filters = [] - if not include_unpublished: - filters.append(ContentPage.is_published == True) - - pages = ( - db.query(ContentPage) - .filter(and_(*filters) if filters else True) - .order_by( - ContentPage.vendor_id, ContentPage.display_order, ContentPage.title - ) - .all() - ) + pages = content_page_service.list_all_pages( + db, vendor_id=vendor_id, include_unpublished=include_unpublished + ) return [page.to_dict() for page in pages] @@ -203,11 +184,7 @@ def get_page( db: Session = Depends(get_db), ): """Get a specific content page by ID.""" - page = content_page_service.get_page_by_id(db, page_id) - - if not page: - raise HTTPException(status_code=404, detail="Content page not found") - + page = content_page_service.get_page_by_id_or_raise(db, page_id) return page.to_dict() @@ -219,7 +196,7 @@ def update_page( db: Session = Depends(get_db), ): """Update a content page (platform or vendor).""" - page = content_page_service.update_page( + page = content_page_service.update_page_or_raise( db, page_id=page_id, title=page_data.title, @@ -234,10 +211,6 @@ def update_page( display_order=page_data.display_order, updated_by=current_user.id, ) - - if not page: - raise HTTPException(status_code=404, detail="Content page not found") - return page.to_dict() @@ -248,9 +221,4 @@ def delete_page( db: Session = Depends(get_db), ): """Delete a content page.""" - success = content_page_service.delete_page(db, page_id) - - if not success: - raise HTTPException(status_code=404, detail="Content page not found") - - return + content_page_service.delete_page_or_raise(db, page_id) diff --git a/app/api/v1/admin/logs.py b/app/api/v1/admin/logs.py index dad0bd4b..7e1aaadc 100644 --- a/app/api/v1/admin/logs.py +++ b/app/api/v1/admin/logs.py @@ -18,6 +18,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db from app.core.logging import reload_log_level +from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException from app.services.admin_audit_service import admin_audit_service from app.services.admin_settings_service import admin_settings_service from app.services.log_service import log_service @@ -26,8 +27,12 @@ from models.schema.admin import ( ApplicationLogFilters, ApplicationLogListResponse, FileLogResponse, + LogCleanupResponse, + LogDeleteResponse, + LogFileListResponse, LogSettingsResponse, LogSettingsUpdate, + LogSettingsUpdateResponse, LogStatistics, ) @@ -87,7 +92,7 @@ def get_log_statistics( return log_service.get_log_statistics(db, days) -@router.delete("/database/cleanup") +@router.delete("/database/cleanup", response_model=LogCleanupResponse) def cleanup_old_logs( retention_days: int = Query(30, ge=1, le=365), confirm: bool = Query(False, description="Must be true to confirm cleanup"), @@ -99,13 +104,8 @@ def cleanup_old_logs( Requires confirmation parameter. """ - from fastapi import HTTPException - if not confirm: - raise HTTPException( - status_code=400, - detail="Cleanup requires confirmation parameter: confirm=true", - ) + raise ConfirmationRequiredException(operation="cleanup_logs") deleted_count = log_service.cleanup_old_logs(db, retention_days) @@ -119,13 +119,13 @@ def cleanup_old_logs( details={"retention_days": retention_days, "deleted_count": deleted_count}, ) - return { - "message": f"Deleted {deleted_count} log entries older than {retention_days} days", - "deleted_count": deleted_count, - } + return LogCleanupResponse( + message=f"Deleted {deleted_count} log entries older than {retention_days} days", + deleted_count=deleted_count, + ) -@router.delete("/database/{log_id}") +@router.delete("/database/{log_id}", response_model=LogDeleteResponse) def delete_log( log_id: int, db: Session = Depends(get_db), @@ -144,7 +144,7 @@ def delete_log( details={}, ) - return {"message": message} + return LogDeleteResponse(message=message) # ============================================================================ @@ -152,7 +152,7 @@ def delete_log( # ============================================================================ -@router.get("/files") +@router.get("/files", response_model=LogFileListResponse) def list_log_files( current_admin: User = Depends(get_current_admin_api), ): @@ -161,7 +161,7 @@ def list_log_files( Returns list of log files with size and modification date. """ - return {"files": log_service.list_log_files()} + return LogFileListResponse(files=log_service.list_log_files()) @router.get("/files/{filename}", response_model=FileLogResponse) @@ -191,7 +191,6 @@ def download_log_file( from pathlib import Path from app.core.config import settings - from fastapi import HTTPException from fastapi.responses import FileResponse # Determine log file path @@ -202,7 +201,7 @@ def download_log_file( log_file = Path("logs") / filename if not log_file.exists(): - raise HTTPException(status_code=404, detail=f"Log file '{filename}' not found") + raise ResourceNotFoundException(resource_type="LogFile", identifier=filename) # Log action from app.core.database import get_db @@ -267,7 +266,7 @@ def get_log_settings( ) -@router.put("/settings") +@router.put("/settings", response_model=LogSettingsUpdateResponse) def update_log_settings( settings_update: LogSettingsUpdate, db: Session = Depends(get_db), @@ -335,8 +334,8 @@ def update_log_settings( details={"updated_fields": updated}, ) - return { - "message": "Log settings updated successfully", - "updated_fields": updated, - "note": "Log level changes are applied immediately. File rotation settings require restart.", - } + return LogSettingsUpdateResponse( + message="Log settings updated successfully", + updated_fields=updated, + note="Log level changes are applied immediately. File rotation settings require restart.", + ) diff --git a/app/api/v1/admin/settings.py b/app/api/v1/admin/settings.py index f0806e90..cc4ff324 100644 --- a/app/api/v1/admin/settings.py +++ b/app/api/v1/admin/settings.py @@ -15,6 +15,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db +from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException from app.services.admin_audit_service import admin_audit_service from app.services.admin_settings_service import admin_settings_service from models.database.user import User @@ -78,9 +79,9 @@ def get_setting( setting = admin_settings_service.get_setting_by_key(db, key) if not setting: - from fastapi import HTTPException - - raise HTTPException(status_code=404, detail=f"Setting '{key}' not found") + raise ResourceNotFoundException( + resource_type="Setting", identifier=key + ) return AdminSettingResponse.model_validate(setting) @@ -184,12 +185,10 @@ def delete_setting( Requires confirmation parameter. WARNING: Deleting settings may affect platform functionality. """ - from fastapi import HTTPException - if not confirm: - raise HTTPException( - status_code=400, - detail="Deletion requires confirmation parameter: confirm=true", + raise ConfirmationRequiredException( + operation="delete_setting", + message="Deletion requires confirmation parameter: confirm=true", ) message = admin_settings_service.delete_setting( diff --git a/app/api/v1/admin/users.py b/app/api/v1/admin/users.py index ba9416ce..0ab33f6d 100644 --- a/app/api/v1/admin/users.py +++ b/app/api/v1/admin/users.py @@ -1,25 +1,30 @@ # app/api/v1/admin/users.py """ User management endpoints for admin. + +All endpoints use the admin_service for business logic. +Domain exceptions are raised by the service and converted to HTTP responses +by the global exception handler. """ import logging -import math -from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query -from sqlalchemy.orm import Session, joinedload +from fastapi import APIRouter, Body, Depends, Path, Query +from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db from app.services.admin_service import admin_service -from middleware.auth import AuthManager from app.services.stats_service import stats_service from models.database.user import User from models.schema.auth import ( UserCreate, + UserDeleteResponse, UserDetailResponse, UserListResponse, UserResponse, + UserSearchResponse, + UserStatusToggleResponse, UserUpdate, ) @@ -38,31 +43,19 @@ def get_all_users( current_admin: User = Depends(get_current_admin_api), ): """Get paginated list of all users (Admin only).""" - query = db.query(User) - - # Apply filters - if search: - search_term = f"%{search.lower()}%" - query = query.filter( - (User.username.ilike(search_term)) - | (User.email.ilike(search_term)) - | (User.first_name.ilike(search_term)) - | (User.last_name.ilike(search_term)) - ) - - if role: - query = query.filter(User.role == role) - + # Convert string params to proper types + is_active_bool = None if is_active: - query = query.filter(User.is_active == (is_active.lower() == "true")) + is_active_bool = is_active.lower() == "true" - # Get total count - total = query.count() - pages = math.ceil(total / per_page) if total > 0 else 1 - - # Apply pagination - skip = (page - 1) * per_page - users = query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all() + users, total, pages = admin_service.list_users( + db=db, + page=page, + per_page=per_page, + search=search if search else None, + role=role if role else None, + is_active=is_active_bool, + ) return UserListResponse( items=[UserResponse.model_validate(user) for user in users], @@ -80,30 +73,16 @@ def create_user( current_admin: User = Depends(get_current_admin_api), ): """Create a new user (Admin only).""" - # Check if email exists - if db.query(User).filter(User.email == user_data.email).first(): - raise HTTPException(status_code=400, detail="Email already registered") - - # Check if username exists - if db.query(User).filter(User.username == user_data.username).first(): - raise HTTPException(status_code=400, detail="Username already taken") - - # Create user - auth_manager = AuthManager() - user = User( + user = admin_service.create_user( + db=db, email=user_data.email, username=user_data.username, - hashed_password=auth_manager.hash_password(user_data.password), + password=user_data.password, first_name=user_data.first_name, last_name=user_data.last_name, role=user_data.role, - is_active=True, + current_admin_id=current_admin.id, ) - db.add(user) - db.commit() - db.refresh(user) - - logger.info(f"Admin {current_admin.username} created user {user.username}") return UserDetailResponse( id=user.id, @@ -118,8 +97,8 @@ def create_user( last_name=user.last_name, full_name=user.full_name, is_email_verified=user.is_email_verified, - owned_companies_count=len(user.owned_companies), - vendor_memberships_count=len(user.vendor_memberships), + 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, ) @@ -132,7 +111,7 @@ def get_user_statistics( return stats_service.get_user_statistics(db) -@router.get("/search") +@router.get("/search", response_model=UserSearchResponse) def search_users( q: str = Query(..., min_length=2, description="Search query (username or email)"), limit: int = Query(10, ge=1, le=50), @@ -144,25 +123,8 @@ def search_users( Used for autocomplete in ownership transfer. """ - search_term = f"%{q.lower()}%" - users = ( - db.query(User) - .filter((User.username.ilike(search_term)) | (User.email.ilike(search_term))) - .limit(limit) - .all() - ) - - return { - "users": [ - { - "id": user.id, - "username": user.username, - "email": user.email, - "is_active": user.is_active, - } - for user in users - ] - } + users = admin_service.search_users(db=db, query=q, limit=limit) + return UserSearchResponse(users=users) @router.get("/{user_id}", response_model=UserDetailResponse) @@ -172,15 +134,7 @@ def get_user_details( current_admin: User = Depends(get_current_admin_api), ): """Get detailed user information (Admin only).""" - user = ( - db.query(User) - .options(joinedload(User.owned_companies), joinedload(User.vendor_memberships)) - .filter(User.id == user_id) - .first() - ) - - if not user: - raise HTTPException(status_code=404, detail="User not found") + user = admin_service.get_user_details(db=db, user_id=user_id) return UserDetailResponse( id=user.id, @@ -195,8 +149,8 @@ def get_user_details( last_name=user.last_name, full_name=user.full_name, is_email_verified=user.is_email_verified, - owned_companies_count=len(user.owned_companies), - vendor_memberships_count=len(user.vendor_memberships), + 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, ) @@ -208,36 +162,19 @@ def update_user( current_admin: User = Depends(get_current_admin_api), ): """Update user information (Admin only).""" - user = db.query(User).filter(User.id == user_id).first() - - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Prevent changing own admin status - if user.id == current_admin.id and user_update.role and user_update.role != "admin": - raise HTTPException( - status_code=400, detail="Cannot change your own admin role" - ) - - # Check email uniqueness if changing - if user_update.email and user_update.email != user.email: - if db.query(User).filter(User.email == user_update.email).first(): - raise HTTPException(status_code=400, detail="Email already registered") - - # Check username uniqueness if changing - if user_update.username and user_update.username != user.username: - if db.query(User).filter(User.username == user_update.username).first(): - raise HTTPException(status_code=400, detail="Username already taken") - - # Update fields update_data = user_update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(user, field, value) - db.commit() - db.refresh(user) - - logger.info(f"Admin {current_admin.username} updated user {user.username}") + user = admin_service.update_user( + db=db, + user_id=user_id, + current_admin_id=current_admin.id, + email=update_data.get("email"), + username=update_data.get("username"), + first_name=update_data.get("first_name"), + last_name=update_data.get("last_name"), + role=update_data.get("role"), + is_active=update_data.get("is_active"), + ) return UserDetailResponse( id=user.id, @@ -252,68 +189,38 @@ def update_user( last_name=user.last_name, full_name=user.full_name, is_email_verified=user.is_email_verified, - owned_companies_count=len(user.owned_companies), - vendor_memberships_count=len(user.vendor_memberships), + 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, ) -@router.put("/{user_id}/status") +@router.put("/{user_id}/status", response_model=UserStatusToggleResponse) def toggle_user_status( user_id: int = Path(..., description="User ID"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """Toggle user active status (Admin only).""" - user = db.query(User).filter(User.id == user_id).first() + user, message = admin_service.toggle_user_status( + db=db, + user_id=user_id, + current_admin_id=current_admin.id, + ) - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Prevent deactivating yourself - if user.id == current_admin.id: - raise HTTPException(status_code=400, detail="Cannot deactivate yourself") - - user.is_active = not user.is_active - db.commit() - - action = "activated" if user.is_active else "deactivated" - logger.info(f"Admin {current_admin.username} {action} user {user.username}") - - return {"message": f"User {action} successfully", "is_active": user.is_active} + return UserStatusToggleResponse(message=message, is_active=user.is_active) -@router.delete("/{user_id}") +@router.delete("/{user_id}", response_model=UserDeleteResponse) def delete_user( user_id: int = Path(..., description="User ID"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """Delete a user (Admin only).""" - user = ( - db.query(User) - .options(joinedload(User.owned_companies)) - .filter(User.id == user_id) - .first() + message = admin_service.delete_user( + db=db, + user_id=user_id, + current_admin_id=current_admin.id, ) - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Prevent deleting yourself - if user.id == current_admin.id: - raise HTTPException(status_code=400, detail="Cannot delete yourself") - - # Prevent deleting users who own companies - if user.owned_companies: - raise HTTPException( - status_code=400, - detail=f"Cannot delete user who owns {len(user.owned_companies)} company(ies). Transfer ownership first.", - ) - - username = user.username - db.delete(user) - db.commit() - - logger.info(f"Admin {current_admin.username} deleted user {username}") - - return {"message": "User deleted successfully"} + return UserDeleteResponse(message=message) diff --git a/app/api/v1/shop/auth.py b/app/api/v1/shop/auth.py index 0c3365d8..b37a2007 100644 --- a/app/api/v1/shop/auth.py +++ b/app/api/v1/shop/auth.py @@ -16,14 +16,20 @@ This prevents: import logging -from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi import APIRouter, Depends, Request, Response from pydantic import BaseModel from sqlalchemy.orm import Session from app.core.database import get_db from app.core.environment import should_use_secure_cookies +from app.exceptions import VendorNotFoundException from app.services.customer_service import customer_service -from models.schema.auth import UserLogin +from models.schema.auth import ( + LogoutResponse, + PasswordResetRequestResponse, + PasswordResetResponse, + UserLogin, +) from models.schema.customer import CustomerRegister, CustomerResponse router = APIRouter() @@ -62,10 +68,7 @@ def register_customer( vendor = getattr(request.state, "vendor", None) if not vendor: - raise HTTPException( - status_code=404, - detail="Vendor not found. Please access via vendor domain/subdomain/path.", - ) + raise VendorNotFoundException("context", identifier_type="subdomain") logger.debug( f"[SHOP_API] register_customer for vendor {vendor.subdomain}", @@ -122,10 +125,7 @@ def customer_login( vendor = getattr(request.state, "vendor", None) if not vendor: - raise HTTPException( - status_code=404, - detail="Vendor not found. Please access via vendor domain/subdomain/path.", - ) + raise VendorNotFoundException("context", identifier_type="subdomain") logger.debug( f"[SHOP_API] customer_login for vendor {vendor.subdomain}", @@ -199,7 +199,7 @@ def customer_login( ) -@router.post("/auth/logout") +@router.post("/auth/logout", response_model=LogoutResponse) def customer_logout(request: Request, response: Response): """ Customer logout for current vendor. @@ -245,10 +245,10 @@ def customer_logout(request: Request, response: Response): logger.debug(f"Deleted customer_token cookie (path={cookie_path})") - return {"message": "Logged out successfully"} + return LogoutResponse(message="Logged out successfully") -@router.post("/auth/forgot-password") +@router.post("/auth/forgot-password", response_model=PasswordResetRequestResponse) def forgot_password(request: Request, email: str, db: Session = Depends(get_db)): """ Request password reset for customer. @@ -263,10 +263,7 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db)) vendor = getattr(request.state, "vendor", None) if not vendor: - raise HTTPException( - status_code=404, - detail="Vendor not found. Please access via vendor domain/subdomain/path.", - ) + raise VendorNotFoundException("context", identifier_type="subdomain") logger.debug( f"[SHOP_API] forgot_password for vendor {vendor.subdomain}", @@ -285,12 +282,12 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db)) logger.info(f"Password reset requested for {email} (vendor: {vendor.subdomain})") - return { - "message": "If an account exists with this email, a password reset link has been sent." - } + return PasswordResetRequestResponse( + message="If an account exists with this email, a password reset link has been sent." + ) -@router.post("/auth/reset-password") +@router.post("/auth/reset-password", response_model=PasswordResetResponse) def reset_password( request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db) ): @@ -307,10 +304,7 @@ def reset_password( vendor = getattr(request.state, "vendor", None) if not vendor: - raise HTTPException( - status_code=404, - detail="Vendor not found. Please access via vendor domain/subdomain/path.", - ) + raise VendorNotFoundException("context", identifier_type="subdomain") logger.debug( f"[SHOP_API] reset_password for vendor {vendor.subdomain}", @@ -329,6 +323,6 @@ def reset_password( logger.info(f"Password reset completed (vendor: {vendor.subdomain})") - return { - "message": "Password reset successfully. You can now log in with your new password." - } + return PasswordResetResponse( + message="Password reset successfully. You can now log in with your new password." + ) diff --git a/app/api/v1/shop/cart.py b/app/api/v1/shop/cart.py index e05ba7c7..900e8080 100644 --- a/app/api/v1/shop/cart.py +++ b/app/api/v1/shop/cart.py @@ -3,17 +3,21 @@ Shop Shopping Cart API (Public) Public endpoints for managing shopping cart in shop frontend. -Uses vendor from request.state (injected by VendorContextMiddleware). +Uses vendor from middleware context (VendorContextMiddleware). No authentication required - uses session ID for cart tracking. + +Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain """ import logging -from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request +from fastapi import APIRouter, Body, Depends, Path from sqlalchemy.orm import Session from app.core.database import get_db from app.services.cart_service import cart_service +from middleware.vendor_context import require_vendor_context +from models.database.vendor import Vendor from models.schema.cart import ( AddToCartRequest, CartOperationResponse, @@ -31,30 +35,21 @@ logger = logging.getLogger(__name__) # ============================================================================ -@router.get("/cart/{session_id}", response_model=CartResponse) +@router.get("/cart/{session_id}", response_model=CartResponse) # public def get_cart( - request: Request, session_id: str = Path(..., description="Shopping session ID"), + vendor: Vendor = Depends(require_vendor_context()), db: Session = Depends(get_db), ) -> CartResponse: """ Get shopping cart contents for current vendor. - Vendor is automatically determined from request context. + Vendor is automatically determined from request context (URL/subdomain/domain). No authentication required - uses session ID for cart tracking. Path Parameters: - session_id: Unique session identifier for the cart """ - # Get vendor from middleware - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise HTTPException( - status_code=404, - detail="Vendor not found. Please access via vendor domain/subdomain/path.", - ) - logger.info( f"[SHOP_API] get_cart for session {session_id}, vendor {vendor.id}", extra={ @@ -79,17 +74,17 @@ def get_cart( return CartResponse.from_service_dict(cart) -@router.post("/cart/{session_id}/items", response_model=CartOperationResponse) +@router.post("/cart/{session_id}/items", response_model=CartOperationResponse) # public def add_to_cart( - request: Request, session_id: str = Path(..., description="Shopping session ID"), cart_data: AddToCartRequest = Body(...), + vendor: Vendor = Depends(require_vendor_context()), db: Session = Depends(get_db), ) -> CartOperationResponse: """ Add product to cart for current vendor. - Vendor is automatically determined from request context. + Vendor is automatically determined from request context (URL/subdomain/domain). No authentication required - uses session ID. Path Parameters: @@ -99,15 +94,6 @@ def add_to_cart( - product_id: ID of product to add - quantity: Quantity to add (default: 1) """ - # Get vendor from middleware - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise HTTPException( - status_code=404, - detail="Vendor not found. Please access via vendor domain/subdomain/path.", - ) - logger.info( f"[SHOP_API] add_to_cart: product {cart_data.product_id}, qty {cart_data.quantity}, session {session_id}", extra={ @@ -140,18 +126,18 @@ def add_to_cart( @router.put( "/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse -) +) # public def update_cart_item( - request: Request, session_id: str = Path(..., description="Shopping session ID"), product_id: int = Path(..., description="Product ID", gt=0), cart_data: UpdateCartItemRequest = Body(...), + vendor: Vendor = Depends(require_vendor_context()), db: Session = Depends(get_db), ) -> CartOperationResponse: """ Update cart item quantity for current vendor. - Vendor is automatically determined from request context. + Vendor is automatically determined from request context (URL/subdomain/domain). No authentication required - uses session ID. Path Parameters: @@ -161,15 +147,6 @@ def update_cart_item( Request Body: - quantity: New quantity (must be >= 1) """ - # Get vendor from middleware - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise HTTPException( - status_code=404, - detail="Vendor not found. Please access via vendor domain/subdomain/path.", - ) - logger.debug( f"[SHOP_API] update_cart_item: product {product_id}, qty {cart_data.quantity}", extra={ @@ -194,32 +171,23 @@ def update_cart_item( @router.delete( "/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse -) +) # public def remove_from_cart( - request: Request, session_id: str = Path(..., description="Shopping session ID"), product_id: int = Path(..., description="Product ID", gt=0), + vendor: Vendor = Depends(require_vendor_context()), db: Session = Depends(get_db), ) -> CartOperationResponse: """ Remove item from cart for current vendor. - Vendor is automatically determined from request context. + Vendor is automatically determined from request context (URL/subdomain/domain). No authentication required - uses session ID. Path Parameters: - session_id: Unique session identifier for the cart - product_id: ID of product to remove """ - # Get vendor from middleware - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise HTTPException( - status_code=404, - detail="Vendor not found. Please access via vendor domain/subdomain/path.", - ) - logger.debug( f"[SHOP_API] remove_from_cart: product {product_id}", extra={ @@ -237,30 +205,21 @@ def remove_from_cart( return CartOperationResponse(**result) -@router.delete("/cart/{session_id}", response_model=ClearCartResponse) +@router.delete("/cart/{session_id}", response_model=ClearCartResponse) # public def clear_cart( - request: Request, session_id: str = Path(..., description="Shopping session ID"), + vendor: Vendor = Depends(require_vendor_context()), db: Session = Depends(get_db), ) -> ClearCartResponse: """ Clear all items from cart for current vendor. - Vendor is automatically determined from request context. + Vendor is automatically determined from request context (URL/subdomain/domain). No authentication required - uses session ID. Path Parameters: - session_id: Unique session identifier for the cart """ - # Get vendor from middleware - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise HTTPException( - status_code=404, - detail="Vendor not found. Please access via vendor domain/subdomain/path.", - ) - logger.debug( f"[SHOP_API] clear_cart for session {session_id}", extra={ diff --git a/app/api/v1/shop/content_pages.py b/app/api/v1/shop/content_pages.py index 8ab17249..52ad8aad 100644 --- a/app/api/v1/shop/content_pages.py +++ b/app/api/v1/shop/content_pages.py @@ -8,7 +8,7 @@ No authentication required. import logging -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, Request from pydantic import BaseModel from sqlalchemy.orm import Session @@ -90,16 +90,13 @@ def get_content_page(slug: str, request: Request, db: Session = Depends(get_db)) vendor = getattr(request.state, "vendor", None) vendor_id = vendor.id if vendor else None - page = content_page_service.get_page_for_vendor( + page = content_page_service.get_page_for_vendor_or_raise( db, slug=slug, vendor_id=vendor_id, include_unpublished=False, # Only show published pages ) - if not page: - raise HTTPException(status_code=404, detail=f"Content page not found: {slug}") - return { "slug": page.slug, "title": page.title, diff --git a/app/api/v1/shop/products.py b/app/api/v1/shop/products.py index f7d834b6..bff80cc7 100644 --- a/app/api/v1/shop/products.py +++ b/app/api/v1/shop/products.py @@ -3,17 +3,21 @@ Shop Product Catalog API (Public) Public endpoints for browsing product catalog in shop frontend. -Uses vendor from request.state (injected by VendorContextMiddleware). +Uses vendor from middleware context (VendorContextMiddleware). No authentication required. + +Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain """ import logging -from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request +from fastapi import APIRouter, Depends, Path, Query from sqlalchemy.orm import Session from app.core.database import get_db from app.services.product_service import product_service +from middleware.vendor_context import require_vendor_context +from models.database.vendor import Vendor from models.schema.product import ( ProductDetailResponse, ProductListResponse, @@ -24,19 +28,19 @@ router = APIRouter() logger = logging.getLogger(__name__) -@router.get("/products", response_model=ProductListResponse) +@router.get("/products", response_model=ProductListResponse) # public def get_product_catalog( - request: Request, skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), search: str | None = Query(None, description="Search products by name"), is_featured: bool | None = Query(None, description="Filter by featured products"), + vendor: Vendor = Depends(require_vendor_context()), db: Session = Depends(get_db), ): """ Get product catalog for current vendor. - Vendor is automatically determined from request context (domain/subdomain/path). + Vendor is automatically determined from request context (URL/subdomain/domain). Only returns active products visible to customers. No authentication required. @@ -46,15 +50,6 @@ def get_product_catalog( - search: Search query for product name/description - is_featured: Filter by featured products only """ - # Get vendor from middleware (injected by VendorContextMiddleware) - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise HTTPException( - status_code=404, - detail="Vendor not found. Please access via vendor domain/subdomain/path.", - ) - logger.debug( f"[SHOP_API] get_product_catalog for vendor: {vendor.subdomain}", extra={ @@ -85,30 +80,21 @@ def get_product_catalog( ) -@router.get("/products/{product_id}", response_model=ProductDetailResponse) +@router.get("/products/{product_id}", response_model=ProductDetailResponse) # public def get_product_details( - request: Request, product_id: int = Path(..., description="Product ID", gt=0), + vendor: Vendor = Depends(require_vendor_context()), db: Session = Depends(get_db), ): """ Get detailed product information for customers. - Vendor is automatically determined from request context. + Vendor is automatically determined from request context (URL/subdomain/domain). No authentication required. Path Parameters: - product_id: ID of the product to retrieve """ - # Get vendor from middleware - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise HTTPException( - status_code=404, - detail="Vendor not found. Please access via vendor domain/subdomain/path.", - ) - logger.debug( f"[SHOP_API] get_product_details for product {product_id}", extra={ @@ -131,19 +117,19 @@ def get_product_details( return ProductDetailResponse.model_validate(product) -@router.get("/products/search", response_model=ProductListResponse) +@router.get("/products/search", response_model=ProductListResponse) # public def search_products( - request: Request, q: str = Query(..., min_length=1, description="Search query"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), + vendor: Vendor = Depends(require_vendor_context()), db: Session = Depends(get_db), ): """ Search products in current vendor's catalog. Searches in product names, descriptions, and SKUs. - Vendor is automatically determined from request context. + Vendor is automatically determined from request context (URL/subdomain/domain). No authentication required. Query Parameters: @@ -151,15 +137,6 @@ def search_products( - skip: Number of results to skip (pagination) - limit: Maximum number of results to return """ - # Get vendor from middleware - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise HTTPException( - status_code=404, - detail="Vendor not found. Please access via vendor domain/subdomain/path.", - ) - logger.debug( f"[SHOP_API] search_products: '{q}'", extra={ diff --git a/app/api/v1/vendor/analytics.py b/app/api/v1/vendor/analytics.py index 7d92ac5b..e0d23912 100644 --- a/app/api/v1/vendor/analytics.py +++ b/app/api/v1/vendor/analytics.py @@ -1,6 +1,8 @@ # app/api/v1/vendor/analytics.py """ Vendor analytics and reporting endpoints. + +Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern) """ import logging @@ -10,21 +12,27 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db +from app.exceptions import InvalidTokenException from app.services.stats_service import stats_service -from middleware.vendor_context import require_vendor_context from models.database.user import User -from models.database.vendor import Vendor router = APIRouter(prefix="/analytics") logger = logging.getLogger(__name__) +def _get_vendor_id_from_token(current_user: User) -> int: + """Helper to get vendor_id from JWT token.""" + if not hasattr(current_user, "token_vendor_id"): + raise InvalidTokenException("Token missing vendor information. Please login again.") + return current_user.token_vendor_id + + @router.get("") def get_vendor_analytics( period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"), - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get vendor analytics data for specified time period.""" - return stats_service.get_vendor_analytics(db, vendor.id, period) + vendor_id = _get_vendor_id_from_token(current_user) + return stats_service.get_vendor_analytics(db, vendor_id, period) diff --git a/app/api/v1/vendor/auth.py b/app/api/v1/vendor/auth.py index e95dcf32..959a5f41 100644 --- a/app/api/v1/vendor/auth.py +++ b/app/api/v1/vendor/auth.py @@ -25,8 +25,8 @@ 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 Role, Vendor, VendorUser -from models.schema.auth import UserLogin +from models.database.vendor import Vendor +from models.schema.auth import LogoutResponse, UserLogin, VendorUserResponse router = APIRouter(prefix="/auth") logger = logging.getLogger(__name__) @@ -68,13 +68,7 @@ def vendor_login( if not vendor and hasattr(user_credentials, "vendor_code"): vendor_code = getattr(user_credentials, "vendor_code", None) if vendor_code: - vendor = ( - db.query(Vendor) - .filter( - Vendor.vendor_code == vendor_code.upper(), Vendor.is_active == True - ) - .first() - ) + vendor = auth_service.get_vendor_by_code(db, vendor_code) # Authenticate user login_result = auth_service.login_user(db=db, user_credentials=user_credentials) @@ -91,51 +85,22 @@ def vendor_login( vendor_role = "Member" if vendor: - # Check if user is vendor owner (via company ownership) - is_owner = vendor.company and vendor.company.owner_user_id == user.id + # Check if user has access to this vendor + has_access, role = auth_service.get_user_vendor_role(db, user, vendor) - if is_owner: - vendor_role = "Owner" + if has_access: + vendor_role = role else: - # Check if user is team member - vendor_user = ( - db.query(VendorUser) - .join(Role) - .filter( - VendorUser.user_id == user.id, - VendorUser.vendor_id == vendor.id, - VendorUser.is_active == True, - ) - .first() + logger.warning( + 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" ) - - if vendor_user: - vendor_role = vendor_user.role.name - else: - logger.warning( - 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" - ) else: # No vendor context - find which vendor this user belongs to - # Check owned vendors first (via company ownership) - for company in user.owned_companies: - if company.vendors: - vendor = company.vendors[0] - vendor_role = "Owner" - break - - # Check vendor memberships if no owned vendor found - if not vendor and user.vendor_memberships: - active_membership = next( - (vm for vm in user.vendor_memberships if vm.is_active), None - ) - if active_membership: - vendor = active_membership.vendor - vendor_role = active_membership.role.name + vendor, vendor_role = auth_service.find_user_vendor(user) if not vendor: raise InvalidCredentialsException("User is not associated with any vendor") @@ -194,7 +159,7 @@ def vendor_login( ) -@router.post("/logout") +@router.post("/logout", response_model=LogoutResponse) def vendor_logout(response: Response): """ Vendor team member logout. @@ -212,10 +177,10 @@ def vendor_logout(response: Response): logger.debug("Deleted vendor_token cookie") - return {"message": "Logged out successfully"} + return LogoutResponse(message="Logged out successfully") -@router.get("/me") +@router.get("/me", response_model=VendorUserResponse) def get_current_vendor_user( user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db) ): @@ -225,10 +190,10 @@ def get_current_vendor_user( This endpoint can be called to verify authentication and get user info. Requires Authorization header (header-only authentication for API endpoints). """ - return { - "id": user.id, - "username": user.username, - "email": user.email, - "role": user.role, - "is_active": user.is_active, - } + return VendorUserResponse( + id=user.id, + username=user.username, + email=user.email, + role=user.role, + is_active=user.is_active, + ) diff --git a/app/api/v1/vendor/content_pages.py b/app/api/v1/vendor/content_pages.py index 02b0297a..9f0f5dd1 100644 --- a/app/api/v1/vendor/content_pages.py +++ b/app/api/v1/vendor/content_pages.py @@ -10,11 +10,12 @@ Vendors can: import logging -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api, get_db +from app.exceptions.content_page import VendorNotAssociatedException from app.services.content_page_service import content_page_service from models.database.user import User @@ -106,9 +107,7 @@ def list_vendor_pages( Returns vendor-specific overrides + platform defaults (vendor overrides take precedence). """ if not current_user.vendor_id: - raise HTTPException( - status_code=403, detail="User is not associated with a vendor" - ) + raise VendorNotAssociatedException() pages = content_page_service.list_pages_for_vendor( db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished @@ -129,9 +128,7 @@ def list_vendor_overrides( Shows what the vendor has customized. """ if not current_user.vendor_id: - raise HTTPException( - status_code=403, detail="User is not associated with a vendor" - ) + raise VendorNotAssociatedException() pages = content_page_service.list_all_vendor_pages( db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished @@ -153,20 +150,15 @@ def get_page( Returns vendor override if exists, otherwise platform default. """ if not current_user.vendor_id: - raise HTTPException( - status_code=403, detail="User is not associated with a vendor" - ) + raise VendorNotAssociatedException() - page = content_page_service.get_page_for_vendor( + page = content_page_service.get_page_for_vendor_or_raise( db, slug=slug, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished, ) - if not page: - raise HTTPException(status_code=404, detail=f"Content page not found: {slug}") - return page.to_dict() @@ -182,9 +174,7 @@ def create_vendor_page( This will be shown instead of the platform default for this vendor. """ if not current_user.vendor_id: - raise HTTPException( - status_code=403, detail="User is not associated with a vendor" - ) + raise VendorNotAssociatedException() page = content_page_service.create_page( db, @@ -218,24 +208,13 @@ def update_vendor_page( Can only update pages owned by this vendor. """ if not current_user.vendor_id: - raise HTTPException( - status_code=403, detail="User is not associated with a vendor" - ) + raise VendorNotAssociatedException() - # Verify ownership - existing_page = content_page_service.get_page_by_id(db, page_id) - if not existing_page: - raise HTTPException(status_code=404, detail="Content page not found") - - if existing_page.vendor_id != current_user.vendor_id: - raise HTTPException( - status_code=403, detail="Cannot edit pages from other vendors" - ) - - # Update - page = content_page_service.update_page( + # Update with ownership check in service layer + page = content_page_service.update_vendor_page( db, page_id=page_id, + vendor_id=current_user.vendor_id, title=page_data.title, content=page_data.content, content_format=page_data.content_format, @@ -264,21 +243,7 @@ def delete_vendor_page( After deletion, platform default will be shown (if exists). """ if not current_user.vendor_id: - raise HTTPException( - status_code=403, detail="User is not associated with a vendor" - ) + raise VendorNotAssociatedException() - # Verify ownership - existing_page = content_page_service.get_page_by_id(db, page_id) - if not existing_page: - raise HTTPException(status_code=404, detail="Content page not found") - - if existing_page.vendor_id != current_user.vendor_id: - raise HTTPException( - status_code=403, detail="Cannot delete pages from other vendors" - ) - - # Delete - content_page_service.delete_page(db, page_id) - - return + # Delete with ownership check in service layer + content_page_service.delete_vendor_page(db, page_id, current_user.vendor_id) diff --git a/app/api/v1/vendor/customers.py b/app/api/v1/vendor/customers.py index 823bb277..c6e5f759 100644 --- a/app/api/v1/vendor/customers.py +++ b/app/api/v1/vendor/customers.py @@ -2,6 +2,8 @@ # app/api/v1/vendor/customers.py """ Vendor customer management endpoints. + +Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern) """ import logging @@ -11,21 +13,27 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db -from middleware.vendor_context import require_vendor_context +from app.exceptions import InvalidTokenException +from app.services.vendor_service import vendor_service from models.database.user import User -from models.database.vendor import Vendor router = APIRouter(prefix="/customers") logger = logging.getLogger(__name__) +def _get_vendor_from_token(current_user: User, db: Session): + """Helper to get vendor from JWT token.""" + if not hasattr(current_user, "token_vendor_id"): + raise InvalidTokenException("Token missing vendor information. Please login again.") + return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) + + @router.get("") def get_vendor_customers( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), search: str | None = Query(None), is_active: bool | None = Query(None), - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -38,6 +46,7 @@ def get_vendor_customers( - Support filtering by active status - Return paginated results """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return { "customers": [], "total": 0, @@ -50,7 +59,6 @@ def get_vendor_customers( @router.get("/{customer_id}") def get_customer_details( customer_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -63,13 +71,13 @@ def get_customer_details( - Include order history - Include total spent, etc. """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Customer details coming in Slice 4"} @router.get("/{customer_id}/orders") def get_customer_orders( customer_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -81,6 +89,7 @@ def get_customer_orders( - Filter by vendor_id - Return order details """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"orders": [], "message": "Customer orders coming in Slice 5"} @@ -88,7 +97,6 @@ def get_customer_orders( def update_customer( customer_id: int, customer_data: dict, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -100,13 +108,13 @@ def update_customer( - Verify customer belongs to vendor - Update customer preferences """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Customer update coming in Slice 4"} @router.put("/{customer_id}/status") def toggle_customer_status( customer_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -118,13 +126,13 @@ def toggle_customer_status( - Verify customer belongs to vendor - Log the change """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Customer status toggle coming in Slice 4"} @router.get("/{customer_id}/stats") def get_customer_statistics( customer_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -137,6 +145,7 @@ def get_customer_statistics( - Average order value - Last order date """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return { "total_orders": 0, "total_spent": 0.0, diff --git a/app/api/v1/vendor/dashboard.py b/app/api/v1/vendor/dashboard.py index 7f38872a..db5d176c 100644 --- a/app/api/v1/vendor/dashboard.py +++ b/app/api/v1/vendor/dashboard.py @@ -10,7 +10,9 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db +from app.exceptions import InvalidTokenException, VendorNotActiveException from app.services.stats_service import stats_service +from app.services.vendor_service import vendor_service from models.database.user import User router = APIRouter(prefix="/dashboard") @@ -35,23 +37,17 @@ def get_vendor_dashboard_stats( Vendor is determined from the JWT token (vendor_id claim). Requires Authorization header (API endpoint). """ - from fastapi import HTTPException - # Get vendor ID from token (set by get_current_vendor_api) if not hasattr(current_user, "token_vendor_id"): - raise HTTPException( - status_code=400, - detail="Token missing vendor information. Please login again.", - ) + raise InvalidTokenException("Token missing vendor information. Please login again.") vendor_id = current_user.token_vendor_id - # Get vendor object to include in response - from models.database.vendor import Vendor + # Get vendor object (raises VendorNotFoundException if not found) + vendor = vendor_service.get_vendor_by_id(db, vendor_id) - vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() - if not vendor or not vendor.is_active: - raise HTTPException(status_code=404, detail="Vendor not found or inactive") + if not vendor.is_active: + raise VendorNotActiveException(vendor.vendor_code) # Get vendor-scoped statistics stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor_id) diff --git a/app/api/v1/vendor/inventory.py b/app/api/v1/vendor/inventory.py index 9e6a13f0..abb828df 100644 --- a/app/api/v1/vendor/inventory.py +++ b/app/api/v1/vendor/inventory.py @@ -1,4 +1,9 @@ # app/api/v1/vendor/inventory.py +""" +Vendor inventory management endpoints. + +Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern) +""" import logging from fastapi import APIRouter, Depends, Query @@ -6,10 +11,9 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db +from app.exceptions import InvalidTokenException from app.services.inventory_service import inventory_service -from middleware.vendor_context import require_vendor_context from models.database.user import User -from models.database.vendor import Vendor from models.schema.inventory import ( InventoryAdjust, InventoryCreate, @@ -24,70 +28,77 @@ router = APIRouter() logger = logging.getLogger(__name__) +def _get_vendor_id_from_token(current_user: User) -> int: + """Helper to get vendor_id from JWT token.""" + if not hasattr(current_user, "token_vendor_id"): + raise InvalidTokenException("Token missing vendor information. Please login again.") + return current_user.token_vendor_id + + @router.post("/inventory/set", response_model=InventoryResponse) def set_inventory( inventory: InventoryCreate, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Set exact inventory quantity (replaces existing).""" - return inventory_service.set_inventory(db, vendor.id, inventory) + vendor_id = _get_vendor_id_from_token(current_user) + return inventory_service.set_inventory(db, vendor_id, inventory) @router.post("/inventory/adjust", response_model=InventoryResponse) def adjust_inventory( adjustment: InventoryAdjust, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Adjust inventory (positive to add, negative to remove).""" - return inventory_service.adjust_inventory(db, vendor.id, adjustment) + vendor_id = _get_vendor_id_from_token(current_user) + return inventory_service.adjust_inventory(db, vendor_id, adjustment) @router.post("/inventory/reserve", response_model=InventoryResponse) def reserve_inventory( reservation: InventoryReserve, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Reserve inventory for an order.""" - return inventory_service.reserve_inventory(db, vendor.id, reservation) + vendor_id = _get_vendor_id_from_token(current_user) + return inventory_service.reserve_inventory(db, vendor_id, reservation) @router.post("/inventory/release", response_model=InventoryResponse) def release_reservation( reservation: InventoryReserve, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Release reserved inventory (cancel order).""" - return inventory_service.release_reservation(db, vendor.id, reservation) + vendor_id = _get_vendor_id_from_token(current_user) + return inventory_service.release_reservation(db, vendor_id, reservation) @router.post("/inventory/fulfill", response_model=InventoryResponse) def fulfill_reservation( reservation: InventoryReserve, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Fulfill reservation (complete order, remove from stock).""" - return inventory_service.fulfill_reservation(db, vendor.id, reservation) + vendor_id = _get_vendor_id_from_token(current_user) + return inventory_service.fulfill_reservation(db, vendor_id, reservation) @router.get("/inventory/product/{product_id}", response_model=ProductInventorySummary) def get_product_inventory( product_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get inventory summary for a product.""" - return inventory_service.get_product_inventory(db, vendor.id, product_id) + vendor_id = _get_vendor_id_from_token(current_user) + return inventory_service.get_product_inventory(db, vendor_id, product_id) @router.get("/inventory", response_model=InventoryListResponse) @@ -96,13 +107,13 @@ def get_vendor_inventory( limit: int = Query(100, ge=1, le=1000), location: str | None = Query(None), low_stock: int | None = Query(None, ge=0), - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get all inventory for vendor.""" + vendor_id = _get_vendor_id_from_token(current_user) inventories = inventory_service.get_vendor_inventory( - db, vendor.id, skip, limit, location, low_stock + db, vendor_id, skip, limit, location, low_stock ) # Get total count @@ -117,23 +128,23 @@ def get_vendor_inventory( def update_inventory( inventory_id: int, inventory_update: InventoryUpdate, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Update inventory entry.""" + vendor_id = _get_vendor_id_from_token(current_user) return inventory_service.update_inventory( - db, vendor.id, inventory_id, inventory_update + db, vendor_id, inventory_id, inventory_update ) @router.delete("/inventory/{inventory_id}") def delete_inventory( inventory_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Delete inventory entry.""" - inventory_service.delete_inventory(db, vendor.id, inventory_id) + vendor_id = _get_vendor_id_from_token(current_user) + inventory_service.delete_inventory(db, vendor_id, inventory_id) return {"message": "Inventory deleted successfully"} diff --git a/app/api/v1/vendor/marketplace.py b/app/api/v1/vendor/marketplace.py index 12001170..adfc85e3 100644 --- a/app/api/v1/vendor/marketplace.py +++ b/app/api/v1/vendor/marketplace.py @@ -1,7 +1,8 @@ -# app/api/v1/vendor/marketplace.py # Note: Should be under /vendor/ route +# app/api/v1/vendor/marketplace.py """ Marketplace import endpoints for vendors. -Vendor context is automatically injected by middleware. + +Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern) """ import logging @@ -11,37 +12,45 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db +from app.exceptions import InvalidTokenException, UnauthorizedVendorAccessException from app.services.marketplace_import_job_service import marketplace_import_job_service +from app.services.vendor_service import vendor_service from app.tasks.background_tasks import process_marketplace_import from middleware.decorators import rate_limit -from middleware.vendor_context import require_vendor_context # IMPORTANT from models.database.user import User -from models.database.vendor import Vendor from models.schema.marketplace_import_job import ( MarketplaceImportJobRequest, MarketplaceImportJobResponse, ) -router = APIRouter() +router = APIRouter(prefix="/marketplace") logger = logging.getLogger(__name__) +def _get_vendor_from_token(current_user: User, db: Session): + """Helper to get vendor from JWT token.""" + if not hasattr(current_user, "token_vendor_id"): + raise InvalidTokenException("Token missing vendor information. Please login again.") + return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) + + @router.post("/import", response_model=MarketplaceImportJobResponse) @rate_limit(max_requests=10, window_seconds=3600) async def import_products_from_marketplace( request: MarketplaceImportJobRequest, background_tasks: BackgroundTasks, - vendor: Vendor = Depends(require_vendor_context()), # ADDED: Vendor from middleware current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Import products from marketplace CSV with background processing (Protected).""" + vendor = _get_vendor_from_token(current_user, db) + logger.info( f"Starting marketplace import: {request.marketplace} for vendor {vendor.vendor_code} " f"by user {current_user.username}" ) - # Create import job (vendor comes from middleware) + # Create import job (vendor comes from token) import_job = marketplace_import_job_service.create_import_job( db, request, vendor, current_user ) @@ -50,9 +59,9 @@ async def import_products_from_marketplace( background_tasks.add_task( process_marketplace_import, import_job.id, - request.source_url, # FIXED: was request.url + request.source_url, request.marketplace, - vendor.id, # Pass vendor_id instead of vendor_code + vendor.id, request.batch_size or 1000, ) @@ -62,7 +71,7 @@ async def import_products_from_marketplace( marketplace=request.marketplace, vendor_id=import_job.vendor_id, vendor_code=vendor.vendor_code, - vendor_name=vendor.name, # FIXED: from vendor object + vendor_name=vendor.name, source_url=request.source_url, message=f"Marketplace import started from {request.marketplace}. " f"Check status with /import-status/{import_job.id}", @@ -77,17 +86,16 @@ async def import_products_from_marketplace( @router.get("/imports/{job_id}", response_model=MarketplaceImportJobResponse) def get_marketplace_import_status( job_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get status of marketplace import job (Protected).""" + vendor = _get_vendor_from_token(current_user, db) + job = marketplace_import_job_service.get_import_job_by_id(db, job_id, current_user) # Verify job belongs to current vendor if job.vendor_id != vendor.id: - from app.exceptions import UnauthorizedVendorAccessException - raise UnauthorizedVendorAccessException(vendor.vendor_code, current_user.id) return marketplace_import_job_service.convert_to_response_model(job) @@ -98,11 +106,12 @@ def get_marketplace_import_jobs( marketplace: str | None = Query(None, description="Filter by marketplace"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get marketplace import jobs for current vendor (Protected).""" + vendor = _get_vendor_from_token(current_user, db) + jobs = marketplace_import_job_service.get_import_jobs( db=db, vendor=vendor, diff --git a/app/api/v1/vendor/media.py b/app/api/v1/vendor/media.py index dc5aaf6d..b7005c38 100644 --- a/app/api/v1/vendor/media.py +++ b/app/api/v1/vendor/media.py @@ -2,6 +2,8 @@ # app/api/v1/vendor/media.py """ Vendor media and file management endpoints. + +Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern) """ import logging @@ -11,21 +13,27 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db -from middleware.vendor_context import require_vendor_context +from app.exceptions import InvalidTokenException +from app.services.vendor_service import vendor_service from models.database.user import User -from models.database.vendor import Vendor router = APIRouter(prefix="/media") logger = logging.getLogger(__name__) +def _get_vendor_from_token(current_user: User, db: Session): + """Helper to get vendor from JWT token.""" + if not hasattr(current_user, "token_vendor_id"): + raise InvalidTokenException("Token missing vendor information. Please login again.") + return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) + + @router.get("") def get_media_library( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), media_type: str | None = Query(None, description="image, video, document"), search: str | None = Query(None), - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -39,6 +47,7 @@ def get_media_library( - Support pagination - Return file URLs, sizes, metadata """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return { "media": [], "total": 0, @@ -52,7 +61,6 @@ def get_media_library( async def upload_media( file: UploadFile = File(...), folder: str | None = Query(None, description="products, general, etc."), - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -67,6 +75,7 @@ async def upload_media( - Save metadata to database - Return file URL """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return { "file_url": None, "thumbnail_url": None, @@ -78,7 +87,6 @@ async def upload_media( async def upload_multiple_media( files: list[UploadFile] = File(...), folder: str | None = Query(None), - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -91,6 +99,7 @@ async def upload_multiple_media( - Return list of uploaded file URLs - Handle errors gracefully """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return { "uploaded_files": [], "failed_files": [], @@ -101,7 +110,6 @@ async def upload_multiple_media( @router.get("/{media_id}") def get_media_details( media_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -113,6 +121,7 @@ def get_media_details( - Return file URL - Return usage information (which products use this file) """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Media details coming in Slice 3"} @@ -120,7 +129,6 @@ def get_media_details( def update_media_metadata( media_id: int, metadata: dict, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -133,13 +141,13 @@ def update_media_metadata( - Update tags/categories - Update description """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Media update coming in Slice 3"} @router.delete("/{media_id}") def delete_media( media_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -153,13 +161,13 @@ def delete_media( - Delete database record - Return success/error """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Media deletion coming in Slice 3"} @router.get("/{media_id}/usage") def get_media_usage( media_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -171,6 +179,7 @@ def get_media_usage( - Check other entities using this media - Return list of usage """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return { "products": [], "other_usage": [], @@ -181,7 +190,6 @@ def get_media_usage( @router.post("/optimize/{media_id}") def optimize_media( media_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -194,4 +202,5 @@ def optimize_media( - Keep original - Update database with new versions """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Media optimization coming in Slice 3"} diff --git a/app/api/v1/vendor/notifications.py b/app/api/v1/vendor/notifications.py index 26610a74..018e1406 100644 --- a/app/api/v1/vendor/notifications.py +++ b/app/api/v1/vendor/notifications.py @@ -2,6 +2,8 @@ # app/api/v1/vendor/notifications.py """ Vendor notification management endpoints. + +Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern) """ import logging @@ -11,20 +13,26 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db -from middleware.vendor_context import require_vendor_context +from app.exceptions import InvalidTokenException +from app.services.vendor_service import vendor_service from models.database.user import User -from models.database.vendor import Vendor router = APIRouter(prefix="/notifications") logger = logging.getLogger(__name__) +def _get_vendor_from_token(current_user: User, db: Session): + """Helper to get vendor from JWT token.""" + if not hasattr(current_user, "token_vendor_id"): + raise InvalidTokenException("Token missing vendor information. Please login again.") + return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) + + @router.get("") def get_notifications( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), unread_only: bool | None = Query(False), - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -37,6 +45,7 @@ def get_notifications( - Support pagination - Return notification details """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return { "notifications": [], "total": 0, @@ -47,7 +56,6 @@ def get_notifications( @router.get("/unread-count") def get_unread_count( - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -58,13 +66,13 @@ def get_unread_count( - Count unread notifications for vendor - Used for notification badge """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"unread_count": 0, "message": "Unread count coming in Slice 5"} @router.put("/{notification_id}/read") def mark_as_read( notification_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -75,12 +83,12 @@ def mark_as_read( - Mark single notification as read - Update read timestamp """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Mark as read coming in Slice 5"} @router.put("/mark-all-read") def mark_all_as_read( - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -91,13 +99,13 @@ def mark_all_as_read( - Mark all vendor notifications as read - Update timestamps """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Mark all as read coming in Slice 5"} @router.delete("/{notification_id}") def delete_notification( notification_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -108,12 +116,12 @@ def delete_notification( - Delete single notification - Verify notification belongs to vendor """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Notification deletion coming in Slice 5"} @router.get("/settings") def get_notification_settings( - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -125,6 +133,7 @@ def get_notification_settings( - Get in-app notification settings - Get notification types enabled/disabled """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return { "email_notifications": True, "in_app_notifications": True, @@ -136,7 +145,6 @@ def get_notification_settings( @router.put("/settings") def update_notification_settings( settings: dict, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -148,12 +156,12 @@ def update_notification_settings( - Update in-app notification settings - Enable/disable specific notification types """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Notification settings update coming in Slice 5"} @router.get("/templates") def get_notification_templates( - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -165,6 +173,7 @@ def get_notification_templates( - Include: order confirmation, shipping notification, etc. - Return template details """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"templates": [], "message": "Notification templates coming in Slice 5"} @@ -172,7 +181,6 @@ def get_notification_templates( def update_notification_template( template_id: int, template_data: dict, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -185,13 +193,13 @@ def update_notification_template( - Validate template variables - Preview template """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Template update coming in Slice 5"} @router.post("/test") def send_test_notification( notification_data: dict, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -203,4 +211,5 @@ def send_test_notification( - Use specified template - Send to current user's email """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Test notification coming in Slice 5"} diff --git a/app/api/v1/vendor/payments.py b/app/api/v1/vendor/payments.py index a29672f6..22737252 100644 --- a/app/api/v1/vendor/payments.py +++ b/app/api/v1/vendor/payments.py @@ -2,6 +2,8 @@ # app/api/v1/vendor/payments.py """ Vendor payment configuration and processing endpoints. + +Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern) """ import logging @@ -11,17 +13,23 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db -from middleware.vendor_context import require_vendor_context +from app.exceptions import InvalidTokenException +from app.services.vendor_service import vendor_service from models.database.user import User -from models.database.vendor import Vendor router = APIRouter(prefix="/payments") logger = logging.getLogger(__name__) +def _get_vendor_from_token(current_user: User, db: Session): + """Helper to get vendor from JWT token.""" + if not hasattr(current_user, "token_vendor_id"): + raise InvalidTokenException("Token missing vendor information. Please login again.") + return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) + + @router.get("/config") def get_payment_configuration( - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -34,6 +42,7 @@ def get_payment_configuration( - Get currency settings - Return masked/secure information only """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return { "payment_gateway": None, "accepted_methods": [], @@ -46,7 +55,6 @@ def get_payment_configuration( @router.put("/config") def update_payment_configuration( payment_config: dict, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -59,13 +67,13 @@ def update_payment_configuration( - Update accepted payment methods - Validate configuration before saving """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Payment configuration update coming in Slice 5"} @router.post("/stripe/connect") def connect_stripe_account( stripe_data: dict, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -78,12 +86,12 @@ def connect_stripe_account( - Verify Stripe account is active - Enable payment processing """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Stripe connection coming in Slice 5"} @router.delete("/stripe/disconnect") def disconnect_stripe_account( - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -95,12 +103,12 @@ def disconnect_stripe_account( - Disable payment processing - Warn about pending payments """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Stripe disconnection coming in Slice 5"} @router.get("/methods") def get_payment_methods( - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -111,12 +119,12 @@ def get_payment_methods( - Return list of enabled payment methods - Include: credit card, PayPal, bank transfer, etc. """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"methods": [], "message": "Payment methods coming in Slice 5"} @router.get("/transactions") def get_payment_transactions( - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -129,6 +137,7 @@ def get_payment_transactions( - Include payment details - Support pagination """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return { "transactions": [], "total": 0, @@ -138,7 +147,6 @@ def get_payment_transactions( @router.get("/balance") def get_payment_balance( - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -151,6 +159,7 @@ def get_payment_balance( - Get next payout date - Get payout history """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return { "available_balance": 0.0, "pending_balance": 0.0, @@ -164,7 +173,6 @@ def get_payment_balance( def refund_payment( payment_id: int, refund_data: dict, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -177,4 +185,5 @@ def refund_payment( - Update order status - Send refund notification to customer """ + vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented return {"message": "Payment refund coming in Slice 5"} diff --git a/app/api/v1/vendor/products.py b/app/api/v1/vendor/products.py index 5fce2adc..f00896a4 100644 --- a/app/api/v1/vendor/products.py +++ b/app/api/v1/vendor/products.py @@ -10,13 +10,16 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db +from app.exceptions import InvalidTokenException from app.services.product_service import product_service from models.database.user import User from models.schema.product import ( ProductCreate, + ProductDeleteResponse, ProductDetailResponse, ProductListResponse, ProductResponse, + ProductToggleResponse, ProductUpdate, ) @@ -42,14 +45,9 @@ def get_vendor_products( Vendor is determined from JWT token (vendor_id claim). """ - from fastapi import HTTPException - # Get vendor ID from token if not hasattr(current_user, "token_vendor_id"): - raise HTTPException( - status_code=400, - detail="Token missing vendor information. Please login again.", - ) + raise InvalidTokenException("Token missing vendor information. Please login again.") vendor_id = current_user.token_vendor_id @@ -77,14 +75,9 @@ def get_product_details( db: Session = Depends(get_db), ): """Get detailed product information including inventory.""" - from fastapi import HTTPException - # Get vendor ID from token if not hasattr(current_user, "token_vendor_id"): - raise HTTPException( - status_code=400, - detail="Token missing vendor information. Please login again.", - ) + raise InvalidTokenException("Token missing vendor information. Please login again.") vendor_id = current_user.token_vendor_id @@ -106,14 +99,9 @@ def add_product_to_catalog( This publishes a MarketplaceProduct to the vendor's public catalog. """ - from fastapi import HTTPException - # Get vendor ID from token if not hasattr(current_user, "token_vendor_id"): - raise HTTPException( - status_code=400, - detail="Token missing vendor information. Please login again.", - ) + raise InvalidTokenException("Token missing vendor information. Please login again.") vendor_id = current_user.token_vendor_id @@ -137,14 +125,9 @@ def update_product( db: Session = Depends(get_db), ): """Update product in vendor catalog.""" - from fastapi import HTTPException - # Get vendor ID from token if not hasattr(current_user, "token_vendor_id"): - raise HTTPException( - status_code=400, - detail="Token missing vendor information. Please login again.", - ) + raise InvalidTokenException("Token missing vendor information. Please login again.") vendor_id = current_user.token_vendor_id @@ -160,21 +143,16 @@ def update_product( return ProductResponse.model_validate(product) -@router.delete("/{product_id}") +@router.delete("/{product_id}", response_model=ProductDeleteResponse) def remove_product_from_catalog( product_id: int, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Remove product from vendor catalog.""" - from fastapi import HTTPException - # Get vendor ID from token if not hasattr(current_user, "token_vendor_id"): - raise HTTPException( - status_code=400, - detail="Token missing vendor information. Please login again.", - ) + raise InvalidTokenException("Token missing vendor information. Please login again.") vendor_id = current_user.token_vendor_id @@ -185,7 +163,7 @@ def remove_product_from_catalog( f"for vendor {current_user.token_vendor_code}" ) - return {"message": f"Product {product_id} removed from catalog"} + return ProductDeleteResponse(message=f"Product {product_id} removed from catalog") @router.post("/from-import/{marketplace_product_id}", response_model=ProductResponse) @@ -199,14 +177,9 @@ def publish_from_marketplace( Shortcut endpoint for publishing directly from marketplace import. """ - from fastapi import HTTPException - # Get vendor ID from token if not hasattr(current_user, "token_vendor_id"): - raise HTTPException( - status_code=400, - detail="Token missing vendor information. Please login again.", - ) + raise InvalidTokenException("Token missing vendor information. Please login again.") vendor_id = current_user.token_vendor_id @@ -226,21 +199,16 @@ def publish_from_marketplace( return ProductResponse.model_validate(product) -@router.put("/{product_id}/toggle-active") +@router.put("/{product_id}/toggle-active", response_model=ProductToggleResponse) def toggle_product_active( product_id: int, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Toggle product active status.""" - from fastapi import HTTPException - # Get vendor ID from token if not hasattr(current_user, "token_vendor_id"): - raise HTTPException( - status_code=400, - detail="Token missing vendor information. Please login again.", - ) + raise InvalidTokenException("Token missing vendor information. Please login again.") vendor_id = current_user.token_vendor_id @@ -253,24 +221,19 @@ def toggle_product_active( status = "activated" if product.is_active else "deactivated" logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}") - return {"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") +@router.put("/{product_id}/toggle-featured", response_model=ProductToggleResponse) def toggle_product_featured( product_id: int, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Toggle product featured status.""" - from fastapi import HTTPException - # Get vendor ID from token if not hasattr(current_user, "token_vendor_id"): - raise HTTPException( - status_code=400, - detail="Token missing vendor information. Please login again.", - ) + raise InvalidTokenException("Token missing vendor information. Please login again.") vendor_id = current_user.token_vendor_id @@ -283,4 +246,4 @@ def toggle_product_featured( status = "featured" if product.is_featured else "unfeatured" logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}") - return {"message": f"Product {status}", "is_featured": product.is_featured} + return ProductToggleResponse(message=f"Product {status}", is_featured=product.is_featured) diff --git a/app/api/v1/vendor/profile.py b/app/api/v1/vendor/profile.py index 2161b493..a00b707f 100644 --- a/app/api/v1/vendor/profile.py +++ b/app/api/v1/vendor/profile.py @@ -1,45 +1,54 @@ # app/api/v1/vendor/profile.py """ Vendor profile management endpoints. + +Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern) """ import logging -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db +from app.exceptions import InsufficientPermissionsException, InvalidTokenException from app.services.vendor_service import vendor_service -from middleware.vendor_context import require_vendor_context from models.database.user import User -from models.database.vendor import Vendor from models.schema.vendor import VendorResponse, VendorUpdate router = APIRouter(prefix="/profile") logger = logging.getLogger(__name__) +def _get_vendor_from_token(current_user: User, db: Session): + """Helper to get vendor from JWT token.""" + if not hasattr(current_user, "token_vendor_id"): + raise InvalidTokenException("Token missing vendor information. Please login again.") + return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) + + @router.get("", response_model=VendorResponse) def get_vendor_profile( - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get current vendor profile information.""" + vendor = _get_vendor_from_token(current_user, db) return vendor @router.put("", response_model=VendorResponse) def update_vendor_profile( vendor_update: VendorUpdate, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Update vendor profile information.""" + vendor = _get_vendor_from_token(current_user, db) + # Verify user has permission to update vendor if not vendor_service.can_update_vendor(vendor, current_user): - raise HTTPException(status_code=403, detail="Insufficient permissions") + raise InsufficientPermissionsException(required_permission="vendor:profile:update") return vendor_service.update_vendor(db, vendor.id, vendor_update) diff --git a/app/api/v1/vendor/settings.py b/app/api/v1/vendor/settings.py index 0ab97b5c..552e37e5 100644 --- a/app/api/v1/vendor/settings.py +++ b/app/api/v1/vendor/settings.py @@ -1,19 +1,20 @@ # app/api/v1/vendor/settings.py """ Vendor settings and configuration endpoints. + +Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern) """ import logging -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db +from app.exceptions import InsufficientPermissionsException, InvalidTokenException from app.services.vendor_service import vendor_service -from middleware.vendor_context import require_vendor_context from models.database.user import User -from models.database.vendor import Vendor router = APIRouter(prefix="/settings") logger = logging.getLogger(__name__) @@ -21,11 +22,16 @@ logger = logging.getLogger(__name__) @router.get("") def get_vendor_settings( - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get vendor settings and configuration.""" + # Get vendor ID from JWT token + if not hasattr(current_user, "token_vendor_id"): + raise InvalidTokenException("Token missing vendor information. Please login again.") + + vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) + return { "vendor_code": vendor.vendor_code, "subdomain": vendor.subdomain, @@ -46,14 +52,21 @@ def get_vendor_settings( @router.put("/marketplace") def update_marketplace_settings( marketplace_config: dict, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Update marketplace integration settings.""" + # Get vendor ID from JWT token + if not hasattr(current_user, "token_vendor_id"): + raise InvalidTokenException("Token missing vendor information. Please login again.") + + vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) + # Verify permissions if not vendor_service.can_update_vendor(vendor, current_user): - raise HTTPException(status_code=403, detail="Insufficient permissions") + raise InsufficientPermissionsException( + required_permission="vendor:settings:update" + ) # Update Letzshop URLs if "letzshop_csv_url_fr" in marketplace_config: diff --git a/app/exceptions/__init__.py b/app/exceptions/__init__.py index 6ebb8097..7280116b 100644 --- a/app/exceptions/__init__.py +++ b/app/exceptions/__init__.py @@ -14,7 +14,9 @@ from .admin import ( CannotModifySelfException, ConfirmationRequiredException, InvalidAdminActionException, + UserCannotBeDeletedException, UserNotFoundException, + UserRoleChangeException, UserStatusChangeException, VendorVerificationException, ) @@ -44,6 +46,17 @@ from .base import ( WizamartException, ) +# Code quality exceptions +from .code_quality import ( + InvalidViolationStatusException, + ScanExecutionException, + ScanNotFoundException, + ScanParseException, + ScanTimeoutException, + ViolationNotFoundException, + ViolationOperationException, +) + # Cart exceptions from .cart import ( CartItemNotFoundException, @@ -155,13 +168,16 @@ from .team import ( # Vendor exceptions from .vendor import ( + InsufficientVendorPermissionsException, InvalidVendorDataException, MaxVendorsReachedException, UnauthorizedVendorAccessException, + VendorAccessDeniedException, VendorAlreadyExistsException, VendorNotActiveException, VendorNotFoundException, VendorNotVerifiedException, + VendorOwnerOnlyException, VendorValidationException, ) @@ -245,13 +261,16 @@ __all__ = [ "InvalidQuantityException", "LocationNotFoundException", # Vendor exceptions - "VendorNotFoundException", - "VendorAlreadyExistsException", - "VendorNotActiveException", - "VendorNotVerifiedException", - "UnauthorizedVendorAccessException", + "InsufficientVendorPermissionsException", "InvalidVendorDataException", "MaxVendorsReachedException", + "UnauthorizedVendorAccessException", + "VendorAccessDeniedException", + "VendorAlreadyExistsException", + "VendorNotActiveException", + "VendorNotFoundException", + "VendorNotVerifiedException", + "VendorOwnerOnlyException", "VendorValidationException", # Vendor Domain "VendorDomainNotFoundException", @@ -334,4 +353,12 @@ __all__ = [ "InvalidAdminActionException", "BulkOperationException", "ConfirmationRequiredException", + # Code quality exceptions + "ViolationNotFoundException", + "ScanNotFoundException", + "ScanExecutionException", + "ScanTimeoutException", + "ScanParseException", + "ViolationOperationException", + "InvalidViolationStatusException", ] diff --git a/app/exceptions/admin.py b/app/exceptions/admin.py index c385f925..a6f39049 100644 --- a/app/exceptions/admin.py +++ b/app/exceptions/admin.py @@ -236,3 +236,37 @@ class VendorVerificationException(BusinessLogicException): error_code="VENDOR_VERIFICATION_FAILED", details=details, ) + + +class UserCannotBeDeletedException(BusinessLogicException): + """Raised when a user cannot be deleted due to ownership constraints.""" + + def __init__(self, user_id: int, reason: str, owned_count: int = 0): + details = { + "user_id": user_id, + "reason": reason, + } + if owned_count > 0: + details["owned_companies_count"] = owned_count + + super().__init__( + message=f"Cannot delete user {user_id}: {reason}", + error_code="USER_CANNOT_BE_DELETED", + details=details, + ) + + +class UserRoleChangeException(BusinessLogicException): + """Raised when user role cannot be changed.""" + + def __init__(self, user_id: int, current_role: str, target_role: str, reason: str): + super().__init__( + message=f"Cannot change user {user_id} role from {current_role} to {target_role}: {reason}", + error_code="USER_ROLE_CHANGE_FAILED", + details={ + "user_id": user_id, + "current_role": current_role, + "target_role": target_role, + "reason": reason, + }, + ) diff --git a/app/exceptions/code_quality.py b/app/exceptions/code_quality.py new file mode 100644 index 00000000..d385f62c --- /dev/null +++ b/app/exceptions/code_quality.py @@ -0,0 +1,95 @@ +# app/exceptions/code_quality.py +""" +Code Quality Domain Exceptions + +These exceptions are raised by the code quality service layer +and converted to HTTP responses by the global exception handler. +""" + +from app.exceptions.base import ( + BusinessLogicException, + ExternalServiceException, + ResourceNotFoundException, + ValidationException, +) + + +class ViolationNotFoundException(ResourceNotFoundException): + """Raised when a violation is not found.""" + + def __init__(self, violation_id: int): + super().__init__( + resource_type="Violation", + identifier=str(violation_id), + error_code="VIOLATION_NOT_FOUND", + ) + + +class ScanNotFoundException(ResourceNotFoundException): + """Raised when a scan is not found.""" + + def __init__(self, scan_id: int): + super().__init__( + resource_type="Scan", + identifier=str(scan_id), + error_code="SCAN_NOT_FOUND", + ) + + +class ScanExecutionException(ExternalServiceException): + """Raised when architecture scan execution fails.""" + + def __init__(self, reason: str): + super().__init__( + service_name="ArchitectureValidator", + message=f"Scan execution failed: {reason}", + error_code="SCAN_EXECUTION_FAILED", + ) + + +class ScanTimeoutException(ExternalServiceException): + """Raised when architecture scan times out.""" + + def __init__(self, timeout_seconds: int = 300): + super().__init__( + service_name="ArchitectureValidator", + message=f"Scan timed out after {timeout_seconds} seconds", + error_code="SCAN_TIMEOUT", + ) + + +class ScanParseException(BusinessLogicException): + """Raised when scan results cannot be parsed.""" + + def __init__(self, reason: str): + super().__init__( + message=f"Failed to parse scan results: {reason}", + error_code="SCAN_PARSE_FAILED", + ) + + +class ViolationOperationException(BusinessLogicException): + """Raised when a violation operation fails.""" + + def __init__(self, operation: str, violation_id: int, reason: str): + super().__init__( + message=f"Failed to {operation} violation {violation_id}: {reason}", + error_code="VIOLATION_OPERATION_FAILED", + details={ + "operation": operation, + "violation_id": violation_id, + "reason": reason, + }, + ) + + +class InvalidViolationStatusException(ValidationException): + """Raised when a violation status transition is invalid.""" + + def __init__(self, violation_id: int, current_status: str, target_status: str): + super().__init__( + message=f"Cannot change violation {violation_id} from '{current_status}' to '{target_status}'", + field="status", + value=target_status, + ) + self.error_code = "INVALID_VIOLATION_STATUS" diff --git a/app/exceptions/content_page.py b/app/exceptions/content_page.py new file mode 100644 index 00000000..e4607b97 --- /dev/null +++ b/app/exceptions/content_page.py @@ -0,0 +1,82 @@ +# app/exceptions/content_page.py +""" +Content Page Domain Exceptions + +These exceptions are raised by the content page service layer +and converted to HTTP responses by the global exception handler. +""" + +from app.exceptions.base import ( + AuthorizationException, + BusinessLogicException, + ConflictException, + ResourceNotFoundException, + ValidationException, +) + + +class ContentPageNotFoundException(ResourceNotFoundException): + """Raised when a content page is not found.""" + + def __init__(self, identifier: str | int | None = None): + if identifier: + message = f"Content page not found: {identifier}" + else: + message = "Content page not found" + super().__init__(message=message, resource_type="content_page") + + +class ContentPageAlreadyExistsException(ConflictException): + """Raised when a content page with the same slug already exists.""" + + def __init__(self, slug: str, vendor_id: int | None = None): + if vendor_id: + message = f"Content page with slug '{slug}' already exists for this vendor" + else: + message = f"Platform content page with slug '{slug}' already exists" + super().__init__(message=message) + + +class ContentPageSlugReservedException(ValidationException): + """Raised when trying to use a reserved slug.""" + + def __init__(self, slug: str): + super().__init__( + message=f"Content page slug '{slug}' is reserved", + field="slug", + value=slug, + ) + + +class ContentPageNotPublishedException(BusinessLogicException): + """Raised when trying to access an unpublished content page.""" + + def __init__(self, slug: str): + super().__init__(message=f"Content page '{slug}' is not published") + + +class UnauthorizedContentPageAccessException(AuthorizationException): + """Raised when a user tries to access/modify a content page they don't own.""" + + def __init__(self, action: str = "access"): + super().__init__( + message=f"Cannot {action} content pages from other vendors", + required_permission=f"content_page:{action}", + ) + + +class VendorNotAssociatedException(AuthorizationException): + """Raised when a user is not associated with a vendor.""" + + def __init__(self): + super().__init__( + message="User is not associated with a vendor", + required_permission="vendor:member", + ) + + +class ContentPageValidationException(ValidationException): + """Raised when content page data validation fails.""" + + def __init__(self, field: str, message: str, value: str | None = None): + super().__init__(message=message, field=field, value=value) diff --git a/app/exceptions/vendor.py b/app/exceptions/vendor.py index d10f42ab..e33ce9b6 100644 --- a/app/exceptions/vendor.py +++ b/app/exceptions/vendor.py @@ -148,3 +148,43 @@ class MaxVendorsReachedException(BusinessLogicException): error_code="MAX_VENDORS_REACHED", details=details, ) + + +class VendorAccessDeniedException(AuthorizationException): + """Raised when no vendor context is available for an authenticated endpoint.""" + + def __init__(self, message: str = "No vendor context available"): + super().__init__( + message=message, + error_code="VENDOR_ACCESS_DENIED", + ) + + +class VendorOwnerOnlyException(AuthorizationException): + """Raised when operation requires vendor owner role.""" + + def __init__(self, operation: str, vendor_code: str | None = None): + details = {"operation": operation} + if vendor_code: + details["vendor_code"] = vendor_code + + super().__init__( + message=f"Operation '{operation}' requires vendor owner role", + error_code="VENDOR_OWNER_ONLY", + details=details, + ) + + +class InsufficientVendorPermissionsException(AuthorizationException): + """Raised when user lacks required vendor permission.""" + + def __init__(self, required_permission: str, vendor_code: str | None = None): + details = {"required_permission": required_permission} + if vendor_code: + details["vendor_code"] = vendor_code + + super().__init__( + message=f"Permission required: {required_permission}", + error_code="INSUFFICIENT_VENDOR_PERMISSIONS", + details=details, + ) diff --git a/app/services/admin_service.py b/app/services/admin_service.py index 7f6c9dc7..32609dba 100644 --- a/app/services/admin_service.py +++ b/app/services/admin_service.py @@ -21,13 +21,17 @@ from sqlalchemy.orm import Session, joinedload from app.exceptions import ( AdminOperationException, CannotModifySelfException, + UserCannotBeDeletedException, UserNotFoundException, + UserRoleChangeException, UserStatusChangeException, ValidationException, VendorAlreadyExistsException, VendorNotFoundException, VendorVerificationException, ) +from app.exceptions.auth import UserAlreadyExistsException +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 @@ -97,6 +101,244 @@ class AdminService: reason="Database update failed", ) + def list_users( + self, + db: Session, + page: int = 1, + per_page: int = 10, + search: str | None = None, + role: str | None = None, + is_active: bool | None = None, + ) -> tuple[list[User], int, int]: + """ + Get paginated list of users with filtering. + + Returns: + Tuple of (users, total_count, total_pages) + """ + import math + + query = db.query(User) + + # Apply filters + if search: + search_term = f"%{search.lower()}%" + query = query.filter( + or_( + User.username.ilike(search_term), + User.email.ilike(search_term), + User.first_name.ilike(search_term), + User.last_name.ilike(search_term), + ) + ) + + if role: + query = query.filter(User.role == role) + + if is_active is not None: + query = query.filter(User.is_active == is_active) + + # Get total count + total = query.count() + pages = math.ceil(total / per_page) if total > 0 else 1 + + # Apply pagination + skip = (page - 1) * per_page + users = query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all() + + return users, total, pages + + def create_user( + self, + db: Session, + email: str, + username: str, + password: str, + first_name: str | None = None, + last_name: str | None = None, + role: str = "customer", + current_admin_id: int | None = None, + ) -> User: + """ + Create a new user. + + Raises: + UserAlreadyExistsException: If email or username already exists + """ + # Check if email exists + if db.query(User).filter(User.email == email).first(): + raise UserAlreadyExistsException("Email already registered", field="email") + + # Check if username exists + if db.query(User).filter(User.username == username).first(): + raise UserAlreadyExistsException("Username already taken", field="username") + + # Create user + auth_manager = AuthManager() + user = User( + email=email, + username=username, + hashed_password=auth_manager.hash_password(password), + first_name=first_name, + last_name=last_name, + role=role, + is_active=True, + ) + db.add(user) + db.commit() + db.refresh(user) + + logger.info(f"Admin {current_admin_id} created user {user.username}") + return user + + def get_user_details(self, db: Session, user_id: int) -> User: + """ + Get user with relationships loaded. + + Raises: + UserNotFoundException: If user not found + """ + user = ( + db.query(User) + .options(joinedload(User.owned_companies), joinedload(User.vendor_memberships)) + .filter(User.id == user_id) + .first() + ) + + if not user: + raise UserNotFoundException(str(user_id)) + + return user + + def update_user( + self, + db: Session, + user_id: int, + current_admin_id: int, + email: str | None = None, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + role: str | None = None, + is_active: bool | None = None, + ) -> User: + """ + Update user information. + + Raises: + UserNotFoundException: If user not found + UserAlreadyExistsException: If email/username already taken + UserRoleChangeException: If trying to change own admin role + """ + user = self._get_user_by_id_or_raise(db, user_id) + + # Prevent changing own admin status + if user.id == current_admin_id and role and role != "admin": + raise UserRoleChangeException( + user_id=user_id, + current_role=user.role, + target_role=role, + reason="Cannot change your own admin role", + ) + + # 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") + + # 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") + + # Update fields + if email is not None: + user.email = email + if username is not None: + user.username = username + if first_name is not None: + user.first_name = first_name + if last_name is not None: + user.last_name = last_name + if role is not None: + user.role = role + if is_active is not None: + user.is_active = is_active + + user.updated_at = datetime.now(UTC) + db.commit() + db.refresh(user) + + logger.info(f"Admin {current_admin_id} updated user {user.username}") + return user + + def delete_user(self, db: Session, user_id: int, current_admin_id: int) -> str: + """ + Delete a user. + + Raises: + UserNotFoundException: If user not found + CannotModifySelfException: If trying to delete yourself + UserCannotBeDeletedException: If user owns companies + """ + user = ( + db.query(User) + .options(joinedload(User.owned_companies)) + .filter(User.id == user_id) + .first() + ) + + if not user: + raise UserNotFoundException(str(user_id)) + + # Prevent deleting yourself + if user.id == current_admin_id: + raise CannotModifySelfException(user_id, "delete account") + + # Prevent deleting users who own companies + if user.owned_companies: + raise UserCannotBeDeletedException( + user_id=user_id, + reason=f"User owns {len(user.owned_companies)} company(ies). Transfer ownership first.", + owned_count=len(user.owned_companies), + ) + + username = user.username + db.delete(user) + db.commit() + + logger.info(f"Admin {current_admin_id} deleted user {username}") + return f"User {username} deleted successfully" + + def search_users( + self, + db: Session, + query: str, + limit: int = 10, + ) -> list[dict]: + """ + Search users by username or email. + + Used for autocomplete in ownership transfer. + """ + search_term = f"%{query.lower()}%" + users = ( + db.query(User) + .filter(or_(User.username.ilike(search_term), User.email.ilike(search_term))) + .limit(limit) + .all() + ) + + return [ + { + "id": user.id, + "username": user.username, + "email": user.email, + "is_active": user.is_active, + } + for user in users + ] + # ============================================================================ # VENDOR MANAGEMENT # ============================================================================ diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 9a15680b..2e16a87f 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -22,6 +22,7 @@ from app.exceptions import ( ) from middleware.auth import AuthManager from models.database.user import User +from models.database.vendor import Vendor, VendorUser from models.schema.auth import UserLogin, UserRegister logger = logging.getLogger(__name__) @@ -214,6 +215,84 @@ class AuthService: logger.error(f"Error creating access token with data: {str(e)}") raise ValidationException("Failed to create access token") + def get_vendor_by_code(self, db: Session, vendor_code: str) -> Vendor | None: + """ + Get active vendor by vendor code. + + Args: + db: Database session + vendor_code: Vendor code to look up + + Returns: + Vendor if found and active, None otherwise + """ + return ( + db.query(Vendor) + .filter(Vendor.vendor_code == vendor_code.upper(), Vendor.is_active == True) + .first() + ) + + def get_user_vendor_role( + self, db: Session, user: User, vendor: Vendor + ) -> tuple[bool, str | None]: + """ + Check if user has access to vendor and return their role. + + Args: + db: Database session + user: User to check + vendor: Vendor to check access for + + Returns: + Tuple of (has_access: bool, role_name: str | None) + """ + # Check if user is vendor owner (via company ownership) + if vendor.company and vendor.company.owner_user_id == user.id: + return True, "Owner" + + # Check if user is team member + vendor_user = ( + db.query(VendorUser) + .filter( + VendorUser.user_id == user.id, + VendorUser.vendor_id == vendor.id, + VendorUser.is_active == True, + ) + .first() + ) + + if vendor_user: + return True, vendor_user.role.name + + return False, None + + def find_user_vendor(self, user: User) -> tuple[Vendor | None, str | None]: + """ + Find which vendor a user belongs to when no vendor context is provided. + + Checks owned companies first, then vendor memberships. + + Args: + user: User to find vendor for + + Returns: + Tuple of (vendor: Vendor | None, role: str | None) + """ + # Check owned vendors first (via company ownership) + for company in user.owned_companies: + if company.vendors: + return company.vendors[0], "Owner" + + # Check vendor memberships + if user.vendor_memberships: + active_membership = next( + (vm for vm in user.vendor_memberships if vm.is_active), None + ) + if active_membership: + return active_membership.vendor, active_membership.role.name + + return None, None + # Private helper methods def _email_exists(self, db: Session, email: str) -> bool: """Check if email already exists.""" diff --git a/app/services/code_quality_service.py b/app/services/code_quality_service.py index 71d0f76c..d25f24f0 100644 --- a/app/services/code_quality_service.py +++ b/app/services/code_quality_service.py @@ -11,6 +11,11 @@ from datetime import datetime from sqlalchemy import desc, func from sqlalchemy.orm import Session +from app.exceptions import ( + ScanParseException, + ScanTimeoutException, + ViolationNotFoundException, +) from models.database.architecture_scan import ( ArchitectureScan, ArchitectureViolation, @@ -54,7 +59,7 @@ class CodeQualityService: ) except subprocess.TimeoutExpired: logger.error("Architecture scan timed out after 5 minutes") - raise Exception("Scan timed out") + raise ScanTimeoutException(timeout_seconds=300) duration = (datetime.now() - start_time).total_seconds() @@ -77,7 +82,7 @@ class CodeQualityService: logger.error(f"Failed to parse validator output: {e}") logger.error(f"Stdout: {result.stdout}") logger.error(f"Stderr: {result.stderr}") - raise Exception(f"Failed to parse scan results: {e}") + raise ScanParseException(reason=str(e)) # Create scan record scan = ArchitectureScan( @@ -285,7 +290,7 @@ class CodeQualityService: """ violation = self.get_violation_by_id(db, violation_id) if not violation: - raise ValueError(f"Violation {violation_id} not found") + raise ViolationNotFoundException(violation_id) violation.status = "resolved" violation.resolved_at = datetime.now() @@ -313,7 +318,7 @@ class CodeQualityService: """ violation = self.get_violation_by_id(db, violation_id) if not violation: - raise ValueError(f"Violation {violation_id} not found") + raise ViolationNotFoundException(violation_id) violation.status = "ignored" violation.resolved_at = datetime.now() diff --git a/app/services/content_page_service.py b/app/services/content_page_service.py index 6394241f..c639b885 100644 --- a/app/services/content_page_service.py +++ b/app/services/content_page_service.py @@ -22,6 +22,10 @@ from datetime import UTC, datetime from sqlalchemy import and_ from sqlalchemy.orm import Session +from app.exceptions.content_page import ( + ContentPageNotFoundException, + UnauthorizedContentPageAccessException, +) from models.database.content_page import ContentPage logger = logging.getLogger(__name__) @@ -319,6 +323,214 @@ class ContentPageService: """Get content page by ID.""" return db.query(ContentPage).filter(ContentPage.id == page_id).first() + @staticmethod + def get_page_by_id_or_raise(db: Session, page_id: int) -> ContentPage: + """ + Get content page by ID or raise ContentPageNotFoundException. + + Args: + db: Database session + page_id: Page ID + + Returns: + ContentPage + + Raises: + ContentPageNotFoundException: If page not found + """ + page = db.query(ContentPage).filter(ContentPage.id == page_id).first() + if not page: + raise ContentPageNotFoundException(identifier=page_id) + return page + + @staticmethod + def get_page_for_vendor_or_raise( + db: Session, + slug: str, + vendor_id: int | None = None, + include_unpublished: bool = False, + ) -> ContentPage: + """ + Get content page for a vendor with fallback to platform default. + Raises ContentPageNotFoundException if not found. + + Args: + db: Database session + slug: Page slug + vendor_id: Vendor ID + include_unpublished: Include draft pages + + Returns: + ContentPage + + Raises: + ContentPageNotFoundException: If page not found + """ + page = ContentPageService.get_page_for_vendor( + db, slug=slug, vendor_id=vendor_id, include_unpublished=include_unpublished + ) + if not page: + raise ContentPageNotFoundException(identifier=slug) + return page + + @staticmethod + def update_page_or_raise( + db: Session, + page_id: int, + title: str | None = None, + content: str | None = None, + content_format: str | None = None, + template: str | None = None, + meta_description: str | None = None, + meta_keywords: str | None = None, + is_published: bool | None = None, + show_in_footer: bool | None = None, + show_in_header: bool | None = None, + display_order: int | None = None, + updated_by: int | None = None, + ) -> ContentPage: + """ + Update an existing content page or raise exception. + + Raises: + ContentPageNotFoundException: If page not found + """ + page = ContentPageService.update_page( + db, + page_id=page_id, + title=title, + content=content, + content_format=content_format, + template=template, + meta_description=meta_description, + meta_keywords=meta_keywords, + is_published=is_published, + show_in_footer=show_in_footer, + show_in_header=show_in_header, + display_order=display_order, + updated_by=updated_by, + ) + if not page: + raise ContentPageNotFoundException(identifier=page_id) + return page + + @staticmethod + def delete_page_or_raise(db: Session, page_id: int) -> None: + """ + Delete a content page or raise exception. + + Raises: + ContentPageNotFoundException: If page not found + """ + success = ContentPageService.delete_page(db, page_id) + if not success: + raise ContentPageNotFoundException(identifier=page_id) + + @staticmethod + def update_vendor_page( + db: Session, + page_id: int, + vendor_id: int, + title: str | None = None, + content: str | None = None, + content_format: str | None = None, + meta_description: str | None = None, + meta_keywords: str | None = None, + is_published: bool | None = None, + show_in_footer: bool | None = None, + show_in_header: bool | None = None, + display_order: int | None = None, + updated_by: int | None = None, + ) -> ContentPage: + """ + Update a vendor-specific content page with ownership check. + + Args: + db: Database session + page_id: Page ID + vendor_id: Vendor ID (for ownership verification) + ... other fields + + Returns: + Updated ContentPage + + Raises: + ContentPageNotFoundException: If page not found + UnauthorizedContentPageAccessException: If page doesn't belong to vendor + """ + page = ContentPageService.get_page_by_id_or_raise(db, page_id) + + if page.vendor_id != vendor_id: + raise UnauthorizedContentPageAccessException(action="edit") + + return ContentPageService.update_page_or_raise( + db, + page_id=page_id, + title=title, + content=content, + content_format=content_format, + meta_description=meta_description, + meta_keywords=meta_keywords, + is_published=is_published, + show_in_footer=show_in_footer, + show_in_header=show_in_header, + display_order=display_order, + updated_by=updated_by, + ) + + @staticmethod + def delete_vendor_page(db: Session, page_id: int, vendor_id: int) -> None: + """ + Delete a vendor-specific content page with ownership check. + + Args: + db: Database session + page_id: Page ID + vendor_id: Vendor ID (for ownership verification) + + Raises: + ContentPageNotFoundException: If page not found + UnauthorizedContentPageAccessException: If page doesn't belong to vendor + """ + page = ContentPageService.get_page_by_id_or_raise(db, page_id) + + if page.vendor_id != vendor_id: + raise UnauthorizedContentPageAccessException(action="delete") + + ContentPageService.delete_page_or_raise(db, page_id) + + @staticmethod + def list_all_pages( + db: Session, + vendor_id: int | None = None, + include_unpublished: bool = False, + ) -> list[ContentPage]: + """ + List all content pages (platform defaults and vendor overrides). + + Args: + db: Database session + vendor_id: Optional filter by vendor ID + include_unpublished: Include draft pages + + Returns: + List of ContentPage objects + """ + filters = [] + + if vendor_id: + filters.append(ContentPage.vendor_id == vendor_id) + + if not include_unpublished: + filters.append(ContentPage.is_published == True) + + return ( + db.query(ContentPage) + .filter(and_(*filters) if filters else True) + .order_by(ContentPage.vendor_id, ContentPage.display_order, ContentPage.title) + .all() + ) + @staticmethod def list_all_vendor_pages( db: Session, vendor_id: int, include_unpublished: bool = False diff --git a/app/templates/admin/base.html b/app/templates/admin/base.html index 3cf5e339..a2c8fda5 100644 --- a/app/templates/admin/base.html +++ b/app/templates/admin/base.html @@ -1,6 +1,6 @@ {# app/templates/admin/base.html #} - + @@ -10,11 +10,7 @@ - - - - + diff --git a/app/templates/admin/login.html b/app/templates/admin/login.html index 313a392d..47cd82f7 100644 --- a/app/templates/admin/login.html +++ b/app/templates/admin/login.html @@ -1,6 +1,6 @@ {# app/templates/admin/login.html #} - + diff --git a/app/templates/admin/monitoring.html b/app/templates/admin/monitoring.html index 7a52b543..ed52e166 100644 --- a/app/templates/admin/monitoring.html +++ b/app/templates/admin/monitoring.html @@ -1,4 +1,5 @@ - +{# standalone - Minimal monitoring page without admin chrome #} + diff --git a/app/templates/admin/test-auth-flow.html b/app/templates/admin/test-auth-flow.html index 52eba53c..d3ef2c6f 100644 --- a/app/templates/admin/test-auth-flow.html +++ b/app/templates/admin/test-auth-flow.html @@ -1,519 +1,253 @@ - - - - - - Auth Flow Testing - Admin Panel - - - -
-

🧪 Auth Flow Testing

-

Comprehensive testing for the Jinja2 migration auth loop fix

- - -
-

📊 Log Level Control

-

- Change logging verbosity for login.js and api-client.js +{% block content %} +

+ {# Page Header #} +
+
+

+ Auth Flow Testing +

+

+ Comprehensive testing for Jinja2 migration auth loop fix

-
- - - - - -
-

- Current levels: LOGIN = 4, API = 3 -

-
- - -
-

Test 1: Clean Slate - Fresh Login Flow

-

- Tests the complete login flow from scratch with no existing tokens. -

- -
- Steps: -
    -
  1. Click "Clear All Data" below
  2. -
  3. Click "Navigate to /admin"
  4. -
  5. Observe browser behavior and console logs
  6. -
  7. You should land on login page
  8. -
-
- -
- ✅ Expected Result: -
    -
  • Single redirect: /admin → /admin/login
  • -
  • Login page loads with NO API calls to /admin/auth/me
  • -
  • No loops, no errors in console
  • -
  • Form is ready for input
  • -
-
- -
- - - -
-
- - -
-

Test 2: Successful Login

-

- Tests that login works correctly and redirects to dashboard. -

- -
- Steps: -
    -
  1. Ensure you're on /admin/login
  2. -
  3. Enter valid admin credentials
  4. -
  5. Click "Login"
  6. -
  7. Observe redirect and dashboard load
  8. -
-
- -
- ✅ Expected Result: -
    -
  • Login API call succeeds (check Network tab)
  • -
  • Token stored in localStorage
  • -
  • Success message shows briefly
  • -
  • Redirect to /admin/dashboard after 500ms
  • -
  • Dashboard loads with stats and recent vendors
  • -
-
- -
- - -
-
- - -
-

Test 3: Dashboard Refresh (Authenticated)

-

- Tests that refreshing the dashboard works without redirect loops. -

- -
- Steps: -
    -
  1. Complete Test 2 (login successfully)
  2. -
  3. On dashboard, press F5 or click "Refresh Page"
  4. -
  5. Observe page reload behavior
  6. -
-
- -
- ✅ Expected Result: -
    -
  • Dashboard reloads normally
  • -
  • No redirects to login
  • -
  • Stats and vendors load correctly
  • -
  • No console errors
  • -
-
- -
- - -
-
- - -
-

Test 4: Expired Token Handling

-

- Tests that expired tokens are handled gracefully with redirect to login. -

- -
- Steps: -
    -
  1. Click "Set Expired Token"
  2. -
  3. Click "Navigate to Dashboard"
  4. -
  5. Observe authentication failure and redirect
  6. -
-
- -
- ✅ Expected Result: -
    -
  • Server detects expired token
  • -
  • Returns 401 Unauthorized
  • -
  • Browser redirects to /admin/login
  • -
  • Token is cleared from localStorage
  • -
  • No infinite loops
  • -
-
- -
- - -
-
- - -
-

Test 5: Direct Dashboard Access (Unauthenticated)

-

- Tests that accessing dashboard without token redirects to login. -

- -
- Steps: -
    -
  1. Click "Clear All Data"
  2. -
  3. Click "Navigate to Dashboard"
  4. -
  5. Observe immediate redirect to login
  6. -
-
- -
- ✅ Expected Result: -
    -
  • Redirect from /admin/dashboard to /admin/login
  • -
  • No API calls attempted
  • -
  • Login page loads correctly
  • -
-
- -
- - -
-
- - -
-

Test 6: Login Page with Valid Token

-

- Tests what happens when user visits login page while already authenticated. -

- -
- Steps: -
    -
  1. Login successfully (Test 2)
  2. -
  3. Click "Go to Login Page" below
  4. -
  5. Observe behavior
  6. -
-
- -
- ✅ Expected Result: -
    -
  • Login page loads
  • -
  • Existing token is cleared (init() clears it)
  • -
  • Form is displayed normally
  • -
  • NO redirect loops
  • -
  • NO API calls to validate token
  • -
-
- -
- - -
-
- - -
-

🔍 Current Auth Status

-
-
- Current URL: - - -
-
- Has admin_token: - - -
-
- Has admin_user: - - -
-
- Token Preview: - - -
-
- Username: - - -
-
- -
- - -
-

⚠️ Important Notes

-
    -
  • Always check browser console for detailed logs
  • -
  • Use Network tab to see actual HTTP requests and redirects
  • -
  • Clear browser cache if you see unexpected behavior
  • -
  • Make sure FastAPI server is running on localhost:8000
  • -
  • Valid admin credentials required for login tests
  • -
- - - \ No newline at end of file + }; +} + +{% endblock %} diff --git a/app/templates/admin/test-vendors-users-migration.html b/app/templates/admin/test-vendors-users-migration.html index 1f80ecf9..d69d43e4 100644 --- a/app/templates/admin/test-vendors-users-migration.html +++ b/app/templates/admin/test-vendors-users-migration.html @@ -1,1060 +1,215 @@ - - - - - - Vendors & Users Migration Testing - - - -
-

🧪 Vendors & Users Migration Testing

-

Comprehensive test suite for verifying the Jinja2 migration of admin vendor and user pages

- - -
-

📊 Migration Status

-
-
-
Dashboard
-
✅ Complete
-
-
-
Icons & Utils
-
✅ Fixed
-
-
-
Logout Flow
-
✅ Working
-
-
-
Vendors List
-
⏳ Testing
-
-
-
Vendor Edit
-
⏳ Testing
-
-
-
Users Page
-
⏳ Testing
-
-
-
- - -
-

🚀 Quick Actions

-
- - - - -
- -
-
-
-
0% Complete - Start testing!
-
- - -
-

- Test 1: Vendors List Page (adminVendors) - High Priority +{# app/templates/admin/test-vendors-users-migration.html #} +{% extends 'admin/base.html' %} + +{% block title %}Vendors & Users Migration Testing{% endblock %} + +{% block content %} +
+ {# Page Header #} +
+
+

+ Vendors & Users Migration Testing

-

- Tests the new vendor LIST functionality. This is a NEW function added to vendors.js alongside the existing vendorCreation() function. +

+ Comprehensive test suite for verifying the Jinja2 migration

- -
-

✅ Page Load Checklist:

-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-

📊 Stats Cards Checklist:

-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-

📋 Table Checklist:

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- 🔌 API Endpoints to Check: -
GET/api/v1/admin/vendors
-
GET/api/v1/admin/vendors/stats
-
DELETE/api/v1/admin/vendors/{code}
-
- -
- ✅ Expected Result: -
    -
  • NEW adminVendors() function works correctly
  • -
  • Uses ApiClient (uppercase), not apiClient
  • -
  • Uses Logger.info() for logging
  • -
  • Uses Utils.showToast() for notifications
  • -
  • Page matches dashboard styling exactly
  • -
-
- -
- - -
-
- - -
-

- Test 2: Vendor Edit Page (adminVendorEdit) - High Priority -

-

- Tests the UPDATED vendor edit functionality. The old vendor-edit.js has been updated to use NEW patterns (ApiClient, Logger, no Auth checks). -

- -
-

✅ Page Load Checklist:

-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-

🔧 Pattern Update Checklist:

-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-

💾 Form & Actions Checklist:

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- 🔌 API Endpoints to Check: -
GET/api/v1/admin/vendors/{code}
-
PUT/api/v1/admin/vendors/{code}
-
PUT/api/v1/admin/vendors/{code}/verification
-
PUT/api/v1/admin/vendors/{code}/status
-
- -
- ✅ Expected Result: -
    -
  • OLD patterns removed: apiClient, Auth checks
  • -
  • NEW patterns used: ApiClient, Logger, Utils
  • -
  • Tailwind styling matches dashboard
  • -
  • Simple confirms instead of complex modals
  • -
  • Two-column form layout works
  • -
-
- -
- - -
-
- - -
-

- Test 3: Users Page (adminUsers) - Medium Priority -

-

- Tests the NEW users page created from scratch. Should follow the exact same pattern as vendors list. -

- -
-

✅ Page Load Checklist:

-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-

📊 Stats Cards Checklist:

-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-

📋 Table Checklist:

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- 🔌 API Endpoints to Check: -
GET/api/v1/admin/users
-
GET/api/v1/admin/users/stats
-
PUT/api/v1/admin/users/{id}/status
-
- -
- ✅ Expected Result: -
    -
  • NEW adminUsers() function works
  • -
  • Follows same pattern as vendors list
  • -
  • Uses ApiClient, Logger, Utils
  • -
  • Matches dashboard styling
  • -
  • Role-based badge colors work
  • -
-
- -
- - -
-
- - -
-

Test 4: Cross-Page Consistency

-

- Verify that all pages maintain consistent styling and behavior. -

- -
-

🎨 Styling Consistency:

-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-

🔧 Functional Consistency:

-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - -
-
- - -
-

Test 5: Backend Routes

-

- Verify that all backend routes are properly configured. -

- -
-

📍 Route Checklist:

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- -
-
- - -
-

📝 Test Console

-
-
- [00:00:00] - ℹ️ Test page loaded. Start testing! -
-
-
- - -
-

ℹ️ Testing Tips

-
    -
  • Use Browser DevTools: Open Console (F12) to see Logger output
  • -
  • Check Network Tab: Verify API calls use correct endpoints
  • -
  • Test Dark Mode: Toggle in header to verify styling
  • -
  • Check Mobile View: Resize browser to test responsive design
  • -
  • Verify Icons: All icons should render (no broken boxes)
  • -
-
- - -
-

⚠️ Important Notes

-
    -
  • Backend must be running: FastAPI server on localhost:8000
  • -
  • Must be logged in: Use valid admin credentials
  • -
  • Check JavaScript files: Ensure vendors.js and vendor-edit.js are updated
  • -
  • Clear browser cache: If you see old code behavior
  • -
  • Patterns matter: Verify NEW patterns (ApiClient, Logger) are used
  • -
- - - + }; +} + +{% endblock %} diff --git a/app/templates/platform/base.html b/app/templates/platform/base.html index ea4cb0eb..06d2b097 100644 --- a/app/templates/platform/base.html +++ b/app/templates/platform/base.html @@ -41,51 +41,8 @@ - {# Tailwind CSS with local fallback #} - - - {# Platform-specific styles #} - + {# Tailwind CSS v4 (built locally via standalone CLI) #} + {% block extra_head %}{% endblock %} diff --git a/app/templates/shared/cdn-fallback.html b/app/templates/shared/cdn-fallback.html deleted file mode 100644 index 1d37e3f2..00000000 --- a/app/templates/shared/cdn-fallback.html +++ /dev/null @@ -1,28 +0,0 @@ -{# app/templates/shared/cdn-fallback.html #} -{# CDN with Local Fallback Pattern #} -{# This partial handles loading CDN resources with automatic fallback to local copies #} - -{# Tailwind CSS with fallback #} - - -{# Alpine.js with fallback - must be loaded at the end of body #} -{# Usage: Include this partial at the bottom of your template, before page-specific scripts #} - diff --git a/app/templates/shop/account/addresses.html b/app/templates/shop/account/addresses.html index 0ca25b1b..6c4d4326 100644 --- a/app/templates/shop/account/addresses.html +++ b/app/templates/shop/account/addresses.html @@ -1,11 +1,15 @@ - - - - - - Address management - - - <-- Address management --> - - +{# app/templates/shop/account/addresses.html #} +{% extends "shop/base.html" %} + +{% block title %}My Addresses{% endblock %} + +{% block content %} +
+

My Addresses

+ + {# TODO: Implement address management #} +
+

Address management coming soon...

+
+
+{% endblock %} diff --git a/app/templates/shop/account/forgot-password.html b/app/templates/shop/account/forgot-password.html index d5541c77..d5fa5d48 100644 --- a/app/templates/shop/account/forgot-password.html +++ b/app/templates/shop/account/forgot-password.html @@ -1,6 +1,6 @@ {# app/templates/shop/account/forgot-password.html #} - + @@ -37,9 +37,8 @@ [x-cloak] { display: none !important; } - {# Tailwind CSS with local fallback #} - + {# Tailwind CSS v4 (built locally via standalone CLI) #} +
diff --git a/app/templates/shop/account/login.html b/app/templates/shop/account/login.html index 62bd3f7d..93e0a5a0 100644 --- a/app/templates/shop/account/login.html +++ b/app/templates/shop/account/login.html @@ -1,6 +1,6 @@ {# app/templates/shop/account/login.html #} - + @@ -37,9 +37,8 @@ [x-cloak] { display: none !important; } - {# Tailwind CSS with local fallback #} - + {# Tailwind CSS v4 (built locally via standalone CLI) #} +
diff --git a/app/templates/shop/account/orders.html b/app/templates/shop/account/orders.html index 909a3bf9..abffe4db 100644 --- a/app/templates/shop/account/orders.html +++ b/app/templates/shop/account/orders.html @@ -1,11 +1,15 @@ - - - - - - Order history - - - <-- Order history --> - - +{# app/templates/shop/account/orders.html #} +{% extends "shop/base.html" %} + +{% block title %}Order History{% endblock %} + +{% block content %} +
+

Order History

+ + {# TODO: Implement order history #} +
+

Order history coming soon...

+
+
+{% endblock %} diff --git a/app/templates/shop/account/profile.html b/app/templates/shop/account/profile.html index 96b6d750..e53cfc68 100644 --- a/app/templates/shop/account/profile.html +++ b/app/templates/shop/account/profile.html @@ -1,11 +1,15 @@ - - - - - - Customer profile - - - <-- Customer profile --> - - +{# app/templates/shop/account/profile.html #} +{% extends "shop/base.html" %} + +{% block title %}My Profile{% endblock %} + +{% block content %} +
+

My Profile

+ + {# TODO: Implement profile management #} +
+

Profile management coming soon...

+
+
+{% endblock %} diff --git a/app/templates/shop/account/register.html b/app/templates/shop/account/register.html index f439ed98..5085a85f 100644 --- a/app/templates/shop/account/register.html +++ b/app/templates/shop/account/register.html @@ -1,6 +1,6 @@ {# app/templates/shop/account/register.html #} - + @@ -37,9 +37,8 @@ [x-cloak] { display: none !important; } - {# Tailwind CSS with local fallback #} - + {# Tailwind CSS v4 (built locally via standalone CLI) #} +
diff --git a/app/templates/shop/base.html b/app/templates/shop/base.html index 0ec817db..f9f38f81 100644 --- a/app/templates/shop/base.html +++ b/app/templates/shop/base.html @@ -37,9 +37,8 @@ {% endif %} - {# Tailwind CSS with local fallback #} - + {# Tailwind CSS v4 (built locally via standalone CLI) #} + {# Base Shop Styles #} diff --git a/app/templates/shop/checkout.html b/app/templates/shop/checkout.html index 3270d419..41320bff 100644 --- a/app/templates/shop/checkout.html +++ b/app/templates/shop/checkout.html @@ -1,11 +1,15 @@ - - - - - - Checkout process - - - <-- Checkout process --> - - +{# app/templates/shop/checkout.html #} +{% extends "shop/base.html" %} + +{% block title %}Checkout{% endblock %} + +{% block content %} +
+

Checkout

+ + {# TODO: Implement checkout process #} +
+

Checkout process coming soon...

+
+
+{% endblock %} diff --git a/app/templates/shop/errors/base.html b/app/templates/shop/errors/base.html index dca7178a..39f45350 100644 --- a/app/templates/shop/errors/base.html +++ b/app/templates/shop/errors/base.html @@ -1,195 +1,108 @@ +{# app/templates/shop/errors/base.html #} +{# Error page base template using Tailwind CSS with vendor theme support #} - + {% block title %}{{ status_code }} - {{ status_name }}{% endblock %}{% if vendor %} | {{ vendor.name }}{% endif %} + + {# Tailwind CSS #} + + + {# Vendor theme colors via CSS variables #} + {% if theme and theme.custom_css %} - + {% endif %} - -
+ +
+ {# Vendor Logo #} {% if vendor and theme and theme.branding and theme.branding.logo %} - + {{ vendor.name }} {% endif %} {% block content %} -
{% block icon %}⚠️{% endblock %}
-
{{ status_code }}
-
{{ status_name }}
-
{{ message }}
+ {# Error Icon #} +
{% block icon %}⚠️{% endblock %}
-
+ {# Status Code #} +
+ {{ status_code }} +
+ + {# Status Name #} +

+ {{ status_name }} +

+ + {# Error Message #} +

+ {{ message }} +

+ + {# Action Buttons #} +
{% block action_buttons %} - Continue Shopping - Contact Us + + Continue Shopping + + + Contact Us + {% endblock %}
{% block extra_content %}{% endblock %} -