refactor: migrate vendor APIs to token-based context and consolidate architecture
## 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
- <!-- standalone --> - 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 #}"
|
||||
- "<!-- standalone -->"
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
45
Makefile
45
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 ==="
|
||||
|
||||
100
app/api/deps.py
100
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={
|
||||
|
||||
16
app/api/v1/vendor/analytics.py
vendored
16
app/api/v1/vendor/analytics.py
vendored
@@ -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)
|
||||
|
||||
83
app/api/v1/vendor/auth.py
vendored
83
app/api/v1/vendor/auth.py
vendored
@@ -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,
|
||||
)
|
||||
|
||||
63
app/api/v1/vendor/content_pages.py
vendored
63
app/api/v1/vendor/content_pages.py
vendored
@@ -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)
|
||||
|
||||
25
app/api/v1/vendor/customers.py
vendored
25
app/api/v1/vendor/customers.py
vendored
@@ -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,
|
||||
|
||||
18
app/api/v1/vendor/dashboard.py
vendored
18
app/api/v1/vendor/dashboard.py
vendored
@@ -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)
|
||||
|
||||
51
app/api/v1/vendor/inventory.py
vendored
51
app/api/v1/vendor/inventory.py
vendored
@@ -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"}
|
||||
|
||||
37
app/api/v1/vendor/marketplace.py
vendored
37
app/api/v1/vendor/marketplace.py
vendored
@@ -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,
|
||||
|
||||
29
app/api/v1/vendor/media.py
vendored
29
app/api/v1/vendor/media.py
vendored
@@ -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"}
|
||||
|
||||
33
app/api/v1/vendor/notifications.py
vendored
33
app/api/v1/vendor/notifications.py
vendored
@@ -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"}
|
||||
|
||||
29
app/api/v1/vendor/payments.py
vendored
29
app/api/v1/vendor/payments.py
vendored
@@ -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"}
|
||||
|
||||
71
app/api/v1/vendor/products.py
vendored
71
app/api/v1/vendor/products.py
vendored
@@ -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)
|
||||
|
||||
21
app/api/v1/vendor/profile.py
vendored
21
app/api/v1/vendor/profile.py
vendored
@@ -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)
|
||||
|
||||
25
app/api/v1/vendor/settings.py
vendored
25
app/api/v1/vendor/settings.py
vendored
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
95
app/exceptions/code_quality.py
Normal file
95
app/exceptions/code_quality.py
Normal file
@@ -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"
|
||||
82
app/exceptions/content_page.py
Normal file
82
app/exceptions/content_page.py
Normal file
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# app/templates/admin/base.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
|
||||
<html :class="{ 'dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -10,11 +10,7 @@
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Tailwind CSS with CDN fallback -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
|
||||
<!-- Admin-specific Tailwind customizations -->
|
||||
<!-- Tailwind CSS v4 (built locally via standalone CLI) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
|
||||
|
||||
<!-- Alpine Cloak -->
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# app/templates/admin/login.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="adminLogin()" lang="en">
|
||||
<html :class="{ 'dark': dark }" x-data="adminLogin()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<DOCTYPE html>
|
||||
{# standalone - Minimal monitoring page without admin chrome #}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
|
||||
@@ -1,519 +1,253 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Auth Flow Testing - Admin Panel</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
{# app/templates/admin/test-auth-flow.html #}
|
||||
{% extends 'admin/base.html' %}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
{% block title %}Auth Flow Testing{% endblock %}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.test-section h2 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.test-description {
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.test-steps {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.test-steps ol {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.test-steps li {
|
||||
margin-bottom: 8px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.expected-result {
|
||||
background: #e8f5e9;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #4caf50;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.expected-result strong {
|
||||
color: #2e7d32;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.expected-result ul {
|
||||
margin-left: 20px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
margin-top: 30px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-panel h3 {
|
||||
color: #38bdf8;
|
||||
margin-bottom: 15px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
color: #34d399;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-value.false {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.log-level-control {
|
||||
background: #fef3c7;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 30px;
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.log-level-control h3 {
|
||||
color: #92400e;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.log-level-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.log-level-buttons button {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.warning-box h3 {
|
||||
color: #991b1b;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.warning-box ul {
|
||||
margin-left: 20px;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
.warning-box li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🧪 Auth Flow Testing</h1>
|
||||
<p class="subtitle">Comprehensive testing for the Jinja2 migration auth loop fix</p>
|
||||
|
||||
<!-- Log Level Control -->
|
||||
<div class="log-level-control">
|
||||
<h3>📊 Log Level Control</h3>
|
||||
<p style="color: #78350f; font-size: 13px; margin-bottom: 10px;">
|
||||
Change logging verbosity for login.js and api-client.js
|
||||
{% block content %}
|
||||
<div x-data="authFlowTest()" x-init="init()">
|
||||
{# Page Header #}
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Auth Flow Testing
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Comprehensive testing for Jinja2 migration auth loop fix
|
||||
</p>
|
||||
<div class="log-level-buttons">
|
||||
<button onclick="setLogLevel(0)" class="btn-secondary">0 - None</button>
|
||||
<button onclick="setLogLevel(1)" class="btn-danger">1 - Errors Only</button>
|
||||
<button onclick="setLogLevel(2)" class="btn-warning">2 - Warnings</button>
|
||||
<button onclick="setLogLevel(3)" class="btn-success">3 - Info (Production)</button>
|
||||
<button onclick="setLogLevel(4)" class="btn-primary">4 - Debug (Full)</button>
|
||||
</div>
|
||||
<p style="color: #78350f; font-size: 12px; margin-top: 10px; font-style: italic;">
|
||||
Current levels: LOGIN = <span id="currentLoginLevel">4</span>, API = <span id="currentApiLevel">3</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Test 1: Clean Slate -->
|
||||
<div class="test-section">
|
||||
<h2>Test 1: Clean Slate - Fresh Login Flow</h2>
|
||||
<p class="test-description">
|
||||
Tests the complete login flow from scratch with no existing tokens.
|
||||
</p>
|
||||
|
||||
<div class="test-steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li>Click "Clear All Data" below</li>
|
||||
<li>Click "Navigate to /admin"</li>
|
||||
<li>Observe browser behavior and console logs</li>
|
||||
<li>You should land on login page</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="expected-result">
|
||||
<strong>✅ Expected Result:</strong>
|
||||
<ul>
|
||||
<li>Single redirect: /admin → /admin/login</li>
|
||||
<li>Login page loads with NO API calls to /admin/auth/me</li>
|
||||
<li>No loops, no errors in console</li>
|
||||
<li>Form is ready for input</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="clearAllData()" class="btn-danger">Clear All Data</button>
|
||||
<button onclick="navigateToAdmin()" class="btn-primary">Navigate to /admin</button>
|
||||
<button onclick="navigateToLogin()" class="btn-secondary">Go to Login</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test 2: Login Success -->
|
||||
<div class="test-section">
|
||||
<h2>Test 2: Successful Login</h2>
|
||||
<p class="test-description">
|
||||
Tests that login works correctly and redirects to dashboard.
|
||||
</p>
|
||||
|
||||
<div class="test-steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li>Ensure you're on /admin/login</li>
|
||||
<li>Enter valid admin credentials</li>
|
||||
<li>Click "Login"</li>
|
||||
<li>Observe redirect and dashboard load</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="expected-result">
|
||||
<strong>✅ Expected Result:</strong>
|
||||
<ul>
|
||||
<li>Login API call succeeds (check Network tab)</li>
|
||||
<li>Token stored in localStorage</li>
|
||||
<li>Success message shows briefly</li>
|
||||
<li>Redirect to /admin/dashboard after 500ms</li>
|
||||
<li>Dashboard loads with stats and recent vendors</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="navigateToLogin()" class="btn-primary">Go to Login Page</button>
|
||||
<button onclick="checkAuthStatus()" class="btn-secondary">Check Auth Status</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test 3: Dashboard Refresh -->
|
||||
<div class="test-section">
|
||||
<h2>Test 3: Dashboard Refresh (Authenticated)</h2>
|
||||
<p class="test-description">
|
||||
Tests that refreshing the dashboard works without redirect loops.
|
||||
</p>
|
||||
|
||||
<div class="test-steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li>Complete Test 2 (login successfully)</li>
|
||||
<li>On dashboard, press F5 or click "Refresh Page"</li>
|
||||
<li>Observe page reload behavior</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="expected-result">
|
||||
<strong>✅ Expected Result:</strong>
|
||||
<ul>
|
||||
<li>Dashboard reloads normally</li>
|
||||
<li>No redirects to login</li>
|
||||
<li>Stats and vendors load correctly</li>
|
||||
<li>No console errors</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="navigateToDashboard()" class="btn-primary">Go to Dashboard</button>
|
||||
<button onclick="window.location.reload()" class="btn-secondary">Refresh Page</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test 4: Expired Token -->
|
||||
<div class="test-section">
|
||||
<h2>Test 4: Expired Token Handling</h2>
|
||||
<p class="test-description">
|
||||
Tests that expired tokens are handled gracefully with redirect to login.
|
||||
</p>
|
||||
|
||||
<div class="test-steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li>Click "Set Expired Token"</li>
|
||||
<li>Click "Navigate to Dashboard"</li>
|
||||
<li>Observe authentication failure and redirect</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="expected-result">
|
||||
<strong>✅ Expected Result:</strong>
|
||||
<ul>
|
||||
<li>Server detects expired token</li>
|
||||
<li>Returns 401 Unauthorized</li>
|
||||
<li>Browser redirects to /admin/login</li>
|
||||
<li>Token is cleared from localStorage</li>
|
||||
<li>No infinite loops</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="setExpiredToken()" class="btn-warning">Set Expired Token</button>
|
||||
<button onclick="navigateToDashboard()" class="btn-primary">Navigate to Dashboard</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test 5: Direct Dashboard Access (No Token) -->
|
||||
<div class="test-section">
|
||||
<h2>Test 5: Direct Dashboard Access (Unauthenticated)</h2>
|
||||
<p class="test-description">
|
||||
Tests that accessing dashboard without token redirects to login.
|
||||
</p>
|
||||
|
||||
<div class="test-steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li>Click "Clear All Data"</li>
|
||||
<li>Click "Navigate to Dashboard"</li>
|
||||
<li>Observe immediate redirect to login</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="expected-result">
|
||||
<strong>✅ Expected Result:</strong>
|
||||
<ul>
|
||||
<li>Redirect from /admin/dashboard to /admin/login</li>
|
||||
<li>No API calls attempted</li>
|
||||
<li>Login page loads correctly</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="clearAllData()" class="btn-danger">Clear All Data</button>
|
||||
<button onclick="navigateToDashboard()" class="btn-primary">Navigate to Dashboard</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test 6: Login Page with Valid Token -->
|
||||
<div class="test-section">
|
||||
<h2>Test 6: Login Page with Valid Token</h2>
|
||||
<p class="test-description">
|
||||
Tests what happens when user visits login page while already authenticated.
|
||||
</p>
|
||||
|
||||
<div class="test-steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li>Login successfully (Test 2)</li>
|
||||
<li>Click "Go to Login Page" below</li>
|
||||
<li>Observe behavior</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="expected-result">
|
||||
<strong>✅ Expected Result:</strong>
|
||||
<ul>
|
||||
<li>Login page loads</li>
|
||||
<li>Existing token is cleared (init() clears it)</li>
|
||||
<li>Form is displayed normally</li>
|
||||
<li>NO redirect loops</li>
|
||||
<li>NO API calls to validate token</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="setValidToken()" class="btn-success">Set Valid Token (Mock)</button>
|
||||
<button onclick="navigateToLogin()" class="btn-primary">Go to Login Page</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Panel -->
|
||||
<div class="status-panel">
|
||||
<h3>🔍 Current Auth Status</h3>
|
||||
<div id="statusDisplay">
|
||||
<div class="status-item">
|
||||
<span class="status-label">Current URL:</span>
|
||||
<span class="status-value" id="currentUrl">-</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Has admin_token:</span>
|
||||
<span class="status-value" id="hasToken">-</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Has admin_user:</span>
|
||||
<span class="status-value" id="hasUser">-</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Token Preview:</span>
|
||||
<span class="status-value" id="tokenPreview">-</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Username:</span>
|
||||
<span class="status-value" id="username">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="updateStatus()" style="margin-top: 15px; background: #38bdf8; color: #0f172a; padding: 8px 16px; border-radius: 4px; font-size: 12px; cursor: pointer; border: none;">
|
||||
🔄 Refresh Status
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Warning Box -->
|
||||
<div class="warning-box">
|
||||
<h3>⚠️ Important Notes</h3>
|
||||
<ul>
|
||||
<li>Always check browser console for detailed logs</li>
|
||||
<li>Use Network tab to see actual HTTP requests and redirects</li>
|
||||
<li>Clear browser cache if you see unexpected behavior</li>
|
||||
<li>Make sure FastAPI server is running on localhost:8000</li>
|
||||
<li>Valid admin credentials required for login tests</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Update status display
|
||||
function updateStatus() {
|
||||
{# Log Level Control #}
|
||||
<div class="px-4 py-3 mb-6 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg shadow-md border-l-4 border-yellow-500">
|
||||
<h4 class="mb-2 text-lg font-semibold text-yellow-800 dark:text-yellow-200">Log Level Control</h4>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300 mb-3">
|
||||
Change logging verbosity for login.js and api-client.js
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="setLogLevel(0)" class="px-3 py-1 text-xs font-medium text-white bg-gray-600 rounded hover:bg-gray-700">0 - None</button>
|
||||
<button @click="setLogLevel(1)" class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">1 - Errors</button>
|
||||
<button @click="setLogLevel(2)" class="px-3 py-1 text-xs font-medium text-white bg-yellow-600 rounded hover:bg-yellow-700">2 - Warnings</button>
|
||||
<button @click="setLogLevel(3)" class="px-3 py-1 text-xs font-medium text-white bg-green-600 rounded hover:bg-green-700">3 - Info</button>
|
||||
<button @click="setLogLevel(4)" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">4 - Debug</button>
|
||||
</div>
|
||||
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-2 italic">
|
||||
Current: LOGIN = <span x-text="currentLoginLevel">4</span>, API = <span x-text="currentApiLevel">3</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Test Sections Grid #}
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2">
|
||||
{# Test 1: Clean Slate #}
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-blue-500">
|
||||
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||
Test 1: Clean Slate - Fresh Login
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Tests complete login flow from scratch with no existing tokens.
|
||||
</p>
|
||||
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
|
||||
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>Clear All Data</li>
|
||||
<li>Navigate to /admin</li>
|
||||
<li>Should land on login page</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
|
||||
<p class="text-xs text-green-700 dark:text-green-400">Expected: Single redirect /admin -> /admin/login, no loops</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="clearAllData()" class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">Clear All Data</button>
|
||||
<button @click="navigateTo('/admin')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to /admin</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Test 2: Successful Login #}
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-green-500">
|
||||
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||
Test 2: Successful Login
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Tests that login works correctly and redirects to dashboard.
|
||||
</p>
|
||||
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
|
||||
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>Go to /admin/login</li>
|
||||
<li>Enter valid admin credentials</li>
|
||||
<li>Click Login</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
|
||||
<p class="text-xs text-green-700 dark:text-green-400">Expected: Token stored, redirect to /admin/dashboard</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="navigateTo('/admin/login')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Login</button>
|
||||
<button @click="checkAuthStatus()" class="px-3 py-1 text-xs font-medium text-white bg-gray-600 rounded hover:bg-gray-700">Check Status</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Test 3: Dashboard Refresh #}
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-purple-500">
|
||||
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||
Test 3: Dashboard Refresh
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Tests that refreshing dashboard works without redirect loops.
|
||||
</p>
|
||||
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
|
||||
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>Complete Test 2 (login)</li>
|
||||
<li>Press F5 or click Refresh</li>
|
||||
<li>Dashboard should reload normally</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
|
||||
<p class="text-xs text-green-700 dark:text-green-400">Expected: No redirect to login, stats load correctly</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="navigateTo('/admin/dashboard')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Dashboard</button>
|
||||
<button @click="window.location.reload()" class="px-3 py-1 text-xs font-medium text-white bg-gray-600 rounded hover:bg-gray-700">Refresh Page</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Test 4: Expired Token #}
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-orange-500">
|
||||
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||
Test 4: Expired Token Handling
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Tests that expired tokens are handled gracefully.
|
||||
</p>
|
||||
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
|
||||
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>Set Expired Token</li>
|
||||
<li>Navigate to Dashboard</li>
|
||||
<li>Should redirect to login</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
|
||||
<p class="text-xs text-green-700 dark:text-green-400">Expected: 401 response, redirect to login, no loops</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="setExpiredToken()" class="px-3 py-1 text-xs font-medium text-white bg-orange-600 rounded hover:bg-orange-700">Set Expired Token</button>
|
||||
<button @click="navigateTo('/admin/dashboard')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Dashboard</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Test 5: Direct Access (No Token) #}
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-red-500">
|
||||
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||
Test 5: Direct Access (Unauthenticated)
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Tests accessing dashboard without token redirects to login.
|
||||
</p>
|
||||
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
|
||||
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>Clear All Data</li>
|
||||
<li>Navigate to Dashboard</li>
|
||||
<li>Should redirect to login</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
|
||||
<p class="text-xs text-green-700 dark:text-green-400">Expected: Redirect to /admin/login, no API calls</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="clearAllData()" class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">Clear All Data</button>
|
||||
<button @click="navigateTo('/admin/dashboard')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Dashboard</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Test 6: Login with Valid Token #}
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-teal-500">
|
||||
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||
Test 6: Login Page with Valid Token
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Tests visiting login page while already authenticated.
|
||||
</p>
|
||||
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
|
||||
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>Login successfully (Test 2)</li>
|
||||
<li>Click Go to Login Page</li>
|
||||
<li>Token should be cleared</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
|
||||
<p class="text-xs text-green-700 dark:text-green-400">Expected: Token cleared, form displayed, no loops</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="setMockToken()" class="px-3 py-1 text-xs font-medium text-white bg-green-600 rounded hover:bg-green-700">Set Mock Token</button>
|
||||
<button @click="navigateTo('/admin/login')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Login</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Status Panel #}
|
||||
<div class="px-4 py-3 bg-gray-800 rounded-lg shadow-md">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-lg font-semibold text-gray-200">Current Auth Status</h4>
|
||||
<button @click="updateStatus()" class="px-3 py-1 text-xs text-gray-400 border border-gray-600 rounded hover:bg-gray-700">Refresh</button>
|
||||
</div>
|
||||
<div class="font-mono text-sm space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Current URL:</span>
|
||||
<span class="text-blue-400" x-text="currentUrl">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Has admin_token:</span>
|
||||
<span :class="hasToken ? 'text-green-400' : 'text-red-400'" x-text="hasToken ? 'Yes' : 'No'">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Has admin_user:</span>
|
||||
<span :class="hasUser ? 'text-green-400' : 'text-red-400'" x-text="hasUser ? 'Yes' : 'No'">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Token Preview:</span>
|
||||
<span class="text-green-400 truncate max-w-xs" x-text="tokenPreview">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Username:</span>
|
||||
<span class="text-green-400" x-text="username">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Warning Box #}
|
||||
<div class="mt-6 px-4 py-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<h4 class="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">Important Notes</h4>
|
||||
<ul class="list-disc list-inside text-sm text-red-600 dark:text-red-400 space-y-1">
|
||||
<li>Always check browser console for detailed logs</li>
|
||||
<li>Use Network tab to see actual HTTP requests and redirects</li>
|
||||
<li>Clear browser cache if you see unexpected behavior</li>
|
||||
<li>Make sure FastAPI server is running on localhost:8000</li>
|
||||
<li>Valid admin credentials required for login tests</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function authFlowTest() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'auth-testing',
|
||||
|
||||
currentUrl: '-',
|
||||
hasToken: false,
|
||||
hasUser: false,
|
||||
tokenPreview: '-',
|
||||
username: '-',
|
||||
currentLoginLevel: 4,
|
||||
currentApiLevel: 3,
|
||||
|
||||
init() {
|
||||
this.updateStatus();
|
||||
setInterval(() => this.updateStatus(), 2000);
|
||||
console.log('Auth Flow Testing Script Loaded');
|
||||
},
|
||||
|
||||
updateStatus() {
|
||||
const token = localStorage.getItem('admin_token');
|
||||
const userStr = localStorage.getItem('admin_user');
|
||||
let user = null;
|
||||
@@ -524,121 +258,67 @@
|
||||
console.error('Failed to parse user data:', e);
|
||||
}
|
||||
|
||||
document.getElementById('currentUrl').textContent = window.location.href;
|
||||
this.currentUrl = window.location.href;
|
||||
this.hasToken = !!token;
|
||||
this.hasUser = !!user;
|
||||
this.tokenPreview = token ? token.substring(0, 30) + '...' : 'No token';
|
||||
this.username = user?.username || 'Not logged in';
|
||||
},
|
||||
|
||||
const hasTokenEl = document.getElementById('hasToken');
|
||||
hasTokenEl.textContent = token ? 'Yes' : 'No';
|
||||
hasTokenEl.className = token ? 'status-value' : 'status-value false';
|
||||
|
||||
const hasUserEl = document.getElementById('hasUser');
|
||||
hasUserEl.textContent = user ? 'Yes' : 'No';
|
||||
hasUserEl.className = user ? 'status-value' : 'status-value false';
|
||||
|
||||
document.getElementById('tokenPreview').textContent = token
|
||||
? token.substring(0, 30) + '...'
|
||||
: 'No token';
|
||||
|
||||
document.getElementById('username').textContent = user?.username || 'Not logged in';
|
||||
|
||||
console.log('📊 Status Updated:', {
|
||||
hasToken: !!token,
|
||||
hasUser: !!user,
|
||||
user: user
|
||||
});
|
||||
}
|
||||
|
||||
// Test functions
|
||||
function clearAllData() {
|
||||
console.log('🗑️ Clearing all localStorage data...');
|
||||
clearAllData() {
|
||||
console.log('Clearing all localStorage data...');
|
||||
localStorage.clear();
|
||||
console.log('✅ All data cleared');
|
||||
alert('✅ All localStorage data cleared!\n\nCheck console for details.');
|
||||
updateStatus();
|
||||
}
|
||||
console.log('All data cleared');
|
||||
alert('All localStorage data cleared!');
|
||||
this.updateStatus();
|
||||
},
|
||||
|
||||
function navigateToAdmin() {
|
||||
console.log('🚀 Navigating to /admin...');
|
||||
window.location.href = '/admin';
|
||||
}
|
||||
navigateTo(path) {
|
||||
console.log(`Navigating to ${path}...`);
|
||||
window.location.href = path;
|
||||
},
|
||||
|
||||
function navigateToLogin() {
|
||||
console.log('🚀 Navigating to /admin/login...');
|
||||
window.location.href = '/admin/login';
|
||||
}
|
||||
|
||||
function navigateToDashboard() {
|
||||
console.log('🚀 Navigating to /admin/dashboard...');
|
||||
window.location.href = '/admin/dashboard';
|
||||
}
|
||||
|
||||
function checkAuthStatus() {
|
||||
updateStatus();
|
||||
checkAuthStatus() {
|
||||
this.updateStatus();
|
||||
alert('Check console and status panel for auth details.');
|
||||
}
|
||||
},
|
||||
|
||||
function setExpiredToken() {
|
||||
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwMjJ9.invalidexpiredtoken';
|
||||
console.log('⚠️ Setting expired/invalid token...');
|
||||
setExpiredToken() {
|
||||
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNTE2MjM5MDIyfQ.invalid';
|
||||
localStorage.setItem('admin_token', expiredToken);
|
||||
localStorage.setItem('admin_user', JSON.stringify({
|
||||
id: 1,
|
||||
username: 'test_expired',
|
||||
role: 'admin'
|
||||
}));
|
||||
console.log('✅ Expired token set');
|
||||
alert('⚠️ Expired token set!\n\nNow try navigating to dashboard.');
|
||||
updateStatus();
|
||||
}
|
||||
alert('Expired token set! Now try navigating to dashboard.');
|
||||
this.updateStatus();
|
||||
},
|
||||
|
||||
function setValidToken() {
|
||||
// This is a mock token - won't actually work with backend
|
||||
setMockToken() {
|
||||
const mockToken = 'mock_valid_token_' + Date.now();
|
||||
console.log('✅ Setting mock valid token...');
|
||||
localStorage.setItem('admin_token', mockToken);
|
||||
localStorage.setItem('admin_user', JSON.stringify({
|
||||
id: 1,
|
||||
username: 'test_user',
|
||||
role: 'admin'
|
||||
}));
|
||||
console.log('✅ Mock token set (will not work with real backend)');
|
||||
alert('✅ Mock token set!\n\nNote: This is a fake token and won\'t work with the real backend.');
|
||||
updateStatus();
|
||||
}
|
||||
alert('Mock token set! Note: This won\'t work with real backend.');
|
||||
this.updateStatus();
|
||||
},
|
||||
|
||||
// Log level control
|
||||
function setLogLevel(level) {
|
||||
console.log(`📊 Setting log level to ${level}...`);
|
||||
|
||||
// Note: This only works if login.js and api-client.js are loaded
|
||||
// In production, you'd need to reload the page or use a more sophisticated approach
|
||||
if (typeof LOG_LEVEL !== 'undefined') {
|
||||
setLogLevel(level) {
|
||||
if (typeof window.LOG_LEVEL !== 'undefined') {
|
||||
window.LOG_LEVEL = level;
|
||||
document.getElementById('currentLoginLevel').textContent = level;
|
||||
console.log('✅ LOGIN log level set to', level);
|
||||
} else {
|
||||
console.warn('⚠️ LOG_LEVEL not found (login.js not loaded)');
|
||||
this.currentLoginLevel = level;
|
||||
}
|
||||
|
||||
if (typeof API_LOG_LEVEL !== 'undefined') {
|
||||
if (typeof window.API_LOG_LEVEL !== 'undefined') {
|
||||
window.API_LOG_LEVEL = level;
|
||||
document.getElementById('currentApiLevel').textContent = level;
|
||||
console.log('✅ API log level set to', level);
|
||||
} else {
|
||||
console.warn('⚠️ API_LOG_LEVEL not found (api-client.js not loaded)');
|
||||
this.currentApiLevel = level;
|
||||
}
|
||||
|
||||
alert(`Log level set to ${level}\n\n0 = None\n1 = Errors\n2 = Warnings\n3 = Info\n4 = Debug\n\nNote: Changes apply to current page. Reload to apply to all scripts.`);
|
||||
alert(`Log level set to ${level}. Reload to apply to all scripts.`);
|
||||
}
|
||||
|
||||
// Initialize status on load
|
||||
updateStatus();
|
||||
|
||||
// Auto-refresh status every 2 seconds
|
||||
setInterval(updateStatus, 2000);
|
||||
|
||||
console.log('🧪 Auth Flow Testing Script Loaded');
|
||||
console.log('📊 Use the buttons above to run tests');
|
||||
console.log('🔍 Watch browser console and Network tab for details');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,51 +41,8 @@
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
{# Tailwind CSS with local fallback #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
|
||||
{# Platform-specific styles #}
|
||||
<style>
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Custom gradients */
|
||||
.gradient-primary {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
|
||||
}
|
||||
|
||||
.gradient-accent {
|
||||
background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-accent) 100%);
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Card hover effect */
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
</style>
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='platform/css/tailwind.output.css') }}">
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
@@ -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 #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
|
||||
{# 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 #}
|
||||
<script>
|
||||
// Alpine.js CDN with fallback
|
||||
(function() {
|
||||
var script = document.createElement('script');
|
||||
script.defer = true;
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js';
|
||||
|
||||
script.onerror = function() {
|
||||
console.warn('Alpine.js CDN failed, loading local copy...');
|
||||
var fallbackScript = document.createElement('script');
|
||||
fallbackScript.defer = true;
|
||||
fallbackScript.src = '{{ url_for("static", path="shared/js/vendor/alpine.min.js") }}';
|
||||
document.head.appendChild(fallbackScript);
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
@@ -1,11 +1,15 @@
|
||||
<DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Address management</title>
|
||||
</head>
|
||||
<body>
|
||||
<-- Address management -->
|
||||
</body>
|
||||
</html>
|
||||
{# app/templates/shop/account/addresses.html #}
|
||||
{% extends "shop/base.html" %}
|
||||
|
||||
{% block title %}My Addresses{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">My Addresses</h1>
|
||||
|
||||
{# TODO: Implement address management #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p class="text-gray-600 dark:text-gray-400">Address management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# app/templates/shop/account/forgot-password.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="forgotPassword()" lang="en">
|
||||
<html :class="{ 'dark': dark }" x-data="forgotPassword()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -37,9 +37,8 @@
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS with local fallback #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# app/templates/shop/account/login.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="customerLogin()" lang="en">
|
||||
<html :class="{ 'dark': dark }" x-data="customerLogin()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -37,9 +37,8 @@
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS with local fallback #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Order history</title>
|
||||
</head>
|
||||
<body>
|
||||
<-- Order history -->
|
||||
</body>
|
||||
</html>
|
||||
{# app/templates/shop/account/orders.html #}
|
||||
{% extends "shop/base.html" %}
|
||||
|
||||
{% block title %}Order History{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Order History</h1>
|
||||
|
||||
{# TODO: Implement order history #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p class="text-gray-600 dark:text-gray-400">Order history coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Customer profile</title>
|
||||
</head>
|
||||
<body>
|
||||
<-- Customer profile -->
|
||||
</body>
|
||||
</html>
|
||||
{# app/templates/shop/account/profile.html #}
|
||||
{% extends "shop/base.html" %}
|
||||
|
||||
{% block title %}My Profile{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">My Profile</h1>
|
||||
|
||||
{# TODO: Implement profile management #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p class="text-gray-600 dark:text-gray-400">Profile management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# app/templates/shop/account/register.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="customerRegistration()" lang="en">
|
||||
<html :class="{ 'dark': dark }" x-data="customerRegistration()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -37,9 +37,8 @@
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS with local fallback #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
|
||||
@@ -37,9 +37,8 @@
|
||||
{% endif %}
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS with local fallback #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
|
||||
{# Base Shop Styles #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/shop.css') }}">
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Checkout process</title>
|
||||
</head>
|
||||
<body>
|
||||
<-- Checkout process -->
|
||||
</body>
|
||||
</html>
|
||||
{# app/templates/shop/checkout.html #}
|
||||
{% extends "shop/base.html" %}
|
||||
|
||||
{% block title %}Checkout{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Checkout</h1>
|
||||
|
||||
{# TODO: Implement checkout process #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p class="text-gray-600 dark:text-gray-400">Checkout process coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,191 +1,104 @@
|
||||
{# app/templates/shop/errors/base.html #}
|
||||
{# Error page base template using Tailwind CSS with vendor theme support #}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ status_code }} - {{ status_name }}{% endblock %}{% if vendor %} | {{ vendor.name }}{% endif %}</title>
|
||||
|
||||
{# Tailwind CSS #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
|
||||
{# Vendor theme colors via CSS variables #}
|
||||
<style>
|
||||
:root {
|
||||
/* Default theme colors (fallback) */
|
||||
--color-primary: {{ theme.colors.primary if theme and theme.colors else '#6366f1' }};
|
||||
--color-secondary: {{ theme.colors.secondary if theme and theme.colors else '#8b5cf6' }};
|
||||
--color-accent: {{ theme.colors.accent if theme and theme.colors else '#ec4899' }};
|
||||
--color-background: {{ theme.colors.background if theme and theme.colors else '#ffffff' }};
|
||||
--color-text: {{ theme.colors.text if theme and theme.colors else '#1f2937' }};
|
||||
--color-border: {{ theme.colors.border if theme and theme.colors else '#e5e7eb' }};
|
||||
--font-heading: {{ theme.fonts.heading if theme and theme.fonts else "'Inter', sans-serif" }};
|
||||
--font-body: {{ theme.fonts.body if theme and theme.fonts else "'Inter', sans-serif" }};
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
.bg-gradient-theme {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
background: var(--color-background);
|
||||
border-radius: 1.5rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
{% if vendor and vendor.logo %}
|
||||
.vendor-logo {
|
||||
max-width: 150px;
|
||||
max-height: 60px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
.error-icon {
|
||||
font-size: 5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.status-code {
|
||||
font-size: 6rem;
|
||||
font-weight: 700;
|
||||
.text-theme-primary {
|
||||
color: var(--color-primary);
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.status-name {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 1rem;
|
||||
font-family: var(--font-heading);
|
||||
.bg-theme-primary {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1.125rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 2.5rem;
|
||||
line-height: 1.6;
|
||||
.border-theme-primary {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
border: 2px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.support-link {
|
||||
margin-top: 2.5rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.support-link a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.support-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.vendor-info {
|
||||
margin-top: 2rem;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
.hover\:bg-theme-primary:hover {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
{% block extra_styles %}{% endblock %}
|
||||
</style>
|
||||
|
||||
{% if theme and theme.custom_css %}
|
||||
<style>
|
||||
{{ theme.custom_css | safe }}
|
||||
</style>
|
||||
<style>{{ theme.custom_css | safe }}</style>
|
||||
{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<body class="h-full bg-gradient-theme flex items-center justify-center p-8">
|
||||
<div class="bg-white rounded-3xl shadow-2xl max-w-xl w-full p-12 text-center">
|
||||
{# Vendor Logo #}
|
||||
{% if vendor and theme and theme.branding and theme.branding.logo %}
|
||||
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo">
|
||||
<img src="{{ theme.branding.logo }}"
|
||||
alt="{{ vendor.name }}"
|
||||
class="max-w-[150px] max-h-[60px] mx-auto mb-8 object-contain">
|
||||
{% endif %}
|
||||
|
||||
{% block content %}
|
||||
<div class="error-icon">{% block icon %}⚠️{% endblock %}</div>
|
||||
<div class="status-code">{{ status_code }}</div>
|
||||
<div class="status-name">{{ status_name }}</div>
|
||||
<div class="error-message">{{ message }}</div>
|
||||
{# Error Icon #}
|
||||
<div class="text-7xl mb-4">{% block icon %}⚠️{% endblock %}</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
{# Status Code #}
|
||||
<div class="text-8xl font-bold text-theme-primary leading-none mb-2">
|
||||
{{ status_code }}
|
||||
</div>
|
||||
|
||||
{# Status Name #}
|
||||
<h1 class="text-3xl font-semibold text-gray-900 mb-4">
|
||||
{{ status_name }}
|
||||
</h1>
|
||||
|
||||
{# Error Message #}
|
||||
<p class="text-lg text-gray-500 mb-10 leading-relaxed">
|
||||
{{ message }}
|
||||
</p>
|
||||
|
||||
{# Action Buttons #}
|
||||
<div class="flex gap-4 justify-center flex-wrap mt-8">
|
||||
{% block action_buttons %}
|
||||
<a href="{{ base_url }}shop/" class="btn btn-primary">Continue Shopping</a>
|
||||
<a href="{{ base_url }}shop/contact" class="btn btn-secondary">Contact Us</a>
|
||||
<a href="{{ base_url }}shop/"
|
||||
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
|
||||
Continue Shopping
|
||||
</a>
|
||||
<a href="{{ base_url }}shop/contact"
|
||||
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
|
||||
Contact Us
|
||||
</a>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
{% block extra_content %}{% endblock %}
|
||||
|
||||
<div class="support-link">
|
||||
{# Support Link #}
|
||||
<div class="mt-10 pt-8 border-t border-gray-200 text-sm text-gray-500">
|
||||
{% block support_link %}
|
||||
Need help? <a href="{{ base_url }}shop/contact">Contact our support team</a>
|
||||
Need help? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact our support team</a>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
{# Vendor Info #}
|
||||
{% if vendor %}
|
||||
<div class="vendor-info">
|
||||
<div class="mt-8 text-sm text-gray-400">
|
||||
{{ vendor.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Search results page</title>
|
||||
</head>
|
||||
<body>
|
||||
<-- Search results page -->
|
||||
</body>
|
||||
</html>
|
||||
{# app/templates/shop/search.html #}
|
||||
{% extends "shop/base.html" %}
|
||||
|
||||
{% block title %}Search Results{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Search Results</h1>
|
||||
|
||||
{# TODO: Implement search results #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p class="text-gray-600 dark:text-gray-400">Search results coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
8
app/templates/vendor/base.html
vendored
8
app/templates/vendor/base.html
vendored
@@ -1,6 +1,6 @@
|
||||
{# app/templates/vendor/base.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
|
||||
<html :class="{ 'dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -10,11 +10,7 @@
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Tailwind CSS with CDN fallback -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
|
||||
<!-- Vendor-specific Tailwind customizations -->
|
||||
<!-- Tailwind CSS v4 (built locally via standalone CLI) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='vendor/css/tailwind.output.css') }}" />
|
||||
|
||||
<!-- Alpine Cloak -->
|
||||
|
||||
2
app/templates/vendor/login.html
vendored
2
app/templates/vendor/login.html
vendored
@@ -1,6 +1,6 @@
|
||||
{# app/templates/vendor/login.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="vendorLogin()" lang="en">
|
||||
<html :class="{ 'dark': dark }" x-data="vendorLogin()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
@@ -355,18 +355,40 @@ def update_product(
|
||||
return ProductResponse.model_validate(product)
|
||||
```
|
||||
|
||||
## Files to Migrate
|
||||
## Migration Status
|
||||
|
||||
Current files still using `require_vendor_context()`:
|
||||
- `app/api/v1/vendor/customers.py`
|
||||
- `app/api/v1/vendor/notifications.py`
|
||||
- `app/api/v1/vendor/media.py`
|
||||
- `app/api/v1/vendor/marketplace.py`
|
||||
- `app/api/v1/vendor/inventory.py`
|
||||
- `app/api/v1/vendor/settings.py`
|
||||
- `app/api/v1/vendor/analytics.py`
|
||||
- `app/api/v1/vendor/payments.py`
|
||||
- `app/api/v1/vendor/profile.py`
|
||||
**COMPLETED** - All vendor API endpoints have been migrated to use the token-based vendor context pattern.
|
||||
|
||||
### Migrated Files
|
||||
All vendor API files now use `current_user.token_vendor_id`:
|
||||
- `app/api/v1/vendor/customers.py` ✅
|
||||
- `app/api/v1/vendor/notifications.py` ✅
|
||||
- `app/api/v1/vendor/media.py` ✅
|
||||
- `app/api/v1/vendor/marketplace.py` ✅
|
||||
- `app/api/v1/vendor/inventory.py` ✅
|
||||
- `app/api/v1/vendor/settings.py` ✅
|
||||
- `app/api/v1/vendor/analytics.py` ✅
|
||||
- `app/api/v1/vendor/payments.py` ✅
|
||||
- `app/api/v1/vendor/profile.py` ✅
|
||||
- `app/api/v1/vendor/dashboard.py` ✅
|
||||
- `app/api/v1/vendor/products.py` ✅
|
||||
- `app/api/v1/vendor/orders.py` ✅
|
||||
- `app/api/v1/vendor/team.py` ✅ (uses permission dependencies)
|
||||
|
||||
### Permission Dependencies Updated
|
||||
The following permission dependencies now use token-based vendor context:
|
||||
- `require_vendor_permission()` - Gets vendor from token, sets `request.state.vendor`
|
||||
- `require_vendor_owner` - Gets vendor from token, sets `request.state.vendor`
|
||||
- `require_any_vendor_permission()` - Gets vendor from token, sets `request.state.vendor`
|
||||
- `require_all_vendor_permissions()` - Gets vendor from token, sets `request.state.vendor`
|
||||
- `get_user_permissions` - Gets vendor from token, sets `request.state.vendor`
|
||||
|
||||
### Shop Endpoints
|
||||
Shop endpoints (public, no authentication) still use `require_vendor_context()`:
|
||||
- `app/api/v1/shop/products.py` - Uses URL/subdomain/domain detection
|
||||
- `app/api/v1/shop/cart.py` - Uses URL/subdomain/domain detection
|
||||
|
||||
This is correct behavior - shop endpoints need to detect vendor from the request URL, not from JWT token.
|
||||
|
||||
## Benefits of Vendor-in-Token
|
||||
|
||||
@@ -483,9 +505,9 @@ See `docs/architecture/rules/API-VND-001.md` for the formal architecture rule en
|
||||
## Related Documentation
|
||||
|
||||
- [Vendor RBAC System](./vendor-rbac.md) - Role-based access control for vendors
|
||||
- [Vendor Authentication](./vendor-authentication.md) - Complete authentication guide
|
||||
- [Architecture Rules](../architecture/rules/) - All architecture rules
|
||||
- [API Design Guidelines](../architecture/api-design.md) - RESTful API patterns
|
||||
- [Authentication & RBAC](../architecture/auth-rbac.md) - Complete authentication guide
|
||||
- [Architecture Patterns](../architecture/architecture-patterns.md) - All architecture patterns
|
||||
- [Middleware Reference](./middleware-reference.md) - Middleware patterns
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
@@ -136,32 +136,36 @@ async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||
- `db.commit()`
|
||||
- `db.query(`
|
||||
|
||||
#### API-003: Catch Service Exceptions
|
||||
#### API-003: No HTTPException in Endpoints
|
||||
**Severity:** Error
|
||||
|
||||
API endpoints must catch domain exceptions from services and convert them to appropriate HTTPException with proper status codes.
|
||||
API endpoints must NOT raise HTTPException directly. Instead, let domain exceptions bubble up to the global exception handler which converts them to appropriate HTTP responses.
|
||||
|
||||
```python
|
||||
# ✅ Good
|
||||
# ✅ Good - Let domain exceptions bubble up
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = vendor_service.create_vendor(db, vendor)
|
||||
return result
|
||||
except VendorAlreadyExistsError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
# Service raises VendorAlreadyExistsException if duplicate
|
||||
# Global handler converts to 409 Conflict
|
||||
return vendor_service.create_vendor(db, vendor)
|
||||
|
||||
# ❌ Bad - Don't raise HTTPException directly
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||
if vendor_service.exists(db, vendor.subdomain):
|
||||
raise HTTPException(status_code=409, detail="Vendor exists") # BAD!
|
||||
return vendor_service.create_vendor(db, vendor)
|
||||
```
|
||||
|
||||
**Pattern:** Services raise domain exceptions → Global handler converts to HTTP responses
|
||||
|
||||
#### API-004: Proper Authentication
|
||||
**Severity:** Warning
|
||||
|
||||
Protected endpoints must use Depends() for authentication.
|
||||
|
||||
```python
|
||||
# ✅ Good
|
||||
# ✅ Good - Protected endpoint with authentication
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(
|
||||
vendor: VendorCreate,
|
||||
@@ -171,6 +175,31 @@ async def create_vendor(
|
||||
pass
|
||||
```
|
||||
|
||||
**Auto-Excluded Files:**
|
||||
|
||||
The validator automatically skips API-004 checks for authentication endpoint files (`*/auth.py`) since login, logout, and registration endpoints are intentionally public.
|
||||
|
||||
**Marking Public Endpoints:**
|
||||
|
||||
For other intentionally public endpoints (webhooks, health checks, etc.), use a comment marker:
|
||||
|
||||
```python
|
||||
# ✅ Good - Webhook endpoint marked as public
|
||||
# public - Stripe webhook receives external callbacks
|
||||
@router.post("/webhook/stripe")
|
||||
def stripe_webhook(request: Request):
|
||||
...
|
||||
|
||||
# ✅ Good - Using noqa style
|
||||
@router.post("/health") # noqa: API-004
|
||||
def health_check():
|
||||
...
|
||||
```
|
||||
|
||||
**Recognized markers:**
|
||||
- `# public` - descriptive marker for intentionally unauthenticated endpoints
|
||||
- `# noqa: API-004` - standard noqa style to suppress the warning
|
||||
|
||||
#### API-005: Multi-Tenant Scoping
|
||||
**Severity:** Error
|
||||
|
||||
@@ -542,12 +571,41 @@ async loadData() {
|
||||
#### TPL-001: Admin Templates Extend admin/base.html
|
||||
**Severity:** Error
|
||||
|
||||
All admin templates must extend the base template for consistent layout (sidebar, navigation, etc.).
|
||||
|
||||
```jinja
|
||||
✅ {% extends "admin/base.html" %}
|
||||
❌ No extends directive
|
||||
{# ✅ Good - Extends base template #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% block content %}
|
||||
...
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
Exceptions: `base.html` itself, files in `partials/`
|
||||
**Auto-Excluded Files:**
|
||||
|
||||
The validator automatically skips TPL-001 checks for:
|
||||
|
||||
- `login.html` - Standalone login page (no sidebar/navigation needed)
|
||||
- `errors/*.html` - Error pages extend `errors/base.html` instead
|
||||
- `test-*.html` - Test/development templates
|
||||
- `base.html` - The base template itself
|
||||
- `partials/*.html` - Partial templates included in other templates
|
||||
|
||||
**Marking Standalone Templates:**
|
||||
|
||||
For other templates that intentionally don't extend base.html, use a comment marker in the first 5 lines:
|
||||
|
||||
```jinja
|
||||
{# standalone - Minimal monitoring page without admin chrome #}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
...
|
||||
```
|
||||
|
||||
**Recognized markers:**
|
||||
- `{# standalone #}` - Jinja comment style
|
||||
- `{# noqa: TPL-001 #}` - Standard noqa style
|
||||
- `<!-- standalone -->` - HTML comment style
|
||||
|
||||
#### TPL-002: Vendor Templates Extend vendor/base.html
|
||||
**Severity:** Error
|
||||
@@ -789,12 +847,13 @@ Before committing code:
|
||||
| Violation | Quick Fix |
|
||||
|-----------|-----------|
|
||||
| HTTPException in service | Create custom exception in `app/exceptions/` |
|
||||
| HTTPException in endpoint | Let domain exceptions bubble up to global handler |
|
||||
| Business logic in endpoint | Move to service layer |
|
||||
| No exception handling | Add try/except, convert to HTTPException |
|
||||
| console.log in JS | Use `window.LogConfig.createLogger()` |
|
||||
| Missing ...data() | Add spread operator in component return |
|
||||
| Bare except clause | Specify exception type |
|
||||
| Raw dict return | Create Pydantic response model |
|
||||
| Template not extending base | Add `{% extends %}` or `{# standalone #}` marker |
|
||||
|
||||
---
|
||||
|
||||
@@ -834,5 +893,5 @@ All rules are defined in `.architecture-rules.yaml`. To modify rules:
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-11-28
|
||||
**Version:** 2.0
|
||||
**Last Updated:** 2025-12-04
|
||||
**Version:** 2.2
|
||||
|
||||
@@ -324,5 +324,5 @@ Migration failures will halt deployment to prevent data corruption.
|
||||
## Further Reading
|
||||
|
||||
- [Alembic Official Documentation](https://alembic.sqlalchemy.org/)
|
||||
- [Database Setup Guide](../getting-started/database-setup-guide.md)
|
||||
- [Deployment Guide](../deployment/production.md)
|
||||
- [Database Seeder Documentation](../database-seeder/database-seeder-documentation.md)
|
||||
- [Database Init Guide](../database-seeder/database-init-guide.md)
|
||||
|
||||
402
docs/development/migration/tailwind-migration-plan.md
Normal file
402
docs/development/migration/tailwind-migration-plan.md
Normal file
@@ -0,0 +1,402 @@
|
||||
# Tailwind CSS Migration Plan: v1.4/v2.2 → v4.0 (Standalone CLI)
|
||||
|
||||
**Created:** December 2024
|
||||
**Completed:** December 2024
|
||||
**Status:** COMPLETE
|
||||
**Goal:** Eliminate Node.js dependency, use Tailwind Standalone CLI
|
||||
|
||||
> **Migration Complete!** All frontends now use Tailwind CSS v4 with the standalone CLI.
|
||||
> See [Tailwind CSS Build Guide](../../frontend/tailwind-css.md) for current documentation.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### Two Tailwind Setups (Before Migration)
|
||||
|
||||
| Component | Version | Source | Purpose |
|
||||
|-----------|---------|--------|---------|
|
||||
| Base styles | 2.2.19 | CDN + local fallback | Core Tailwind utilities for all frontends |
|
||||
| Custom overrides | 1.4.6 | npm build | Windmill Dashboard theme (admin/vendor) |
|
||||
|
||||
### Current Files (Before Migration)
|
||||
|
||||
```
|
||||
package.json # tailwindcss: 1.4.6 (TO BE REMOVED)
|
||||
package-lock.json # (TO BE REMOVED)
|
||||
node_modules/ # (TO BE REMOVED)
|
||||
tailwind.config.js # v1.4 format config (TO BE UPDATED)
|
||||
postcss.config.js # (TO BE REMOVED)
|
||||
static/shared/css/tailwind.min.css # CDN fallback v2.2.19 (TO BE REMOVED)
|
||||
static/admin/css/tailwind.output.css # Built overrides (TO BE REBUILT)
|
||||
static/vendor/css/tailwind.output.css # Built overrides (TO BE REBUILT)
|
||||
```
|
||||
|
||||
### Current Plugins (TO BE REPLACED)
|
||||
|
||||
```json
|
||||
{
|
||||
"@tailwindcss/custom-forms": "0.2.1", // DEPRECATED - replaced by @tailwindcss/forms
|
||||
"tailwindcss-multi-theme": "1.0.3" // DEPRECATED - native darkMode in v3+
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Goals
|
||||
|
||||
1. ✅ Eliminate Node.js dependency entirely
|
||||
2. ✅ Use Tailwind Standalone CLI (single binary, no npm)
|
||||
3. ✅ Upgrade to Tailwind v4.0 (latest)
|
||||
4. ✅ Remove CDN dependency (all CSS built locally)
|
||||
5. ✅ Update config to v4 format
|
||||
6. ✅ Replace deprecated plugins with native features
|
||||
7. ✅ Consolidate to single Tailwind version
|
||||
8. ✅ Verify all frontends work correctly
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Migration
|
||||
|
||||
### Phase 1: Install Tailwind Standalone CLI
|
||||
|
||||
The standalone CLI bundles Tailwind + Node.js runtime into a single binary.
|
||||
|
||||
```bash
|
||||
# Download latest standalone CLI for Linux x64
|
||||
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64
|
||||
chmod +x tailwindcss-linux-x64
|
||||
sudo mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss
|
||||
|
||||
# Verify installation
|
||||
tailwindcss --version
|
||||
```
|
||||
|
||||
**For other platforms:**
|
||||
- macOS (Intel): `tailwindcss-macos-x64`
|
||||
- macOS (Apple Silicon): `tailwindcss-macos-arm64`
|
||||
- Windows: `tailwindcss-windows-x64.exe`
|
||||
- Linux ARM: `tailwindcss-linux-arm64`
|
||||
|
||||
### Phase 2: Update tailwind.config.js
|
||||
|
||||
Replace the entire config file with v4-compatible format:
|
||||
|
||||
```javascript
|
||||
// tailwind.config.js - Tailwind CSS v4.0 (Standalone CLI)
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
// v4: 'content' replaces 'purge'
|
||||
content: [
|
||||
'app/templates/**/*.html',
|
||||
'static/**/*.js',
|
||||
],
|
||||
|
||||
// v4: safelist for dynamic classes (Alpine.js)
|
||||
safelist: [
|
||||
'bg-orange-600',
|
||||
'bg-green-600',
|
||||
'bg-red-600',
|
||||
'bg-blue-600',
|
||||
'bg-purple-600',
|
||||
'hover:bg-orange-700',
|
||||
'hover:bg-green-700',
|
||||
'hover:bg-red-700',
|
||||
'hover:bg-blue-700',
|
||||
'hover:bg-purple-700',
|
||||
// Status colors
|
||||
'text-green-600',
|
||||
'text-red-600',
|
||||
'text-yellow-600',
|
||||
'text-blue-600',
|
||||
'bg-green-100',
|
||||
'bg-red-100',
|
||||
'bg-yellow-100',
|
||||
'bg-blue-100',
|
||||
],
|
||||
|
||||
// v4: darkMode replaces tailwindcss-multi-theme
|
||||
darkMode: 'class',
|
||||
|
||||
theme: {
|
||||
extend: {
|
||||
// Custom colors from Windmill Dashboard
|
||||
colors: {
|
||||
gray: {
|
||||
50: '#f9fafb',
|
||||
100: '#f4f5f7',
|
||||
200: '#e5e7eb',
|
||||
300: '#d5d6d7',
|
||||
400: '#9e9e9e',
|
||||
500: '#707275',
|
||||
600: '#4c4f52',
|
||||
700: '#24262d',
|
||||
800: '#1a1c23',
|
||||
900: '#121317',
|
||||
},
|
||||
purple: {
|
||||
50: '#f6f5ff',
|
||||
100: '#edebfe',
|
||||
200: '#dcd7fe',
|
||||
300: '#cabffd',
|
||||
400: '#ac94fa',
|
||||
500: '#9061f9',
|
||||
600: '#7e3af2',
|
||||
700: '#6c2bd9',
|
||||
800: '#5521b5',
|
||||
900: '#4a1d96',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
maxHeight: {
|
||||
'xl': '36rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
// Note: Standalone CLI includes @tailwindcss/forms by default in v4
|
||||
// No external plugins needed
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Update CSS Source Files
|
||||
|
||||
Update `static/admin/css/tailwind.css`:
|
||||
|
||||
```css
|
||||
/* Tailwind CSS v4 directives */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom component classes */
|
||||
@layer components {
|
||||
.form-input {
|
||||
@apply block w-full rounded-md border-gray-300 shadow-sm
|
||||
focus:border-purple-500 focus:ring-purple-500;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
@apply block w-full rounded-md border-gray-300 shadow-sm
|
||||
focus:border-purple-500 focus:ring-purple-500;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
@apply rounded border-gray-300 text-purple-600
|
||||
focus:ring-purple-500;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
@apply block w-full rounded-md border-gray-300 shadow-sm
|
||||
focus:border-purple-500 focus:ring-purple-500;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode base overrides */
|
||||
@layer base {
|
||||
.dark body {
|
||||
@apply bg-gray-900 text-gray-200;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: Add Makefile Targets
|
||||
|
||||
Add these targets to your Makefile (replaces npm scripts):
|
||||
|
||||
```makefile
|
||||
# =============================================================================
|
||||
# Tailwind CSS (Standalone CLI - No Node.js Required)
|
||||
# =============================================================================
|
||||
|
||||
.PHONY: tailwind-install tailwind-dev tailwind-build tailwind-watch
|
||||
|
||||
# Install Tailwind standalone CLI
|
||||
tailwind-install:
|
||||
@echo "Installing Tailwind CSS standalone CLI..."
|
||||
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64
|
||||
chmod +x tailwindcss-linux-x64
|
||||
sudo mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss
|
||||
@echo "Tailwind CLI installed: $$(tailwindcss --version)"
|
||||
|
||||
# Development build (includes all classes, faster)
|
||||
tailwind-dev:
|
||||
@echo "Building Tailwind CSS (development)..."
|
||||
tailwindcss -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css
|
||||
tailwindcss -i static/admin/css/tailwind.css -o static/vendor/css/tailwind.output.css
|
||||
@echo "CSS built successfully"
|
||||
|
||||
# Production build (purged and minified)
|
||||
tailwind-build:
|
||||
@echo "Building Tailwind CSS (production)..."
|
||||
tailwindcss -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --minify
|
||||
tailwindcss -i static/admin/css/tailwind.css -o static/vendor/css/tailwind.output.css --minify
|
||||
@echo "CSS built and minified successfully"
|
||||
|
||||
# Watch mode for development
|
||||
tailwind-watch:
|
||||
@echo "Watching for CSS changes..."
|
||||
tailwindcss -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --watch
|
||||
```
|
||||
|
||||
### Phase 5: Update Dark Mode Implementation
|
||||
|
||||
**Old approach (tailwindcss-multi-theme):**
|
||||
```html
|
||||
<body class="theme-dark">
|
||||
<div class="bg-white dark:bg-gray-800">
|
||||
```
|
||||
|
||||
**New approach (Tailwind v4 native):**
|
||||
```html
|
||||
<html class="dark">
|
||||
<body>
|
||||
<div class="bg-white dark:bg-gray-800">
|
||||
```
|
||||
|
||||
**Files to update:**
|
||||
1. `app/templates/admin/base.html` - Add `dark` class to `<html>` element
|
||||
2. `app/templates/vendor/base.html` - Add `dark` class to `<html>` element
|
||||
3. `static/admin/js/init-alpine.js` - Update dark mode toggle logic
|
||||
4. `static/vendor/js/init-alpine.js` - Update dark mode toggle logic
|
||||
|
||||
**JavaScript update:**
|
||||
```javascript
|
||||
// Old
|
||||
document.body.classList.toggle('theme-dark');
|
||||
|
||||
// New
|
||||
document.documentElement.classList.toggle('dark');
|
||||
```
|
||||
|
||||
### Phase 6: Update Base Templates
|
||||
|
||||
**Remove CDN loading, use only local built CSS:**
|
||||
|
||||
```html
|
||||
<!-- OLD: CDN with fallback (REMOVE THIS) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
|
||||
|
||||
<!-- NEW: Local only (v4) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
|
||||
```
|
||||
|
||||
### Phase 7: Build and Test
|
||||
|
||||
```bash
|
||||
# Build CSS
|
||||
make tailwind-build
|
||||
|
||||
# Start development server
|
||||
make dev
|
||||
|
||||
# Test all frontends:
|
||||
# - http://localhost:8000/admin/dashboard
|
||||
# - http://localhost:8000/vendor/{code}/dashboard
|
||||
# - http://localhost:8000/ (shop)
|
||||
```
|
||||
|
||||
### Phase 8: Remove Node.js Artifacts
|
||||
|
||||
After successful testing, remove all Node.js related files:
|
||||
|
||||
```bash
|
||||
# Remove Node.js files
|
||||
rm -rf node_modules/
|
||||
rm package.json
|
||||
rm package-lock.json
|
||||
rm postcss.config.js
|
||||
|
||||
# Remove unused CDN fallback
|
||||
rm static/shared/css/tailwind.min.css
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [x] Tailwind CLI installed and working (`tailwindcss --version`)
|
||||
- [x] Admin dashboard loads correctly
|
||||
- [x] Vendor dashboard loads correctly
|
||||
- [x] Shop frontend loads correctly
|
||||
- [x] Platform pages load correctly
|
||||
- [x] Dark mode toggle works
|
||||
- [x] All buttons have correct colors
|
||||
- [x] Forms display correctly (inputs, selects, checkboxes)
|
||||
- [x] Responsive design works (mobile/tablet/desktop)
|
||||
- [x] No console errors
|
||||
- [x] Production build works (`make tailwind-build`)
|
||||
- [x] node_modules removed
|
||||
- [x] package.json removed
|
||||
- [x] Each frontend has its own CSS source file
|
||||
- [x] Vendor theming (CSS variables) still works
|
||||
|
||||
---
|
||||
|
||||
## Breaking Changes Reference
|
||||
|
||||
### Class Name Changes (v2 → v4)
|
||||
|
||||
| v2.x | v4.x | Notes |
|
||||
|------|------|-------|
|
||||
| `overflow-ellipsis` | `text-ellipsis` | Renamed |
|
||||
| `flex-grow-0` | `grow-0` | Shortened |
|
||||
| `flex-shrink-0` | `shrink-0` | Shortened |
|
||||
| `decoration-clone` | `box-decoration-clone` | Renamed |
|
||||
|
||||
### Plugin Changes
|
||||
|
||||
| Old Plugin | New Approach | Notes |
|
||||
|------------|--------------|-------|
|
||||
| `@tailwindcss/custom-forms` | Built-in form styles | Native in v4 |
|
||||
| `tailwindcss-multi-theme` | `darkMode: 'class'` | Native support |
|
||||
|
||||
### Config Changes
|
||||
|
||||
| v1.x/v2.x | v4.x |
|
||||
|-----------|------|
|
||||
| `purge: [...]` | `content: [...]` |
|
||||
| `variants: {...}` | Removed (JIT generates all) |
|
||||
| `mode: 'jit'` | Default (not needed) |
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise, the old setup can be restored:
|
||||
|
||||
```bash
|
||||
# Reinstall Node dependencies
|
||||
npm install
|
||||
|
||||
# Rebuild with old config
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits of This Migration
|
||||
|
||||
| Aspect | Before | After |
|
||||
|--------|--------|-------|
|
||||
| **Node.js required** | Yes | No |
|
||||
| **Dependencies** | ~200MB node_modules | ~50MB single binary |
|
||||
| **Build speed** | Slower (npm + PostCSS) | Faster (standalone) |
|
||||
| **Tailwind versions** | 2 (v1.4 + v2.2) | 1 (v4.0) |
|
||||
| **CSS loading** | CDN + local | Local only |
|
||||
| **Maintenance** | npm updates, security patches | Single binary updates |
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- [Tailwind CSS Standalone CLI](https://tailwindcss.com/blog/standalone-cli)
|
||||
- [Tailwind CSS v4 Documentation](https://tailwindcss.com/docs)
|
||||
- [Dark Mode in Tailwind](https://tailwindcss.com/docs/dark-mode)
|
||||
- [Standalone CLI Releases](https://github.com/tailwindlabs/tailwindcss/releases)
|
||||
@@ -1585,7 +1585,7 @@ async applyFilters() {
|
||||
// marketplace.js
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'marketplace', // Highlights "Marketplace Import" in sidebar
|
||||
currentPage: 'marketplace', // Highlights "Marketplace" in sidebar
|
||||
// ...
|
||||
};
|
||||
|
||||
@@ -1597,6 +1597,28 @@ return {
|
||||
};
|
||||
```
|
||||
|
||||
### Collapsible Sections
|
||||
|
||||
Sidebar sections are collapsible with state persisted to localStorage:
|
||||
|
||||
```javascript
|
||||
// Section keys used in openSections state
|
||||
{
|
||||
platformAdmin: true, // Platform Administration (default open)
|
||||
contentMgmt: false, // Content Management
|
||||
devTools: false, // Developer Tools
|
||||
monitoring: false // Platform Monitoring
|
||||
}
|
||||
|
||||
// Toggle a section
|
||||
toggleSection('devTools');
|
||||
|
||||
// Check if section is open
|
||||
if (openSections.devTools) { ... }
|
||||
```
|
||||
|
||||
See [Sidebar Navigation](../shared/sidebar.md) for full documentation.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI Patterns
|
||||
|
||||
@@ -1,131 +1,158 @@
|
||||
# Complete Implementation Guide - Testing Hub, Components & Icons
|
||||
# Admin Sidebar Navigation
|
||||
|
||||
## 🎉 What's Been Created
|
||||
## Overview
|
||||
|
||||
### ✅ All Files Follow Your Alpine.js Architecture Perfectly!
|
||||
The admin sidebar provides navigation across all admin pages. It features collapsible sections with state persistence, active page indicators, and responsive mobile support.
|
||||
|
||||
1. **Testing Hub** - Manual QA tools
|
||||
2. **Components Library** - UI component reference with navigation
|
||||
3. **Icons Browser** - Searchable icon library with copy-to-clipboard
|
||||
4. **Sidebar Fix** - Active menu indicator for all pages
|
||||
**File Location:** `app/templates/admin/partials/sidebar.html`
|
||||
|
||||
---
|
||||
|
||||
## 📦 Files Created
|
||||
## Sidebar Structure
|
||||
|
||||
### JavaScript Files (Alpine.js Components)
|
||||
1. **[testing-hub.js](computer:///mnt/user-data/outputs/testing-hub.js)** - Testing hub component
|
||||
2. **[components.js](computer:///mnt/user-data/outputs/components.js)** - Components library component
|
||||
3. **[icons-page.js](computer:///mnt/user-data/outputs/icons-page.js)** - Icons browser component
|
||||
The sidebar is organized into the following sections:
|
||||
|
||||
### HTML Templates
|
||||
1. **[testing-hub.html](computer:///mnt/user-data/outputs/testing-hub.html)** - Testing hub page
|
||||
2. **[components.html](computer:///mnt/user-data/outputs/components.html)** - Components library page
|
||||
3. **[icons.html](computer:///mnt/user-data/outputs/icons.html)** - Icons browser page
|
||||
|
||||
### Sidebar Update
|
||||
1. **[sidebar-fixed.html](computer:///mnt/user-data/outputs/sidebar-fixed.html)** - Fixed sidebar with active indicators
|
||||
|
||||
### Documentation
|
||||
1. **[ARCHITECTURE_CONFIRMATION.md](computer:///mnt/user-data/outputs/ARCHITECTURE_CONFIRMATION.md)** - Architecture confirmation
|
||||
| Section | Collapsible | Pages |
|
||||
|---------|-------------|-------|
|
||||
| Dashboard | No | Dashboard |
|
||||
| Platform Administration | Yes | Companies, Vendors, Users, Customers, Marketplace |
|
||||
| Content Management | Yes | Platform Homepage, Content Pages, Vendor Themes |
|
||||
| Developer Tools | Yes | Components, Icons, Testing Hub, Code Quality |
|
||||
| Platform Monitoring | Yes | Import Jobs, Application Logs |
|
||||
| Settings | No | Settings |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Installation Steps
|
||||
## Collapsible Sections
|
||||
|
||||
### Step 1: Install JavaScript Files
|
||||
### How It Works
|
||||
|
||||
```bash
|
||||
# Copy to your static directory
|
||||
cp outputs/testing-hub.js static/admin/js/testing-hub.js
|
||||
cp outputs/components.js static/admin/js/components.js
|
||||
cp outputs/icons-page.js static/admin/js/icons-page.js
|
||||
Sections can be expanded/collapsed by clicking the section header. The state is persisted to `localStorage` so sections remain open/closed across page navigation and browser sessions.
|
||||
|
||||
### State Management
|
||||
|
||||
**File:** `static/admin/js/init-alpine.js`
|
||||
|
||||
```javascript
|
||||
// Default state: Platform Administration open, others closed
|
||||
const defaultSections = {
|
||||
platformAdmin: true,
|
||||
contentMgmt: false,
|
||||
devTools: false,
|
||||
monitoring: false
|
||||
};
|
||||
|
||||
// State stored in localStorage under this key
|
||||
const SIDEBAR_STORAGE_KEY = 'admin_sidebar_sections';
|
||||
```
|
||||
|
||||
### Step 2: Install HTML Templates
|
||||
### Available Methods
|
||||
|
||||
```bash
|
||||
# Copy to your templates directory
|
||||
cp outputs/testing-hub.html app/templates/admin/testing-hub.html
|
||||
cp outputs/components.html app/templates/admin/components.html
|
||||
cp outputs/icons.html app/templates/admin/icons.html
|
||||
```
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `toggleSection(section)` | Toggle a section open/closed |
|
||||
| `expandSectionForCurrentPage()` | Auto-expand section containing current page |
|
||||
| `openSections.platformAdmin` | Check if Platform Administration is open |
|
||||
| `openSections.contentMgmt` | Check if Content Management is open |
|
||||
| `openSections.devTools` | Check if Developer Tools is open |
|
||||
| `openSections.monitoring` | Check if Platform Monitoring is open |
|
||||
|
||||
### Step 3: Fix Sidebar (IMPORTANT!)
|
||||
### CSS Transitions
|
||||
|
||||
```bash
|
||||
# Replace your current sidebar
|
||||
cp outputs/sidebar-fixed.html app/templates/partials/sidebar.html
|
||||
```
|
||||
|
||||
### Step 4: Add Icons Route
|
||||
|
||||
Update `app/api/v1/admin/pages.py` - add this route:
|
||||
|
||||
```python
|
||||
@router.get("/icons", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_icons_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Render icons browser page.
|
||||
Browse and search all available icons.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"admin/icons.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Step 5: Verify Icons Are Updated
|
||||
|
||||
Make sure you're using the updated `icons-updated.js` from earlier:
|
||||
|
||||
```bash
|
||||
cp outputs/icons-updated.js static/shared/js/icons.js
|
||||
```
|
||||
|
||||
### Step 6: Restart Server
|
||||
|
||||
```bash
|
||||
# Stop current server (Ctrl+C)
|
||||
# Start again
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Sidebar Active Indicator Fix
|
||||
|
||||
### The Problem
|
||||
|
||||
You noticed that only the Dashboard menu item showed the vertical purple bar on the left when active. Other menu items didn't show this indicator.
|
||||
|
||||
### The Root Cause
|
||||
|
||||
Each page's JavaScript component needs to set `currentPage` correctly, and the sidebar HTML needs to check for that value.
|
||||
|
||||
**Before (Only Dashboard worked):**
|
||||
```html
|
||||
<!-- Only dashboard had the x-show condition -->
|
||||
<span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"></span>
|
||||
```
|
||||
|
||||
### The Fix
|
||||
|
||||
**1. Sidebar HTML** - Add the indicator `<span>` to EVERY menu item:
|
||||
Sections animate smoothly using CSS transitions (no plugins required):
|
||||
|
||||
```html
|
||||
<ul
|
||||
x-show="openSections.platformAdmin"
|
||||
x-transition:enter="transition-all duration-200 ease-out"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition-all duration-150 ease-in"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
class="mt-1 overflow-hidden"
|
||||
>
|
||||
```
|
||||
|
||||
### Chevron Icon Rotation
|
||||
|
||||
The chevron icon rotates 180 degrees when a section is expanded:
|
||||
|
||||
```html
|
||||
<span
|
||||
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
|
||||
:class="{ 'rotate-180': openSections.platformAdmin }"
|
||||
></span>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Page-to-Section Mapping
|
||||
|
||||
Pages are mapped to their parent sections for auto-expansion:
|
||||
|
||||
```javascript
|
||||
const pageSectionMap = {
|
||||
// Platform Administration
|
||||
companies: 'platformAdmin',
|
||||
vendors: 'platformAdmin',
|
||||
users: 'platformAdmin',
|
||||
customers: 'platformAdmin',
|
||||
marketplace: 'platformAdmin',
|
||||
// Content Management
|
||||
'platform-homepage': 'contentMgmt',
|
||||
'content-pages': 'contentMgmt',
|
||||
'vendor-theme': 'contentMgmt',
|
||||
// Developer Tools
|
||||
components: 'devTools',
|
||||
icons: 'devTools',
|
||||
testing: 'devTools',
|
||||
'code-quality': 'devTools',
|
||||
// Platform Monitoring
|
||||
imports: 'monitoring',
|
||||
logs: 'monitoring'
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Page Mapping
|
||||
|
||||
| Page | `currentPage` Value | Section | URL |
|
||||
|------|---------------------|---------|-----|
|
||||
| Dashboard | `'dashboard'` | (always visible) | `/admin/dashboard` |
|
||||
| Companies | `'companies'` | Platform Administration | `/admin/companies` |
|
||||
| Vendors | `'vendors'` | Platform Administration | `/admin/vendors` |
|
||||
| Users | `'users'` | Platform Administration | `/admin/users` |
|
||||
| Customers | `'customers'` | Platform Administration | `/admin/customers` |
|
||||
| Marketplace | `'marketplace'` | Platform Administration | `/admin/marketplace` |
|
||||
| Platform Homepage | `'platform-homepage'` | Content Management | `/admin/platform-homepage` |
|
||||
| Content Pages | `'content-pages'` | Content Management | `/admin/content-pages` |
|
||||
| Vendor Themes | `'vendor-theme'` | Content Management | `/admin/vendor-themes` |
|
||||
| Components | `'components'` | Developer Tools | `/admin/components` |
|
||||
| Icons | `'icons'` | Developer Tools | `/admin/icons` |
|
||||
| Testing Hub | `'testing'` | Developer Tools | `/admin/testing` |
|
||||
| Code Quality | `'code-quality'` | Developer Tools | `/admin/code-quality` |
|
||||
| Import Jobs | `'imports'` | Platform Monitoring | `/admin/imports` |
|
||||
| Application Logs | `'logs'` | Platform Monitoring | `/admin/logs` |
|
||||
| Settings | `'settings'` | (always visible) | `/admin/settings` |
|
||||
|
||||
---
|
||||
|
||||
## Active Page Indicator
|
||||
|
||||
Each menu item shows a purple vertical bar when active:
|
||||
|
||||
```html
|
||||
<!-- Vendors -->
|
||||
<li class="relative px-6 py-3">
|
||||
<!-- ✅ Add this span for the purple bar -->
|
||||
<span x-show="currentPage === 'vendors'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"></span>
|
||||
<a :class="currentPage === 'vendors' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
<!-- Purple bar indicator (shows when page is active) -->
|
||||
<span x-show="currentPage === 'vendors'"
|
||||
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
|
||||
aria-hidden="true"></span>
|
||||
|
||||
<!-- Link with active text styling -->
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === 'vendors' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
href="/admin/vendors">
|
||||
<span x-html="$icon('shopping-bag')"></span>
|
||||
<span class="ml-4">Vendors</span>
|
||||
@@ -133,329 +160,179 @@ Each page's JavaScript component needs to set `currentPage` correctly, and the s
|
||||
</li>
|
||||
```
|
||||
|
||||
**2. JavaScript Files** - Each component must set `currentPage`:
|
||||
### Setting currentPage in Components
|
||||
|
||||
Each page component must set `currentPage` to match the sidebar:
|
||||
|
||||
```javascript
|
||||
// vendors.js
|
||||
function adminVendors() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'vendors', // ✅ Must match sidebar check
|
||||
// ... rest of component
|
||||
};
|
||||
}
|
||||
|
||||
// users.js
|
||||
function adminUsers() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'users', // ✅ Must match sidebar check
|
||||
...data(), // Inherit base (includes openSections)
|
||||
currentPage: 'vendors', // Must match sidebar check
|
||||
// ... rest of component
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Page Mapping
|
||||
|
||||
| Page | JavaScript `currentPage` | Sidebar Check | URL |
|
||||
|------|--------------------------|---------------|-----|
|
||||
| Dashboard | `'dashboard'` | `x-show="currentPage === 'dashboard'"` | `/admin/dashboard` |
|
||||
| Companies | `'companies'` | `x-show="currentPage === 'companies'"` | `/admin/companies` |
|
||||
| Vendors | `'vendors'` | `x-show="currentPage === 'vendors'"` | `/admin/vendors` |
|
||||
| Users | `'users'` | `x-show="currentPage === 'users'"` | `/admin/users` |
|
||||
| Customers | `'customers'` | `x-show="currentPage === 'customers'"` | `/admin/customers` |
|
||||
| Marketplace | `'marketplace'` | `x-show="currentPage === 'marketplace'"` | `/admin/marketplace` |
|
||||
| Imports | `'imports'` | `x-show="currentPage === 'imports'"` | `/admin/imports` |
|
||||
| Components | `'components'` | `x-show="currentPage === 'components'"` | `/admin/components` |
|
||||
| Icons | `'icons'` | `x-show="currentPage === 'icons'"` | `/admin/icons` |
|
||||
| Testing | `'testing'` | `x-show="currentPage === 'testing'"` | `/admin/testing` |
|
||||
| Settings | `'settings'` | `x-show="currentPage === 'settings'"` | `/admin/settings` |
|
||||
|
||||
### Updated Sidebar Structure
|
||||
|
||||
The sidebar is organized into the following sections:
|
||||
|
||||
**Dashboard:**
|
||||
- Dashboard
|
||||
|
||||
**Platform Administration:**
|
||||
- Companies
|
||||
- Vendors
|
||||
- Users
|
||||
- Customers
|
||||
- Marketplace
|
||||
|
||||
**Content Management:**
|
||||
- Platform Homepage
|
||||
- Content Pages
|
||||
- Vendor Themes
|
||||
|
||||
**Developer Tools:**
|
||||
- Components
|
||||
- Icons
|
||||
- Testing Hub
|
||||
- Code Quality
|
||||
|
||||
**Platform Monitoring:**
|
||||
- Import Jobs
|
||||
- Application Logs
|
||||
|
||||
**Settings:**
|
||||
- Settings
|
||||
|
||||
Each section is properly separated with dividers and all menu items have active indicators.
|
||||
|
||||
---
|
||||
|
||||
## ✨ New Features
|
||||
## Jinja2 Macros
|
||||
|
||||
### Testing Hub
|
||||
- **2 Test Suites**: Auth Flow and Data Migration
|
||||
- **Stats Cards**: Quick metrics overview
|
||||
- **Interactive Cards**: Click to run tests
|
||||
- **Best Practices**: Testing guidelines
|
||||
- **Resource Links**: To Components and Icons pages
|
||||
The sidebar uses Jinja2 macros for DRY code:
|
||||
|
||||
### Components Library
|
||||
- **Sticky Section Navigation**: Jump to Forms, Buttons, Cards, etc.
|
||||
- **Hash-based URLs**: Bookmarkable sections (#forms, #buttons)
|
||||
- **Copy to Clipboard**: Click to copy component code
|
||||
- **Live Examples**: All components with real Alpine.js
|
||||
- **Dark Mode**: All examples support dark mode
|
||||
### section_header
|
||||
|
||||
### Icons Browser
|
||||
- **Search Functionality**: Filter icons by name
|
||||
- **Category Navigation**: Browse by category
|
||||
- **Live Preview**: See icons in multiple sizes
|
||||
- **Copy Icon Name**: Quick copy to clipboard
|
||||
- **Copy Usage Code**: Copy Alpine.js usage code
|
||||
- **Selected Icon Details**: Full preview and size examples
|
||||
- **Auto-categorization**: Icons organized automatically
|
||||
Creates a collapsible section header with chevron:
|
||||
|
||||
---
|
||||
|
||||
## 🎯 How Each Feature Works
|
||||
|
||||
### Components Library Navigation
|
||||
|
||||
1. **Click a section** in the left sidebar
|
||||
2. **Page scrolls** to that section smoothly
|
||||
3. **URL updates** with hash (#forms)
|
||||
4. **Active section** is highlighted in purple
|
||||
5. **Bookmarkable**: Share URL with #section
|
||||
|
||||
```javascript
|
||||
// How it works
|
||||
goToSection(sectionId) {
|
||||
this.activeSection = sectionId;
|
||||
window.location.hash = sectionId;
|
||||
// Smooth scroll
|
||||
document.getElementById(sectionId).scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
```jinja2
|
||||
{% macro section_header(title, section_key) %}
|
||||
<button
|
||||
@click="toggleSection('{{ section_key }}')"
|
||||
class="flex items-center justify-between w-full px-6 py-2 ..."
|
||||
>
|
||||
<span>{{ title }}</span>
|
||||
<span x-html="$icon('chevron-down', '...')"
|
||||
:class="{ 'rotate-180': openSections.{{ section_key }} }"></span>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
```
|
||||
|
||||
### Icons Browser Search
|
||||
### section_content
|
||||
|
||||
1. **Type in search box** - filters as you type
|
||||
2. **Click category pill** - filters by category
|
||||
3. **Click any icon** - shows details panel
|
||||
4. **Hover icon** - shows copy buttons
|
||||
5. **Click copy** - copies to clipboard
|
||||
Wraps section items with collapse animation:
|
||||
|
||||
```javascript
|
||||
// How it works
|
||||
filterIcons() {
|
||||
let icons = this.allIcons;
|
||||
|
||||
// Filter by category
|
||||
if (this.activeCategory !== 'all') {
|
||||
icons = icons.filter(icon => icon.category === this.activeCategory);
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (this.searchQuery.trim()) {
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
icons = icons.filter(icon => icon.name.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
this.filteredIcons = icons;
|
||||
}
|
||||
```jinja2
|
||||
{% macro section_content(section_key) %}
|
||||
<ul x-show="openSections.{{ section_key }}" x-transition:...>
|
||||
{{ caller() }}
|
||||
</ul>
|
||||
{% endmacro %}
|
||||
```
|
||||
|
||||
### Testing Hub Navigation
|
||||
### menu_item
|
||||
|
||||
1. **View stats** at the top
|
||||
2. **Read test suite cards** with features
|
||||
3. **Click "Run Tests"** to go to test page
|
||||
4. **Read best practices** before testing
|
||||
Creates a menu item with active indicator:
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
After installation, verify:
|
||||
|
||||
### General
|
||||
- [ ] Server starts without errors
|
||||
- [ ] All routes load successfully
|
||||
- [ ] Icons display correctly everywhere
|
||||
- [ ] Dark mode works on all pages
|
||||
|
||||
### Sidebar
|
||||
- [ ] Dashboard shows purple bar when active
|
||||
- [ ] Vendors shows purple bar when active
|
||||
- [ ] Users shows purple bar when active
|
||||
- [ ] Components shows purple bar when active
|
||||
- [ ] Icons shows purple bar when active
|
||||
- [ ] Testing shows purple bar when active
|
||||
- [ ] Text is bold/highlighted on active page
|
||||
|
||||
### Testing Hub
|
||||
- [ ] Page loads at `/admin/testing`
|
||||
- [ ] Stats cards display correctly
|
||||
- [ ] Test suite cards are clickable
|
||||
- [ ] Icons render properly
|
||||
- [ ] Links to other pages work
|
||||
|
||||
### Components Library
|
||||
- [ ] Page loads at `/admin/components`
|
||||
- [ ] Section navigation works
|
||||
- [ ] Clicking section scrolls to it
|
||||
- [ ] URL hash updates (#forms, etc.)
|
||||
- [ ] Copy buttons work
|
||||
- [ ] All form examples render
|
||||
- [ ] Toast examples work
|
||||
|
||||
### Icons Browser
|
||||
- [ ] Page loads at `/admin/icons`
|
||||
- [ ] Shows correct icon count
|
||||
- [ ] Search filters icons
|
||||
- [ ] Category pills filter icons
|
||||
- [ ] Clicking icon shows details
|
||||
- [ ] Copy name button works
|
||||
- [ ] Copy usage button works
|
||||
- [ ] Preview shows multiple sizes
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Customization
|
||||
|
||||
### Adding More Test Suites
|
||||
|
||||
Edit `testing-hub.js`:
|
||||
|
||||
```javascript
|
||||
testSuites: [
|
||||
// ... existing suites
|
||||
{
|
||||
id: 'new-suite',
|
||||
name: 'New Test Suite',
|
||||
description: 'Description here',
|
||||
url: '/admin/test/new-suite',
|
||||
icon: 'icon-name',
|
||||
color: 'blue', // blue, orange, green, purple
|
||||
testCount: 5,
|
||||
features: [
|
||||
'Feature 1',
|
||||
'Feature 2'
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Adding More Component Sections
|
||||
|
||||
Edit `components.js`:
|
||||
|
||||
```javascript
|
||||
sections: [
|
||||
// ... existing sections
|
||||
{ id: 'new-section', name: 'New Section', icon: 'icon-name' }
|
||||
]
|
||||
```
|
||||
|
||||
Then add the section HTML in `components.html`:
|
||||
|
||||
```html
|
||||
<section id="new-section">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h2>New Section</h2>
|
||||
<!-- Your components here -->
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
### Adding More Icon Categories
|
||||
|
||||
Edit `icons-page.js`:
|
||||
|
||||
```javascript
|
||||
categories: [
|
||||
// ... existing categories
|
||||
{ id: 'new-category', name: 'New Category', icon: 'icon-name' }
|
||||
]
|
||||
```
|
||||
|
||||
And update the `categorizeIcon()` function:
|
||||
|
||||
```javascript
|
||||
categoryMap: {
|
||||
// ... existing mappings
|
||||
'new-category': ['keyword1', 'keyword2']
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Quick Reference
|
||||
|
||||
### Alpine.js Component Pattern
|
||||
|
||||
```javascript
|
||||
function yourPageComponent() {
|
||||
return {
|
||||
...data(), // ✅ Inherit base
|
||||
currentPage: 'name', // ✅ Set page ID
|
||||
|
||||
async init() {
|
||||
if (window._yourPageInitialized) return;
|
||||
window._yourPageInitialized = true;
|
||||
// Your init code
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Sidebar Menu Item Pattern
|
||||
|
||||
```html
|
||||
```jinja2
|
||||
{% macro menu_item(page_id, url, icon, label) %}
|
||||
<li class="relative px-6 py-3">
|
||||
<!-- Active indicator -->
|
||||
<span x-show="currentPage === 'page-name'"
|
||||
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg">
|
||||
</span>
|
||||
|
||||
<!-- Link -->
|
||||
<a :class="currentPage === 'page-name' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
href="/admin/page-name">
|
||||
<span x-html="$icon('icon-name')"></span>
|
||||
<span class="ml-4">Page Name</span>
|
||||
<span x-show="currentPage === '{{ page_id }}'" class="..."></span>
|
||||
<a href="{{ url }}">
|
||||
<span x-html="$icon('{{ icon }}')"></span>
|
||||
<span class="ml-4">{{ label }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
```
|
||||
|
||||
### Usage Example
|
||||
|
||||
```jinja2
|
||||
{{ section_header('Platform Administration', 'platformAdmin') }}
|
||||
{% call section_content('platformAdmin') %}
|
||||
{{ menu_item('companies', '/admin/companies', 'office-building', 'Companies') }}
|
||||
{{ menu_item('vendors', '/admin/vendors', 'shopping-bag', 'Vendors') }}
|
||||
{% endcall %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Summary
|
||||
## Adding a New Page
|
||||
|
||||
**Architecture:** ✅ All files follow your Alpine.js patterns perfectly
|
||||
**Sidebar:** ✅ Fixed - all menu items now show active indicator
|
||||
**Testing Hub:** ✅ Complete with test suites and navigation
|
||||
**Components:** ✅ Complete with section navigation and copy feature
|
||||
**Icons:** ✅ Complete with search, categories, and copy features
|
||||
### Step 1: Add Route
|
||||
|
||||
**Total Files:** 7 (3 JS + 3 HTML + 1 Sidebar)
|
||||
**Lines of Code:** ~2000+
|
||||
**Features Added:** 20+
|
||||
Add the route in `app/routes/admin_pages.py`:
|
||||
|
||||
Everything is ready to install! 🚀
|
||||
```python
|
||||
@router.get("/new-page", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_new_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
||||
):
|
||||
return templates.TemplateResponse(
|
||||
"admin/new-page.html",
|
||||
{"request": request, "user": current_user},
|
||||
)
|
||||
```
|
||||
|
||||
### Step 2: Create Template
|
||||
|
||||
Create `app/templates/admin/new-page.html`:
|
||||
|
||||
```jinja2
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}New Page{% endblock %}
|
||||
{% block alpine_data %}adminNewPage(){% endblock %}
|
||||
{% block content %}
|
||||
<!-- Your content -->
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
### Step 3: Create JavaScript Component
|
||||
|
||||
Create `static/admin/js/new-page.js`:
|
||||
|
||||
```javascript
|
||||
function adminNewPage() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'new-page', // Must match sidebar
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Add to Sidebar
|
||||
|
||||
Edit `app/templates/admin/partials/sidebar.html`:
|
||||
|
||||
```jinja2
|
||||
{# Add to appropriate section #}
|
||||
{{ menu_item('new-page', '/admin/new-page', 'icon-name', 'New Page') }}
|
||||
```
|
||||
|
||||
### Step 5: Update Page-Section Map (if in collapsible section)
|
||||
|
||||
Edit `static/admin/js/init-alpine.js`:
|
||||
|
||||
```javascript
|
||||
const pageSectionMap = {
|
||||
// ... existing mappings
|
||||
'new-page': 'platformAdmin', // Add mapping
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mobile Sidebar
|
||||
|
||||
The sidebar has a mobile version that slides in from the left:
|
||||
|
||||
- **Toggle:** Click hamburger menu in header
|
||||
- **Close:** Click outside, press Escape, or navigate
|
||||
- **State:** Controlled by `isSideMenuOpen`
|
||||
|
||||
```javascript
|
||||
// In base data()
|
||||
isSideMenuOpen: false,
|
||||
toggleSideMenu() {
|
||||
this.isSideMenuOpen = !this.isSideMenuOpen
|
||||
},
|
||||
closeSideMenu() {
|
||||
this.isSideMenuOpen = false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Sections expand/collapse on header click
|
||||
- [ ] Chevron rotates when section opens/closes
|
||||
- [ ] Section state persists after page reload
|
||||
- [ ] Section state persists across different pages
|
||||
- [ ] Active page shows purple bar indicator
|
||||
- [ ] Active page text is highlighted
|
||||
- [ ] Mobile sidebar opens/closes correctly
|
||||
- [ ] Collapsible sections work on mobile
|
||||
- [ ] All navigation links work correctly
|
||||
|
||||
@@ -1,144 +1,86 @@
|
||||
# UI Components Library Implementation Guide
|
||||
# UI Components Library
|
||||
|
||||
**Version:** 2.0
|
||||
**Last Updated:** December 2024
|
||||
**Live Reference:** `/admin/components`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
This guide covers the implementation of:
|
||||
1. **Components reference page** - A library showcasing all your UI components
|
||||
2. **Updated vendor edit page** - Using proper form components
|
||||
3. **Updated vendor detail page** - Using proper card components
|
||||
4. **Sidebar navigation** - Adding "Components" menu item
|
||||
|
||||
## Files Created
|
||||
The admin panel uses a consistent set of UI components built with Tailwind CSS and Alpine.js. All components support dark mode and are fully accessible.
|
||||
|
||||
### 1. Components Library Page
|
||||
**File:** `app/templates/admin/components.html`
|
||||
- Complete reference for all UI components
|
||||
- Quick navigation to sections (Forms, Buttons, Cards, etc.)
|
||||
- Copy-paste ready examples
|
||||
- Shows validation states, disabled states, helper text, etc.
|
||||
**Live Component Library:** Visit `/admin/components` in the admin panel to see all components with copy-paste ready code.
|
||||
|
||||
### 2. Updated Vendor Edit Page
|
||||
**File:** `app/templates/admin/vendor-edit-updated.html`
|
||||
- Uses proper form components from your library
|
||||
- Improved visual hierarchy with card sections
|
||||
- Better validation state displays (red borders for errors)
|
||||
- Quick actions section at the top
|
||||
- Status badges showing current state
|
||||
- Clean, consistent styling throughout
|
||||
---
|
||||
|
||||
### 3. Vendor Detail Page
|
||||
**File:** `app/templates/admin/vendor-detail.html`
|
||||
- NEW file (didn't exist before)
|
||||
- Uses card components to display vendor information
|
||||
- Status cards showing verification, active status, dates
|
||||
- Information organized in clear sections
|
||||
- All vendor data displayed in readable format
|
||||
- Delete action button
|
||||
## Page Layout Structure
|
||||
|
||||
### 4. JavaScript for Detail Page
|
||||
**File:** `static/admin/js/vendor-detail.js`
|
||||
- Loads vendor data
|
||||
- Handles delete action with double confirmation
|
||||
- Logging for debugging
|
||||
- Error handling
|
||||
All admin list pages follow a consistent structure:
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add Components Menu to Sidebar
|
||||
|
||||
Update your `app/templates/admin/sidebar.html` (or wherever your sidebar is defined):
|
||||
|
||||
```html
|
||||
<!-- Add this menu item after "Settings" or wherever appropriate -->
|
||||
<li class="relative px-6 py-3">
|
||||
<a
|
||||
class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="{ 'text-gray-800 dark:text-gray-100': currentPage === 'components' }"
|
||||
href="/admin/components"
|
||||
>
|
||||
<span x-html="$icon('collection', 'w-5 h-5')"></span>
|
||||
<span class="ml-4">Components</span>
|
||||
</a>
|
||||
</li>
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Page Header (Title + Action Button) │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Stats Cards (4 columns on desktop) │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Search & Filters Bar │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Data Table │
|
||||
│ ├── Table Header │
|
||||
│ ├── Table Rows │
|
||||
│ └── Pagination │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Step 2: Add Components Page Route
|
||||
---
|
||||
|
||||
Update your `app/api/v1/admin/pages.py`:
|
||||
## Form Components
|
||||
|
||||
```python
|
||||
@router.get("/components", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_components_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Render UI components reference page.
|
||||
Shows all available UI components for easy reference.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"admin/components.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
}
|
||||
)
|
||||
```
|
||||
### Basic Input
|
||||
|
||||
### Step 3: Replace Vendor Edit Template
|
||||
|
||||
1. Backup your current: `app/templates/admin/vendor-edit.html`
|
||||
2. Replace it with: `vendor-edit-updated.html`
|
||||
3. Keep your existing `vendor-edit.js` (no changes needed)
|
||||
|
||||
### Step 4: Add Vendor Detail Template & JavaScript
|
||||
|
||||
1. Copy `vendor-detail.html` to `app/templates/admin/vendor-detail.html`
|
||||
2. Copy `vendor-detail.js` to `static/admin/js/vendor-detail.js`
|
||||
|
||||
## Component Usage Guide
|
||||
|
||||
### Form Components
|
||||
|
||||
#### Basic Input
|
||||
```html
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Label Text <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.fieldName"
|
||||
required
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
/>
|
||||
</label>
|
||||
```
|
||||
|
||||
#### Input with Validation Error
|
||||
```html
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Field Label</span>
|
||||
<input
|
||||
type="text"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.fieldName }"
|
||||
x-model="formData.field"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
/>
|
||||
<span x-show="errors.fieldName" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.fieldName"></span>
|
||||
</label>
|
||||
```
|
||||
|
||||
#### Disabled Input
|
||||
### Required Field with Validation
|
||||
|
||||
```html
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Email Address <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
x-model="formData.email"
|
||||
required
|
||||
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.email }"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input"
|
||||
/>
|
||||
<span x-show="errors.email" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.email"></span>
|
||||
</label>
|
||||
```
|
||||
|
||||
### Disabled/Read-Only Input
|
||||
|
||||
```html
|
||||
<input
|
||||
type="text"
|
||||
x-model="data.field"
|
||||
disabled
|
||||
value="Read-only value"
|
||||
class="block w-full mt-1 text-sm bg-gray-100 border-gray-300 rounded-md dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
```
|
||||
|
||||
#### Textarea
|
||||
### Textarea
|
||||
|
||||
```html
|
||||
<textarea
|
||||
x-model="formData.description"
|
||||
@@ -147,26 +89,90 @@ async def admin_components_page(
|
||||
></textarea>
|
||||
```
|
||||
|
||||
### Card Components
|
||||
### Select
|
||||
|
||||
```html
|
||||
<select
|
||||
x-model="formData.option"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-select"
|
||||
>
|
||||
<option value="">Select an option</option>
|
||||
<option value="option1">Option 1</option>
|
||||
<option value="option2">Option 2</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Button Components
|
||||
|
||||
### Primary Button
|
||||
|
||||
```html
|
||||
<button class="px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
Primary Button
|
||||
</button>
|
||||
```
|
||||
|
||||
### Button with Icon
|
||||
|
||||
```html
|
||||
<button class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none">
|
||||
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||
Add Item
|
||||
</button>
|
||||
```
|
||||
|
||||
### Secondary Button
|
||||
|
||||
```html
|
||||
<button class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg hover:border-gray-400 focus:outline-none dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800">
|
||||
Cancel
|
||||
</button>
|
||||
```
|
||||
|
||||
### Danger Button
|
||||
|
||||
```html
|
||||
<button class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
|
||||
Delete
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Card Components
|
||||
|
||||
### Stats Card
|
||||
|
||||
#### Stats Card
|
||||
```html
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('user-group', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Users
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
1,234
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Info Card
|
||||
### Stats Card Colors
|
||||
|
||||
| Color | Use Case | Classes |
|
||||
|-------|----------|---------|
|
||||
| Blue | Total counts | `text-blue-500 bg-blue-100` |
|
||||
| Green | Active/Success | `text-green-500 bg-green-100` |
|
||||
| Red | Inactive/Errors | `text-red-500 bg-red-100` |
|
||||
| Orange | Warnings/Admin | `text-orange-500 bg-orange-100` |
|
||||
| Purple | Primary metrics | `text-purple-500 bg-purple-100` |
|
||||
|
||||
### Info Card
|
||||
|
||||
```html
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
@@ -181,125 +187,281 @@ async def admin_components_page(
|
||||
</div>
|
||||
```
|
||||
|
||||
### Button Components
|
||||
---
|
||||
|
||||
## Status Badges
|
||||
|
||||
### Success Badge
|
||||
|
||||
#### Primary Button
|
||||
```html
|
||||
<button class="px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
Button Text
|
||||
</button>
|
||||
<span class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-green-700 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-100">
|
||||
<span x-html="$icon('check-circle', 'w-3 h-3 mr-1')"></span>
|
||||
Active
|
||||
</span>
|
||||
```
|
||||
|
||||
#### Button with Icon
|
||||
### Warning Badge
|
||||
|
||||
```html
|
||||
<button class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none">
|
||||
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||
Add Item
|
||||
</button>
|
||||
<span class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-orange-700 bg-orange-100 rounded-full dark:bg-orange-700 dark:text-orange-100">
|
||||
<span x-html="$icon('clock', 'w-3 h-3 mr-1')"></span>
|
||||
Pending
|
||||
</span>
|
||||
```
|
||||
|
||||
#### Secondary Button
|
||||
### Danger Badge
|
||||
|
||||
```html
|
||||
<button class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg hover:border-gray-400 focus:outline-none dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800">
|
||||
Cancel
|
||||
</button>
|
||||
<span class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-red-700 bg-red-100 rounded-full dark:bg-red-700 dark:text-red-100">
|
||||
<span x-html="$icon('x-circle', 'w-3 h-3 mr-1')"></span>
|
||||
Inactive
|
||||
</span>
|
||||
```
|
||||
|
||||
## Features of Updated Pages
|
||||
### Dynamic Badge
|
||||
|
||||
### Vendor Edit Page Improvements
|
||||
```html
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="item.is_active
|
||||
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
|
||||
: 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
|
||||
x-text="item.is_active ? 'Active' : 'Inactive'">
|
||||
</span>
|
||||
```
|
||||
|
||||
1. **Quick Actions Section**
|
||||
- Verify/Unverify button
|
||||
- Activate/Deactivate button
|
||||
- Status badges showing current state
|
||||
---
|
||||
|
||||
2. **Better Form Organization**
|
||||
- Clear sections with headers
|
||||
- Two-column layout on desktop
|
||||
- Helper text for all fields
|
||||
- Proper validation states
|
||||
## Data Tables
|
||||
|
||||
3. **Visual Consistency**
|
||||
- Uses standard form components
|
||||
- Consistent spacing and sizing
|
||||
- Dark mode support
|
||||
### Basic Table Structure
|
||||
|
||||
4. **User Experience**
|
||||
- Disabled states for read-only fields
|
||||
- Clear indication of required fields
|
||||
- Loading states
|
||||
- Error messages inline with fields
|
||||
```html
|
||||
<div class="w-full overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Name</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="item in items" :key="item.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3" x-text="item.name"></td>
|
||||
<td class="px-4 py-3"><!-- Status badge --></td>
|
||||
<td class="px-4 py-3"><!-- Action buttons --></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Vendor Detail Page Features
|
||||
### Action Buttons
|
||||
|
||||
1. **Status Overview**
|
||||
- 4 stats cards at top showing key metrics
|
||||
- Visual status indicators (colors, icons)
|
||||
```html
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<!-- View -->
|
||||
<a :href="'/admin/resource/' + item.id"
|
||||
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-700"
|
||||
title="View">
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
|
||||
2. **Information Organization**
|
||||
- Basic info card
|
||||
- Contact info card
|
||||
- Business details section
|
||||
- Owner information section
|
||||
- Marketplace URLs (if available)
|
||||
<!-- Edit -->
|
||||
<a :href="'/admin/resource/' + item.id + '/edit'"
|
||||
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700"
|
||||
title="Edit">
|
||||
<span x-html="$icon('edit', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
|
||||
3. **Actions**
|
||||
- Edit button (goes to edit page)
|
||||
- Delete button (with double confirmation)
|
||||
- Back to list button
|
||||
<!-- Delete -->
|
||||
<button @click="deleteItem(item)"
|
||||
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700"
|
||||
title="Delete">
|
||||
<span x-html="$icon('delete', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Quick Reference: Where to Find Components
|
||||
---
|
||||
|
||||
When you need a component, visit `/admin/components` and you'll find:
|
||||
## Loading & Error States
|
||||
|
||||
- **Forms Section**: All input types, validation states, helper text
|
||||
- **Buttons Section**: All button styles and states
|
||||
- **Cards Section**: Stats cards, info cards
|
||||
- **Tables Section**: (from your tables.html)
|
||||
- **Modals Section**: (from your modals.html)
|
||||
- **Charts Section**: (from your charts.html)
|
||||
### Loading State
|
||||
|
||||
## Testing Checklist
|
||||
```html
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] `/admin/components` page loads and displays all components
|
||||
- [ ] Components menu item appears in sidebar
|
||||
- [ ] `/admin/vendors/{vendor_code}/edit` displays correctly
|
||||
- [ ] Form validation shows errors in red
|
||||
- [ ] Quick actions (verify/activate) work
|
||||
- [ ] `/admin/vendors/{vendor_code}` displays all vendor data
|
||||
- [ ] Status cards show correct information
|
||||
- [ ] Edit button navigates to edit page
|
||||
- [ ] Delete button shows double confirmation
|
||||
- [ ] All pages work in dark mode
|
||||
- [ ] All pages are responsive on mobile
|
||||
### Error Alert
|
||||
|
||||
## Color Scheme Reference
|
||||
```html
|
||||
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error loading data</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Your component library uses these color schemes:
|
||||
### Empty State
|
||||
|
||||
- **Primary**: Purple (`bg-purple-600`, `text-purple-600`)
|
||||
- **Success**: Green (`bg-green-600`, `text-green-600`)
|
||||
- **Warning**: Orange (`bg-orange-600`, `text-orange-600`)
|
||||
- **Danger**: Red (`bg-red-600`, `text-red-600`)
|
||||
- **Info**: Blue (`bg-blue-600`, `text-blue-600`)
|
||||
```html
|
||||
<div class="flex flex-col items-center py-12">
|
||||
<span x-html="$icon('inbox', 'w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-lg font-medium text-gray-600 dark:text-gray-400">No items found</p>
|
||||
<p class="text-sm text-gray-500" x-text="filters.search ? 'Try adjusting your search' : 'Create your first item'"></p>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
---
|
||||
|
||||
1. Implement the components page route
|
||||
2. Add menu item to sidebar
|
||||
3. Replace vendor-edit.html
|
||||
4. Add vendor-detail.html and .js
|
||||
5. Test all pages
|
||||
6. Apply same patterns to other admin pages (users, imports, etc.)
|
||||
## Modal Components
|
||||
|
||||
## Tips
|
||||
### Confirmation Modal
|
||||
|
||||
- Always reference `/admin/components` when building new pages
|
||||
- Copy component HTML directly from the components page
|
||||
- Maintain consistent spacing and styling
|
||||
- Use Alpine.js x-model for form bindings
|
||||
- Use your icon system with `x-html="$icon('icon-name', 'w-5 h-5')"`
|
||||
- Test in both light and dark modes
|
||||
```html
|
||||
<div
|
||||
x-show="showModal"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||
@click.self="showModal = false">
|
||||
|
||||
Enjoy your new component library! 🎨
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 m-4 max-w-md w-full">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
Confirm Action
|
||||
</h3>
|
||||
<button @click="showModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
Are you sure you want to perform this action?
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="showModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="confirmAction()"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Toast Notifications
|
||||
|
||||
Use the global Utils helper:
|
||||
|
||||
```javascript
|
||||
Utils.showToast('Operation successful!', 'success');
|
||||
Utils.showToast('Something went wrong', 'error');
|
||||
Utils.showToast('Please check your input', 'warning');
|
||||
Utils.showToast('Here is some information', 'info');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Grid Layouts
|
||||
|
||||
### 2 Columns (Desktop)
|
||||
|
||||
```html
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div>Column 1</div>
|
||||
<div>Column 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4 Columns (Stats Cards)
|
||||
|
||||
```html
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Stats cards here -->
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Color Reference
|
||||
|
||||
| Type | Primary | Success | Warning | Danger | Info |
|
||||
|------|---------|---------|---------|--------|------|
|
||||
| Background | `bg-purple-600` | `bg-green-600` | `bg-orange-600` | `bg-red-600` | `bg-blue-600` |
|
||||
| Text | `text-purple-600` | `text-green-600` | `text-orange-600` | `text-red-600` | `text-blue-600` |
|
||||
| Light BG | `bg-purple-100` | `bg-green-100` | `bg-orange-100` | `bg-red-100` | `bg-blue-100` |
|
||||
|
||||
---
|
||||
|
||||
## Common Icons
|
||||
|
||||
| Icon | Use Case |
|
||||
|------|----------|
|
||||
| `user-group` | Users/Teams |
|
||||
| `badge-check` | Verified |
|
||||
| `check-circle` | Success |
|
||||
| `x-circle` | Error/Inactive |
|
||||
| `clock` | Pending |
|
||||
| `calendar` | Dates |
|
||||
| `edit` | Edit |
|
||||
| `delete` | Delete |
|
||||
| `plus` | Add |
|
||||
| `arrow-left` | Back |
|
||||
| `exclamation` | Warning |
|
||||
| `spinner` | Loading |
|
||||
|
||||
---
|
||||
|
||||
## JavaScript Patterns
|
||||
|
||||
### List Page Structure
|
||||
|
||||
```javascript
|
||||
function adminResourceList() {
|
||||
return {
|
||||
...data(), // Inherit base layout
|
||||
currentPage: 'resource-name',
|
||||
|
||||
// State
|
||||
items: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
filters: { search: '', status: '' },
|
||||
stats: {},
|
||||
pagination: { page: 1, per_page: 10, total: 0 },
|
||||
|
||||
async init() {
|
||||
await this.loadItems();
|
||||
await this.loadStats();
|
||||
},
|
||||
|
||||
async loadItems() { /* ... */ },
|
||||
debouncedSearch() { /* ... */ },
|
||||
async deleteItem(item) { /* ... */ }
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Tailwind CSS](../tailwind-css.md)
|
||||
- [Tailwind CSS Official Docs](https://tailwindcss.com/docs)
|
||||
- [Alpine.js Official Docs](https://alpinejs.dev/)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Tailwind CSS Build Guide
|
||||
|
||||
**Version:** 1.0
|
||||
**Version:** 2.0
|
||||
**Last Updated:** December 2024
|
||||
**Audience:** Frontend Developers
|
||||
|
||||
@@ -8,17 +8,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The platform uses [Tailwind CSS](https://tailwindcss.com/) for styling with a **dual-layer architecture**:
|
||||
|
||||
1. **Base Layer (CDN):** Tailwind CSS v2.2.19 loaded via CDN with local fallback
|
||||
2. **Override Layer (npm build):** Custom Windmill Dashboard theme built from Tailwind v1.4.6
|
||||
|
||||
This layered approach allows:
|
||||
- Fast loading via CDN for base utilities
|
||||
- Custom theme extensions (colors, dark mode, forms) via npm build
|
||||
- Offline support via local fallback
|
||||
|
||||
> **Note:** A migration to Tailwind v3.4 is planned. See [Migration Plan](tailwind-migration-plan.md).
|
||||
The platform uses [Tailwind CSS v4](https://tailwindcss.com/) with the **Standalone CLI** - no Node.js required. Each frontend (admin, vendor, shop, platform) has its own dedicated CSS configuration.
|
||||
|
||||
---
|
||||
|
||||
@@ -27,180 +17,230 @@ This layered approach allows:
|
||||
### How It Works
|
||||
|
||||
```
|
||||
Browser loads:
|
||||
1. CDN Tailwind 2.2.19 (base utilities)
|
||||
└── Fallback: static/shared/css/tailwind.min.css
|
||||
|
||||
2. Custom tailwind.output.css (overrides/extensions)
|
||||
└── Built from: static/admin/css/tailwind.css
|
||||
└── Contains: Windmill Dashboard theme, custom colors, dark mode
|
||||
Tailwind Standalone CLI (single binary, no npm)
|
||||
│
|
||||
├── static/admin/css/tailwind.css → tailwind.output.css (Admin)
|
||||
├── static/vendor/css/tailwind.css → tailwind.output.css (Vendor)
|
||||
├── static/shop/css/tailwind.css → tailwind.output.css (Shop)
|
||||
└── static/platform/css/tailwind.css → tailwind.output.css (Platform)
|
||||
```
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Version | Purpose |
|
||||
|------|---------|---------|
|
||||
| CDN `tailwindcss@2.2.19` | 2.2.19 | Base Tailwind utilities |
|
||||
| `static/shared/css/tailwind.min.css` | 2.2.19 | Local fallback for CDN |
|
||||
| `tailwind.config.js` | 1.4.6 | Custom build configuration |
|
||||
| `static/admin/css/tailwind.css` | - | Source file with directives |
|
||||
| `static/admin/css/tailwind.output.css` | 1.4.6 | Compiled custom styles |
|
||||
| `static/vendor/css/tailwind.output.css` | 1.4.6 | Compiled custom styles |
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `~/.local/bin/tailwindcss` | Standalone CLI binary (v4.x) |
|
||||
| `static/*/css/tailwind.css` | CSS-first source configuration |
|
||||
| `static/*/css/tailwind.output.css` | Compiled output (do not edit) |
|
||||
|
||||
### Template Loading Order
|
||||
### Template Loading
|
||||
|
||||
Each frontend loads its own CSS:
|
||||
|
||||
```html
|
||||
<!-- 1. Base Tailwind from CDN (with fallback) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='/static/shared/css/tailwind.min.css';">
|
||||
<!-- Admin -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
|
||||
|
||||
<!-- 2. Custom overrides (built via npm) -->
|
||||
<link rel="stylesheet" href="/static/admin/css/tailwind.output.css" />
|
||||
<!-- Vendor -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='vendor/css/tailwind.output.css') }}" />
|
||||
|
||||
<!-- Shop -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}" />
|
||||
|
||||
<!-- Platform -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='platform/css/tailwind.output.css') }}" />
|
||||
```
|
||||
|
||||
See [CDN Fallback Strategy](cdn-fallback-strategy.md) for details on offline support.
|
||||
|
||||
---
|
||||
|
||||
## How Tailwind Purging Works
|
||||
## CSS-First Configuration (Tailwind v4)
|
||||
|
||||
Tailwind generates thousands of utility classes. To keep the CSS file small, it "purges" (removes) unused classes by scanning your source files.
|
||||
Tailwind v4 uses **CSS-first configuration** instead of `tailwind.config.js`. All customization happens directly in CSS files.
|
||||
|
||||
### Content Paths
|
||||
### Source File Structure
|
||||
|
||||
The `purge.content` array in `tailwind.config.js` tells Tailwind where to look for class usage:
|
||||
```css
|
||||
/* static/admin/css/tailwind.css */
|
||||
|
||||
```javascript
|
||||
purge: {
|
||||
content: [
|
||||
'public/**/*.html',
|
||||
'app/templates/**/*.html', // Jinja2 templates
|
||||
'static/**/*.js', // Alpine.js components
|
||||
],
|
||||
// ...
|
||||
/* Import Tailwind */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Content sources for tree-shaking */
|
||||
@source "../../../app/templates/admin/**/*.html";
|
||||
@source "../../js/**/*.js";
|
||||
|
||||
/* Custom theme (colors, fonts, spacing) */
|
||||
@theme {
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-900: #121317;
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Dark mode variant */
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Custom utilities */
|
||||
@layer utilities {
|
||||
.shadow-outline-purple {
|
||||
box-shadow: 0 0 0 3px hsla(262, 97%, 81%, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom components */
|
||||
@layer components {
|
||||
.form-input { ... }
|
||||
.btn-primary { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Safelist
|
||||
### Key Directives
|
||||
|
||||
Some classes are used dynamically in Alpine.js expressions and can't be detected by scanning. These must be added to the `safelist`:
|
||||
| Directive | Purpose | Example |
|
||||
|-----------|---------|---------|
|
||||
| `@import "tailwindcss"` | Import Tailwind base | Required at top |
|
||||
| `@source` | Content paths for purging | `@source "../../../app/templates/**/*.html"` |
|
||||
| `@theme` | Custom design tokens | `--color-purple-600: #7e3af2;` |
|
||||
| `@variant` | Custom variants | `@variant dark (&:where(.dark, .dark *))` |
|
||||
| `@layer utilities` | Custom utility classes | `.shadow-outline-*` |
|
||||
| `@layer components` | Custom components | `.form-input`, `.btn-*` |
|
||||
|
||||
```javascript
|
||||
purge: {
|
||||
content: [...],
|
||||
safelist: [
|
||||
'bg-orange-600',
|
||||
'bg-green-600',
|
||||
'bg-red-600',
|
||||
'hover:bg-orange-700',
|
||||
'hover:bg-green-700',
|
||||
'hover:bg-red-700',
|
||||
],
|
||||
---
|
||||
|
||||
## Custom Color Palette
|
||||
|
||||
All frontends share the same Windmill Dashboard color palette:
|
||||
|
||||
```css
|
||||
@theme {
|
||||
/* Gray (custom darker palette) */
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f4f5f7;
|
||||
--color-gray-200: #e5e7eb;
|
||||
--color-gray-300: #d5d6d7;
|
||||
--color-gray-400: #9e9e9e;
|
||||
--color-gray-500: #707275;
|
||||
--color-gray-600: #4c4f52;
|
||||
--color-gray-700: #24262d;
|
||||
--color-gray-800: #1a1c23;
|
||||
--color-gray-900: #121317;
|
||||
|
||||
/* Purple (primary) */
|
||||
--color-purple-600: #7e3af2;
|
||||
|
||||
/* Plus: red, orange, yellow, green, teal, blue, indigo, pink, cool-gray */
|
||||
}
|
||||
```
|
||||
|
||||
**When to add to safelist:**
|
||||
|
||||
- Classes used in Alpine.js `:class` bindings with dynamic conditions
|
||||
- Classes constructed from variables (e.g., `bg-${color}-500`)
|
||||
- Classes used only in JavaScript, not in HTML templates
|
||||
|
||||
---
|
||||
|
||||
## Building Tailwind CSS
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Install Node.js dependencies (one-time setup):
|
||||
Install the standalone CLI (one-time setup):
|
||||
|
||||
```bash
|
||||
npm install
|
||||
make tailwind-install
|
||||
```
|
||||
|
||||
Or using Make:
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
make npm-install
|
||||
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64
|
||||
chmod +x tailwindcss-linux-x64
|
||||
mv tailwindcss-linux-x64 ~/.local/bin/tailwindcss
|
||||
```
|
||||
|
||||
### Development Build
|
||||
|
||||
For development, build without purging (includes all classes, larger file):
|
||||
Build all frontends (includes all classes, larger files):
|
||||
|
||||
```bash
|
||||
# Build admin CSS
|
||||
npm run tailwind:admin
|
||||
|
||||
# Build vendor CSS
|
||||
npm run tailwind:vendor
|
||||
|
||||
# Or using Make
|
||||
make tailwind-dev
|
||||
```
|
||||
|
||||
### Production Build
|
||||
|
||||
For production, build with purging and minification (smaller file):
|
||||
Build with minification (smaller files):
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
|
||||
# Or using Make
|
||||
make tailwind-build
|
||||
```
|
||||
|
||||
---
|
||||
### Watch Mode
|
||||
|
||||
## Available npm Scripts
|
||||
Watch for changes during development:
|
||||
|
||||
| Script | Command | Description |
|
||||
|--------|---------|-------------|
|
||||
| `npm run tailwind:admin` | Build admin CSS (dev) | Fast, includes all classes |
|
||||
| `npm run tailwind:vendor` | Build vendor CSS (dev) | Fast, includes all classes |
|
||||
| `npm run build:admin` | Build admin CSS (prod) | Purged and minified |
|
||||
| `npm run build:vendor` | Build vendor CSS (prod) | Purged and minified |
|
||||
| `npm run build` | Build all (prod) | Runs both production builds |
|
||||
|
||||
---
|
||||
|
||||
## Adding New Utility Classes
|
||||
|
||||
If you need a class that doesn't exist in the compiled CSS:
|
||||
|
||||
### Option 1: Check if it's being purged
|
||||
|
||||
The class might exist but is being removed. Add it to the `safelist` in `tailwind.config.js`:
|
||||
|
||||
```javascript
|
||||
safelist: [
|
||||
'your-new-class',
|
||||
// ...
|
||||
]
|
||||
```bash
|
||||
make tailwind-watch
|
||||
```
|
||||
|
||||
### Option 2: Extend the theme
|
||||
---
|
||||
|
||||
Add custom values to `tailwind.config.js`:
|
||||
## Makefile Targets
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `make tailwind-install` | Install Tailwind standalone CLI |
|
||||
| `make tailwind-dev` | Build all CSS (development) |
|
||||
| `make tailwind-build` | Build all CSS (production, minified) |
|
||||
| `make tailwind-watch` | Watch for changes |
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode
|
||||
|
||||
The platform uses class-based dark mode with the `.dark` class on the `<html>` element:
|
||||
|
||||
```html
|
||||
<html :class="{ 'dark': dark }">
|
||||
<body class="bg-white dark:bg-gray-900">
|
||||
```
|
||||
|
||||
Toggle dark mode with JavaScript:
|
||||
|
||||
```javascript
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'brand': '#123456',
|
||||
},
|
||||
spacing: {
|
||||
'128': '32rem',
|
||||
},
|
||||
},
|
||||
document.documentElement.classList.toggle('dark');
|
||||
localStorage.setItem('darkMode', dark);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding Custom Utilities
|
||||
|
||||
Add custom utilities in the source CSS file:
|
||||
|
||||
```css
|
||||
@layer utilities {
|
||||
.shadow-outline-purple {
|
||||
box-shadow: 0 0 0 3px hsla(262, 97%, 81%, 0.45);
|
||||
}
|
||||
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Option 3: Rebuild CSS
|
||||
---
|
||||
|
||||
After any config change, rebuild the CSS:
|
||||
## Adding Custom Components
|
||||
|
||||
```bash
|
||||
make tailwind-dev
|
||||
Add reusable component classes:
|
||||
|
||||
```css
|
||||
@layer components {
|
||||
.form-input {
|
||||
@apply block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm;
|
||||
@apply focus:border-purple-500 focus:ring-1 focus:ring-purple-500;
|
||||
@apply dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg;
|
||||
@apply hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -209,105 +249,51 @@ make tailwind-dev
|
||||
|
||||
### Classes not appearing
|
||||
|
||||
1. **Check purge paths** - Ensure your file is in a scanned directory
|
||||
2. **Check safelist** - Dynamic classes need to be safelisted
|
||||
1. **Check @source paths** - Ensure your templates are in a scanned directory
|
||||
2. **Check class exists** - Tailwind v4 may have renamed some classes
|
||||
3. **Rebuild CSS** - Run `make tailwind-dev` after config changes
|
||||
|
||||
### Dynamic classes in Alpine.js
|
||||
|
||||
**Problem:** Classes in `:class` bindings may be purged.
|
||||
Tailwind v4 uses JIT compilation and scans for complete class names. Dynamic classes work automatically if the full class name appears in your templates:
|
||||
|
||||
```html
|
||||
<!-- This class might be purged -->
|
||||
<!-- This works - full class names visible -->
|
||||
<div :class="isActive ? 'bg-green-600' : 'bg-red-600'">
|
||||
```
|
||||
|
||||
**Solution:** Add to safelist:
|
||||
### Class name changes (v2 → v4)
|
||||
|
||||
```javascript
|
||||
safelist: ['bg-green-600', 'bg-red-600']
|
||||
```
|
||||
|
||||
### Large CSS file in development
|
||||
|
||||
Development builds include all Tailwind classes (~1.2MB). This is normal.
|
||||
|
||||
Production builds purge unused classes, resulting in much smaller files (~50-100KB typically).
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### tailwind.config.js Structure
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
// Where to scan for class usage
|
||||
purge: {
|
||||
content: [...],
|
||||
safelist: [...],
|
||||
},
|
||||
|
||||
// Theme customization
|
||||
theme: {
|
||||
// Override defaults
|
||||
colors: {...},
|
||||
|
||||
// Extend defaults
|
||||
extend: {
|
||||
fontFamily: {...},
|
||||
maxHeight: {...},
|
||||
},
|
||||
},
|
||||
|
||||
// Variant configuration (hover, focus, dark mode, etc.)
|
||||
variants: {
|
||||
backgroundColor: ['hover', 'focus', 'dark', 'dark:hover'],
|
||||
textColor: ['hover', 'dark'],
|
||||
// ...
|
||||
},
|
||||
|
||||
// Plugins
|
||||
plugins: [
|
||||
require('tailwindcss-multi-theme'),
|
||||
require('@tailwindcss/custom-forms'),
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Dark Mode
|
||||
|
||||
The platform uses `tailwindcss-multi-theme` for dark mode. Dark mode classes use the `.theme-dark` parent selector:
|
||||
|
||||
```css
|
||||
/* Light mode */
|
||||
.bg-white { ... }
|
||||
|
||||
/* Dark mode */
|
||||
.theme-dark .dark\:bg-gray-800 { ... }
|
||||
```
|
||||
| v2.x | v4.x |
|
||||
|------|------|
|
||||
| `overflow-ellipsis` | `text-ellipsis` |
|
||||
| `flex-grow-0` | `grow-0` |
|
||||
| `flex-shrink-0` | `shrink-0` |
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always rebuild after config changes**
|
||||
1. **Always rebuild after CSS changes**
|
||||
```bash
|
||||
make tailwind-dev
|
||||
```
|
||||
|
||||
2. **Add dynamic classes to safelist** to prevent purging
|
||||
2. **Use production builds for deployment**
|
||||
```bash
|
||||
make tailwind-build
|
||||
```
|
||||
|
||||
3. **Use production builds for deployment** to minimize file size
|
||||
3. **Keep each frontend's CSS isolated** - Don't cross-import between frontends
|
||||
|
||||
4. **Check existing classes first** before adding custom ones - Tailwind likely has what you need
|
||||
4. **Use @apply sparingly** - Prefer utility classes in templates when possible
|
||||
|
||||
5. **Use consistent color scales** (e.g., `purple-600`, `purple-700`) for hover states
|
||||
5. **Test in both light and dark modes**
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Frontend Overview](overview.md)
|
||||
- [UI Components](shared/ui-components.md)
|
||||
- [Tailwind CSS Official Docs](https://tailwindcss.com/docs)
|
||||
- [Tailwind CSS v4 Blog Post](https://tailwindcss.com/blog/tailwindcss-v4)
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
# Tailwind CSS Migration Plan: v1.4/v2.2 → v3.4
|
||||
|
||||
**Created:** December 2024
|
||||
**Status:** Planned
|
||||
**Estimated Time:** 2-3 hours
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### Two Tailwind Setups
|
||||
|
||||
| Component | Version | Source | Purpose |
|
||||
|-----------|---------|--------|---------|
|
||||
| Base styles | 2.2.19 | CDN + local fallback | Core Tailwind utilities for all frontends |
|
||||
| Custom overrides | 1.4.6 | npm build | Windmill Dashboard theme (admin/vendor) |
|
||||
|
||||
### Current Files
|
||||
|
||||
```
|
||||
package.json # tailwindcss: 1.4.6
|
||||
tailwind.config.js # v1.4 format config
|
||||
static/shared/css/tailwind.min.css # CDN fallback (v2.2.19)
|
||||
static/admin/css/tailwind.output.css # Built overrides
|
||||
static/vendor/css/tailwind.output.css # Built overrides
|
||||
```
|
||||
|
||||
### Current Plugins
|
||||
|
||||
```json
|
||||
{
|
||||
"@tailwindcss/custom-forms": "0.2.1", // DEPRECATED in v3
|
||||
"tailwindcss-multi-theme": "1.0.3" // May not work with v3
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Goals
|
||||
|
||||
1. Upgrade npm Tailwind to v3.4.x (latest stable)
|
||||
2. Upgrade CDN Tailwind to v3.4.x
|
||||
3. Update local fallback file
|
||||
4. Replace deprecated plugins
|
||||
5. Update config to v3 format
|
||||
6. Verify all frontends work correctly
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Migration
|
||||
|
||||
### Phase 1: Backup Current State
|
||||
|
||||
```bash
|
||||
# Create backup branch
|
||||
git checkout -b backup/tailwind-v1.4
|
||||
git add -A
|
||||
git commit -m "Backup before Tailwind v3 migration"
|
||||
git checkout master
|
||||
|
||||
# Create migration branch
|
||||
git checkout -b feat/tailwind-v3-upgrade
|
||||
```
|
||||
|
||||
### Phase 2: Update npm Dependencies
|
||||
|
||||
```bash
|
||||
# Remove old packages
|
||||
npm uninstall tailwindcss tailwindcss-multi-theme @tailwindcss/custom-forms
|
||||
|
||||
# Install new packages
|
||||
npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
|
||||
npm install -D @tailwindcss/forms @tailwindcss/typography
|
||||
|
||||
# Verify versions
|
||||
npm list tailwindcss
|
||||
```
|
||||
|
||||
**Expected versions:**
|
||||
- tailwindcss: ^3.4.x
|
||||
- postcss: ^8.4.x
|
||||
- autoprefixer: ^10.4.x
|
||||
|
||||
### Phase 3: Update tailwind.config.js
|
||||
|
||||
Replace the entire config file:
|
||||
|
||||
```javascript
|
||||
// tailwind.config.js - Tailwind CSS v3.4
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
// v3: 'content' replaces 'purge'
|
||||
content: [
|
||||
'app/templates/**/*.html',
|
||||
'static/**/*.js',
|
||||
],
|
||||
|
||||
// v3: safelist for dynamic classes
|
||||
safelist: [
|
||||
'bg-orange-600',
|
||||
'bg-green-600',
|
||||
'bg-red-600',
|
||||
'hover:bg-orange-700',
|
||||
'hover:bg-green-700',
|
||||
'hover:bg-red-700',
|
||||
// Add any other dynamic classes used in Alpine.js
|
||||
],
|
||||
|
||||
// v3: darkMode replaces tailwindcss-multi-theme
|
||||
darkMode: 'class', // or 'media' for OS preference
|
||||
|
||||
theme: {
|
||||
extend: {
|
||||
// Custom colors from Windmill Dashboard
|
||||
colors: {
|
||||
gray: {
|
||||
50: '#f9fafb',
|
||||
100: '#f4f5f7',
|
||||
200: '#e5e7eb',
|
||||
300: '#d5d6d7',
|
||||
400: '#9e9e9e',
|
||||
500: '#707275',
|
||||
600: '#4c4f52',
|
||||
700: '#24262d',
|
||||
800: '#1a1c23',
|
||||
900: '#121317',
|
||||
},
|
||||
purple: {
|
||||
50: '#f6f5ff',
|
||||
100: '#edebfe',
|
||||
200: '#dcd7fe',
|
||||
300: '#cabffd',
|
||||
400: '#ac94fa',
|
||||
500: '#9061f9',
|
||||
600: '#7e3af2',
|
||||
700: '#6c2bd9',
|
||||
800: '#5521b5',
|
||||
900: '#4a1d96',
|
||||
},
|
||||
// Add other custom colors as needed
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
maxHeight: {
|
||||
'xl': '36rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'), // Replaces @tailwindcss/custom-forms
|
||||
require('@tailwindcss/typography'), // Optional: for prose content
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: Update CSS Source File
|
||||
|
||||
Update `static/admin/css/tailwind.css`:
|
||||
|
||||
```css
|
||||
/* Tailwind CSS v3 directives */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom component classes if needed */
|
||||
@layer components {
|
||||
.form-input {
|
||||
@apply block w-full rounded-md border-gray-300 shadow-sm
|
||||
focus:border-purple-500 focus:ring-purple-500;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
@apply block w-full rounded-md border-gray-300 shadow-sm
|
||||
focus:border-purple-500 focus:ring-purple-500;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
@apply rounded border-gray-300 text-purple-600
|
||||
focus:ring-purple-500;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5: Update postcss.config.js
|
||||
|
||||
```javascript
|
||||
// postcss.config.js
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
...(process.env.NODE_ENV === 'production' ? { cssnano: {} } : {})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 6: Update package.json Scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"tailwind:admin": "npx tailwindcss -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css",
|
||||
"tailwind:vendor": "npx tailwindcss -i static/admin/css/tailwind.css -o static/vendor/css/tailwind.output.css",
|
||||
"tailwind:watch": "npx tailwindcss -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --watch",
|
||||
"build:admin": "npx tailwindcss -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --minify",
|
||||
"build:vendor": "npx tailwindcss -i static/admin/css/tailwind.css -o static/vendor/css/tailwind.output.css --minify",
|
||||
"build": "npm run build:admin && npm run build:vendor"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 7: Update Dark Mode Implementation
|
||||
|
||||
**Old approach (tailwindcss-multi-theme):**
|
||||
```html
|
||||
<body class="theme-dark">
|
||||
<div class="bg-white dark:bg-gray-800">
|
||||
```
|
||||
|
||||
**New approach (Tailwind v3 native):**
|
||||
```html
|
||||
<body class="dark">
|
||||
<div class="bg-white dark:bg-gray-800">
|
||||
```
|
||||
|
||||
**Files to update:**
|
||||
1. `app/templates/admin/base.html` - Change `theme-dark` to `dark`
|
||||
2. `app/templates/vendor/base.html` - Change `theme-dark` to `dark`
|
||||
3. `static/admin/js/init-alpine.js` - Update dark mode toggle logic
|
||||
4. `static/vendor/js/init-alpine.js` - Update dark mode toggle logic
|
||||
|
||||
**JavaScript update example:**
|
||||
```javascript
|
||||
// Old
|
||||
document.body.classList.toggle('theme-dark');
|
||||
|
||||
// New
|
||||
document.documentElement.classList.toggle('dark');
|
||||
```
|
||||
|
||||
### Phase 8: Update CDN and Local Fallback
|
||||
|
||||
**Option A: Use Tailwind Play CDN (development only)**
|
||||
```html
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
```
|
||||
|
||||
**Option B: Build and serve locally (recommended for production)**
|
||||
|
||||
Since Tailwind v3 doesn't have a pre-built CDN CSS file (it's JIT-only), the recommended approach is:
|
||||
|
||||
1. Remove CDN loading from templates
|
||||
2. Load only the built `tailwind.output.css`
|
||||
3. Include all needed utilities in your build
|
||||
|
||||
**Update base templates:**
|
||||
```html
|
||||
<!-- OLD: CDN with fallback -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
|
||||
|
||||
<!-- NEW: Local only (v3) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
|
||||
```
|
||||
|
||||
### Phase 9: Build and Test
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build CSS
|
||||
npm run tailwind:admin
|
||||
npm run tailwind:vendor
|
||||
|
||||
# Start development server
|
||||
make dev
|
||||
|
||||
# Test all frontends:
|
||||
# - http://localhost:8000/admin/dashboard
|
||||
# - http://localhost:8000/vendor/{code}/dashboard
|
||||
# - http://localhost:8000/ (shop)
|
||||
```
|
||||
|
||||
### Phase 10: Verify Checklist
|
||||
|
||||
- [ ] Admin dashboard loads correctly
|
||||
- [ ] Vendor dashboard loads correctly
|
||||
- [ ] Shop frontend loads correctly
|
||||
- [ ] Dark mode toggle works
|
||||
- [ ] All buttons have correct colors
|
||||
- [ ] Forms display correctly (inputs, selects, checkboxes)
|
||||
- [ ] Responsive design works (mobile/tablet/desktop)
|
||||
- [ ] No console errors
|
||||
- [ ] Production build works (`npm run build`)
|
||||
|
||||
---
|
||||
|
||||
## Breaking Changes Reference
|
||||
|
||||
### Class Name Changes (v2 → v3)
|
||||
|
||||
| v2.x | v3.x | Notes |
|
||||
|------|------|-------|
|
||||
| `overflow-ellipsis` | `text-ellipsis` | Renamed |
|
||||
| `flex-grow-0` | `grow-0` | Shortened |
|
||||
| `flex-shrink-0` | `shrink-0` | Shortened |
|
||||
| `decoration-clone` | `box-decoration-clone` | Renamed |
|
||||
|
||||
### Plugin Changes
|
||||
|
||||
| Old Plugin | New Plugin | Notes |
|
||||
|------------|------------|-------|
|
||||
| `@tailwindcss/custom-forms` | `@tailwindcss/forms` | Complete rewrite |
|
||||
| `tailwindcss-multi-theme` | Built-in `darkMode: 'class'` | Native support |
|
||||
|
||||
### Config Changes
|
||||
|
||||
| v1.x/v2.x | v3.x |
|
||||
|-----------|------|
|
||||
| `purge: [...]` | `content: [...]` |
|
||||
| `variants: {...}` | Removed (JIT generates all) |
|
||||
| `mode: 'jit'` | Default (not needed) |
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise:
|
||||
|
||||
```bash
|
||||
# Switch back to backup branch
|
||||
git checkout backup/tailwind-v1.4
|
||||
|
||||
# Or reset changes
|
||||
git checkout master
|
||||
git reset --hard HEAD~1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Post-Migration Tasks
|
||||
|
||||
1. Update `docs/frontend/tailwind-css.md` with v3 information
|
||||
2. Update `docs/frontend/cdn-fallback-strategy.md` (or remove CDN references)
|
||||
3. Remove `static/shared/css/tailwind.min.css` if no longer needed
|
||||
4. Update any documentation referencing old class names
|
||||
5. Consider adding Tailwind IntelliSense VS Code extension config
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- [Tailwind CSS v3 Upgrade Guide](https://tailwindcss.com/docs/upgrade-guide)
|
||||
- [Tailwind CSS v3 Documentation](https://tailwindcss.com/docs)
|
||||
- [@tailwindcss/forms Plugin](https://github.com/tailwindlabs/tailwindcss-forms)
|
||||
- [Dark Mode in Tailwind v3](https://tailwindcss.com/docs/dark-mode)
|
||||
@@ -1,387 +0,0 @@
|
||||
# UI Components
|
||||
|
||||
This document describes the reusable UI components and patterns used in the Wizamart admin panel.
|
||||
|
||||
## Page Layout Structure
|
||||
|
||||
All admin list pages follow a consistent structure:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Page Header (Title + Action Button) │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Stats Cards (4 columns on desktop) │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Search & Filters Bar │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Data Table │
|
||||
│ ├── Table Header │
|
||||
│ ├── Table Rows │
|
||||
│ └── Pagination │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Page Header
|
||||
|
||||
The page header contains the page title and primary action button.
|
||||
|
||||
```html
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Page Title
|
||||
</h2>
|
||||
<a href="/admin/resource/create"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||
Create Resource
|
||||
</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Stats Cards
|
||||
|
||||
Stats cards display key metrics in a 4-column grid layout.
|
||||
|
||||
### Structure
|
||||
|
||||
```html
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Card Template -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-{color}-500 bg-{color}-100 rounded-full dark:text-{color}-100 dark:bg-{color}-500">
|
||||
<span x-html="$icon('icon-name', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Label
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="value">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Color Options
|
||||
|
||||
| Color | Use Case | Example |
|
||||
|-------|----------|---------|
|
||||
| `blue` | Total counts | Total Users, Total Companies |
|
||||
| `green` | Positive status | Active, Verified |
|
||||
| `red` | Negative status | Inactive, Errors |
|
||||
| `orange` | Special/Admin | Admin users, Warnings |
|
||||
| `purple` | Primary/Vendors | Active vendors, Main metrics |
|
||||
|
||||
### Icon Style
|
||||
|
||||
- Icons should be inside a **circular** container (`rounded-full`)
|
||||
- Icon size: `w-5 h-5`
|
||||
- Container padding: `p-3`
|
||||
|
||||
## Search & Filters Bar
|
||||
|
||||
The search and filters bar provides filtering capabilities for list pages.
|
||||
|
||||
### Structure
|
||||
|
||||
```html
|
||||
<div class="mb-6 px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<!-- Search Input -->
|
||||
<div class="flex-1 max-w-md">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.search"
|
||||
@input="debouncedSearch()"
|
||||
placeholder="Search..."
|
||||
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Dropdowns -->
|
||||
<div class="flex gap-3">
|
||||
<select
|
||||
x-model="filters.filterName"
|
||||
@change="loadData()"
|
||||
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="">All Options</option>
|
||||
<option value="value1">Option 1</option>
|
||||
<option value="value2">Option 2</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### JavaScript Implementation
|
||||
|
||||
```javascript
|
||||
// State
|
||||
filters: {
|
||||
search: '',
|
||||
status: '',
|
||||
type: ''
|
||||
},
|
||||
|
||||
// Debounced search function
|
||||
debouncedSearch() {
|
||||
if (this._searchTimeout) {
|
||||
clearTimeout(this._searchTimeout);
|
||||
}
|
||||
this._searchTimeout = setTimeout(() => {
|
||||
this.pagination.page = 1; // Reset to first page
|
||||
this.loadData();
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// Load data with filters
|
||||
async loadData() {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', this.pagination.page);
|
||||
params.append('per_page', this.pagination.per_page);
|
||||
|
||||
if (this.filters.search) {
|
||||
params.append('search', this.filters.search);
|
||||
}
|
||||
if (this.filters.status) {
|
||||
params.append('status', this.filters.status);
|
||||
}
|
||||
|
||||
const response = await apiClient.get(`/admin/resource?${params}`);
|
||||
// Handle response...
|
||||
}
|
||||
```
|
||||
|
||||
### Backend API Support
|
||||
|
||||
The API endpoint should support these query parameters:
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `page` | int | Page number (1-based) |
|
||||
| `per_page` | int | Items per page |
|
||||
| `search` | string | Search term (searches multiple fields) |
|
||||
| `{filter}` | string | Filter by specific field |
|
||||
|
||||
Example API implementation:
|
||||
|
||||
```python
|
||||
@router.get("", response_model=ListResponse)
|
||||
def get_all(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(10, ge=1, le=100),
|
||||
search: str = Query("", description="Search term"),
|
||||
status: str = Query("", description="Filter by status"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(Model)
|
||||
|
||||
# Apply search
|
||||
if search:
|
||||
search_term = f"%{search.lower()}%"
|
||||
query = query.filter(
|
||||
(Model.name.ilike(search_term)) |
|
||||
(Model.email.ilike(search_term))
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if status:
|
||||
query = query.filter(Model.status == status)
|
||||
|
||||
# Pagination
|
||||
total = query.count()
|
||||
items = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
return ListResponse(items=items, total=total, page=page, ...)
|
||||
```
|
||||
|
||||
## Data Table
|
||||
|
||||
### Structure
|
||||
|
||||
```html
|
||||
<div class="w-full overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Column</th>
|
||||
<!-- More columns -->
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="item in items" :key="item.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<!-- Columns -->
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<!-- Pagination controls -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Action Buttons
|
||||
|
||||
Standard action buttons for table rows:
|
||||
|
||||
```html
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<!-- View -->
|
||||
<a :href="'/admin/resource/' + item.id"
|
||||
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-700"
|
||||
title="View">
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
|
||||
<!-- Edit -->
|
||||
<a :href="'/admin/resource/' + item.id + '/edit'"
|
||||
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700"
|
||||
title="Edit">
|
||||
<span x-html="$icon('edit', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
|
||||
<!-- Delete -->
|
||||
<button @click="deleteItem(item)"
|
||||
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700"
|
||||
title="Delete">
|
||||
<span x-html="$icon('delete', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Status Badges
|
||||
|
||||
```html
|
||||
<!-- Active/Inactive -->
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="item.is_active
|
||||
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
|
||||
: 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
|
||||
x-text="item.is_active ? 'Active' : 'Inactive'">
|
||||
</span>
|
||||
|
||||
<!-- Role Badge -->
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs capitalize"
|
||||
:class="{
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': item.role === 'admin',
|
||||
'text-purple-700 bg-purple-100 dark:bg-purple-700 dark:text-purple-100': item.role === 'vendor'
|
||||
}"
|
||||
x-text="item.role">
|
||||
</span>
|
||||
```
|
||||
|
||||
## Loading & Error States
|
||||
|
||||
### Loading State
|
||||
|
||||
```html
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Error State
|
||||
|
||||
```html
|
||||
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error loading data</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Empty State
|
||||
|
||||
```html
|
||||
<template x-if="items.length === 0">
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<span x-html="$icon('inbox', 'w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-lg font-medium">No items found</p>
|
||||
<p class="text-sm" x-text="filters.search ? 'Try adjusting your search' : 'Create your first item'"></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Pages Using These Components
|
||||
|
||||
| Page | Stats | Search | Filters |
|
||||
|------|-------|--------|---------|
|
||||
| `/admin/users` | Total, Active, Inactive, Admins | Name, Email, Username | Role, Status |
|
||||
| `/admin/companies` | Total, Verified, Active, Vendors | Name, Email, Owner | Status |
|
||||
| `/admin/vendors` | Total, Verified, Active, Products | Name, Code, Subdomain | Status, Company |
|
||||
|
||||
## JavaScript Module Structure
|
||||
|
||||
Each list page follows this pattern:
|
||||
|
||||
```javascript
|
||||
function adminResourceList() {
|
||||
return {
|
||||
// Inherit base layout
|
||||
...data(),
|
||||
|
||||
// Page identifier (for sidebar highlighting)
|
||||
currentPage: 'resource-name',
|
||||
|
||||
// State
|
||||
items: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
filters: { search: '', status: '' },
|
||||
stats: {},
|
||||
pagination: { page: 1, per_page: 10, total: 0, pages: 0 },
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
await this.loadItems();
|
||||
await this.loadStats();
|
||||
},
|
||||
|
||||
// Format helpers
|
||||
formatDate(dateString) {
|
||||
return dateString ? Utils.formatDate(dateString) : '-';
|
||||
},
|
||||
|
||||
// Data loading
|
||||
async loadItems() { /* ... */ },
|
||||
async loadStats() { /* ... */ },
|
||||
|
||||
// Search & filters
|
||||
debouncedSearch() { /* ... */ },
|
||||
|
||||
// Pagination
|
||||
nextPage() { /* ... */ },
|
||||
previousPage() { /* ... */ },
|
||||
|
||||
// Actions
|
||||
async deleteItem(item) { /* ... */ }
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Icons Reference](./icons.md)
|
||||
- [Alpine.js Integration](./alpine-integration.md)
|
||||
- [Tailwind CSS](./tailwind-css.md)
|
||||
@@ -564,11 +564,9 @@ def require_vendor_context():
|
||||
def dependency(request: Request):
|
||||
vendor = get_current_vendor(request)
|
||||
if not vendor:
|
||||
from fastapi import HTTPException
|
||||
from app.exceptions import VendorNotFoundException
|
||||
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Vendor not found or not active"
|
||||
)
|
||||
raise VendorNotFoundException("unknown", identifier_type="context")
|
||||
return vendor
|
||||
|
||||
return dependency
|
||||
|
||||
10
mkdocs.yml
10
mkdocs.yml
@@ -40,6 +40,7 @@ nav:
|
||||
- API Consolidation:
|
||||
- Proposal: architecture/api-consolidation-proposal.md
|
||||
- Migration Status: architecture/api-migration-status.md
|
||||
- Architecture Violations Status: architecture/architecture-violations-status.md
|
||||
- Diagrams:
|
||||
- Multi-Tenant Diagrams: architecture/diagrams/multitenant-diagrams.md
|
||||
- Vendor Domain Diagrams: architecture/diagrams/vendor-domain-diagrams.md
|
||||
@@ -84,7 +85,6 @@ nav:
|
||||
- Overview: frontend/overview.md
|
||||
- CDN Fallback Strategy: frontend/cdn-fallback-strategy.md
|
||||
- Tailwind CSS Build: frontend/tailwind-css.md
|
||||
- Tailwind v3 Migration Plan: frontend/tailwind-migration-plan.md
|
||||
- Shared Components:
|
||||
- UI Components: frontend/shared/ui-components.md
|
||||
- UI Components Quick Reference: frontend/shared/ui-components-quick-reference.md
|
||||
@@ -118,7 +118,13 @@ nav:
|
||||
- Customer Authentication:
|
||||
- Implementation Guide: development/customer-authentication-implementation.md
|
||||
- Quick Summary: development/customer-auth-summary.md
|
||||
- Database Migrations: development/migration/database-migrations.md
|
||||
- Migrations:
|
||||
- Database Migrations: development/migration/database-migrations.md
|
||||
- Tailwind CSS Migration: development/migration/tailwind-migration-plan.md
|
||||
- Makefile Refactoring: development/migration/makefile-refactoring-complete.md
|
||||
- SVC-006 Migration Plan: development/migration/svc-006-migration-plan.md
|
||||
- Vendor Contact Inheritance: development/migration/vendor-contact-inheritance.md
|
||||
- Seed Scripts Audit: development/seed-scripts-audit.md
|
||||
- Database Seeder:
|
||||
- Documentation: development/database-seeder/database-seeder-documentation.md
|
||||
- Makefile Guide: development/database-seeder/makefile-database-seeder.md
|
||||
|
||||
@@ -513,3 +513,38 @@ class FileLogResponse(BaseModel):
|
||||
last_modified: datetime
|
||||
lines: list[str]
|
||||
total_lines: int
|
||||
|
||||
|
||||
class LogFileInfo(BaseModel):
|
||||
"""Log file info for listing."""
|
||||
|
||||
filename: str
|
||||
size_bytes: int
|
||||
last_modified: datetime
|
||||
|
||||
|
||||
class LogFileListResponse(BaseModel):
|
||||
"""Response for listing log files."""
|
||||
|
||||
files: list[LogFileInfo]
|
||||
|
||||
|
||||
class LogDeleteResponse(BaseModel):
|
||||
"""Response for log deletion."""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
class LogCleanupResponse(BaseModel):
|
||||
"""Response for log cleanup operation."""
|
||||
|
||||
message: str
|
||||
deleted_count: int
|
||||
|
||||
|
||||
class LogSettingsUpdateResponse(BaseModel):
|
||||
"""Response for log settings update."""
|
||||
|
||||
message: str
|
||||
updated_fields: list[str]
|
||||
note: str | None = None
|
||||
|
||||
@@ -121,3 +121,59 @@ class UserListResponse(BaseModel):
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class UserSearchItem(BaseModel):
|
||||
"""Schema for a single user search result."""
|
||||
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class UserSearchResponse(BaseModel):
|
||||
"""Schema for user search results."""
|
||||
|
||||
users: list[UserSearchItem]
|
||||
|
||||
|
||||
class UserStatusToggleResponse(BaseModel):
|
||||
"""Schema for user status toggle response."""
|
||||
|
||||
message: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class UserDeleteResponse(BaseModel):
|
||||
"""Schema for user delete response."""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
class LogoutResponse(BaseModel):
|
||||
"""Schema for logout response."""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
class PasswordResetRequestResponse(BaseModel):
|
||||
"""Schema for password reset request response."""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
class PasswordResetResponse(BaseModel):
|
||||
"""Schema for password reset response."""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
class VendorUserResponse(BaseModel):
|
||||
"""Schema for vendor user info in auth context."""
|
||||
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
role: str
|
||||
is_active: bool
|
||||
|
||||
@@ -71,3 +71,17 @@ class ProductListResponse(BaseModel):
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class ProductDeleteResponse(BaseModel):
|
||||
"""Response for product deletion."""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
class ProductToggleResponse(BaseModel):
|
||||
"""Response for product toggle operations (active/featured)."""
|
||||
|
||||
message: str
|
||||
is_active: bool | None = None
|
||||
is_featured: bool | None = None
|
||||
|
||||
55
package.json
55
package.json
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"name": "windmill-dashboard",
|
||||
"version": "1.0.2",
|
||||
"description": "A multi theme, completely accessible, with components and pages examples, ready for production dashboard.",
|
||||
"scripts": {
|
||||
"tailwind:admin": "tailwindcss build static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css",
|
||||
"tailwind:vendor": "tailwindcss build static/admin/css/tailwind.css -o static/vendor/css/tailwind.output.css",
|
||||
"build:admin": "env NODE_ENV=production postcss static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css",
|
||||
"build:vendor": "env NODE_ENV=production postcss static/admin/css/tailwind.css -o static/vendor/css/tailwind.output.css",
|
||||
"build": "npm run build:admin && npm run build:vendor",
|
||||
"cz": "git-cz",
|
||||
"release": "release-it"
|
||||
},
|
||||
"author": "Estevan Maito <ejmaito@gmail.com>",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@release-it/conventional-changelog": "1.1.4",
|
||||
"@tailwindcss/custom-forms": "0.2.1",
|
||||
"autoprefixer": "9.8.0",
|
||||
"color": "3.1.2",
|
||||
"commitizen": "4.1.2",
|
||||
"cssnano": "4.1.10",
|
||||
"cz-conventional-changelog": "3.2.0",
|
||||
"postcss-cli": "7.1.1",
|
||||
"release-it": "13.6.4",
|
||||
"tailwindcss": "1.4.6",
|
||||
"tailwindcss-multi-theme": "1.0.3"
|
||||
},
|
||||
"keywords": [
|
||||
"tailwind",
|
||||
"windmill",
|
||||
"dashboard",
|
||||
"template",
|
||||
"admin"
|
||||
],
|
||||
"release-it": {
|
||||
"github": {
|
||||
"release": true
|
||||
},
|
||||
"npm": {
|
||||
"publish": false
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/conventional-changelog": {
|
||||
"preset": "angular",
|
||||
"infile": "CHANGELOG.md"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "./node_modules/cz-conventional-changelog"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('tailwindcss'),
|
||||
require('autoprefixer'),
|
||||
require('cssnano')({
|
||||
preset: 'default',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -119,18 +119,53 @@ python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = [
|
||||
"-v",
|
||||
"--tb=short",
|
||||
"--strict-markers",
|
||||
"--strict-config",
|
||||
"--color=yes",
|
||||
"--durations=10",
|
||||
"--showlocals",
|
||||
"-ra",
|
||||
"--cov=app",
|
||||
"--cov=models",
|
||||
"--cov=middleware",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=html:htmlcov",
|
||||
"--cov-fail-under=80",
|
||||
]
|
||||
markers = [
|
||||
"unit: Unit tests",
|
||||
"integration: Integration tests",
|
||||
"slow: Slow running tests",
|
||||
"unit: marks tests as unit tests - fast, isolated components",
|
||||
"integration: marks tests as integration tests - multiple components working together",
|
||||
"system: marks tests as system tests - full application behavior",
|
||||
"e2e: marks tests as end-to-end tests - complete user workflows",
|
||||
"slow: marks tests as slow running tests (deselect with '-m \"not slow\"')",
|
||||
"performance: marks tests as performance and load tests",
|
||||
"auth: marks tests as authentication and authorization tests",
|
||||
"products: marks tests as product management functionality",
|
||||
"inventory: marks tests as inventory and inventory management",
|
||||
"vendors: marks tests as vendor management functionality",
|
||||
"admin: marks tests as admin functionality and permissions",
|
||||
"marketplace: marks tests as marketplace import functionality",
|
||||
"stats: marks tests as statistics and reporting",
|
||||
"database: marks tests as tests that require database operations",
|
||||
"external: marks tests as tests that require external services",
|
||||
"api: marks tests as API endpoint tests",
|
||||
"security: marks tests as security-related tests",
|
||||
"ci: marks tests as tests that should only run in CI",
|
||||
"dev: marks tests as development-specific tests",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::UserWarning",
|
||||
"ignore::DeprecationWarning",
|
||||
"ignore::PendingDeprecationWarning",
|
||||
"ignore::sqlalchemy.exc.SAWarning",
|
||||
]
|
||||
timeout = 300
|
||||
timeout_method = "thread"
|
||||
log_cli = true
|
||||
log_cli_level = "INFO"
|
||||
log_cli_format = "%(asctime)s [%(levelname)8s] %(name)s: %(message)s"
|
||||
log_cli_date_format = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
# =============================================================================
|
||||
# COVERAGE
|
||||
|
||||
64
pytest.ini
64
pytest.ini
@@ -1,64 +0,0 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
|
||||
# Enhanced addopts for better development experience
|
||||
addopts =
|
||||
-v
|
||||
--tb=short
|
||||
--strict-markers
|
||||
--strict-config
|
||||
--color=yes
|
||||
--durations=10
|
||||
--showlocals
|
||||
-ra
|
||||
--cov=app
|
||||
--cov=models
|
||||
--cov=middleware
|
||||
--cov-report=term-missing
|
||||
--cov-report=html:htmlcov
|
||||
--cov-fail-under=80
|
||||
|
||||
# Test discovery and execution settings
|
||||
minversion = 6.0
|
||||
|
||||
# Markers for your specific test organization
|
||||
markers =
|
||||
unit: marks tests as unit tests - fast, isolated components
|
||||
integration: marks tests as integration tests - multiple components working together
|
||||
system: marks tests as system tests - full application behavior
|
||||
e2e: marks tests as end-to-end tests - complete user workflows
|
||||
slow: marks tests as slow running tests (deselect with '-m "not slow"')
|
||||
performance: marks tests as performance and load tests
|
||||
auth: marks tests as authentication and authorization tests
|
||||
products: marks tests as product management functionality
|
||||
inventory: marks tests as inventory and inventory management
|
||||
vendors: marks tests as vendor management functionality
|
||||
admin: marks tests as admin functionality and permissions
|
||||
marketplace: marks tests as marketplace import functionality
|
||||
stats: marks tests as statistics and reporting
|
||||
database: marks tests as tests that require database operations
|
||||
external: marks tests as tests that require external services
|
||||
api: marks tests as API endpoint tests
|
||||
security: marks tests as security-related tests
|
||||
ci: marks tests as tests that should only run in CI
|
||||
dev: marks tests as development-specific tests
|
||||
|
||||
# Test filtering shortcuts
|
||||
filterwarnings =
|
||||
ignore::UserWarning
|
||||
ignore::DeprecationWarning
|
||||
ignore::PendingDeprecationWarning
|
||||
ignore::sqlalchemy.exc.SAWarning
|
||||
|
||||
# Timeout settings
|
||||
timeout = 300
|
||||
timeout_method = thread
|
||||
|
||||
# Additional logging configuration
|
||||
log_cli = true
|
||||
log_cli_level = INFO
|
||||
log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
|
||||
log_cli_date_format = %Y-%m-%d %H:%M:%S
|
||||
@@ -381,6 +381,25 @@ class ArchitectureValidator:
|
||||
print("⏭️ Not an admin template, skipping extends check")
|
||||
return
|
||||
|
||||
# Check for standalone marker in template (first 5 lines)
|
||||
# Supports: {# standalone #}, {# noqa: TPL-001 #}, <!-- standalone -->
|
||||
first_lines = "\n".join(lines[:5]).lower()
|
||||
if "standalone" in first_lines or "noqa: tpl-001" in first_lines:
|
||||
print("⏭️ Template marked as standalone, skipping extends check")
|
||||
return
|
||||
|
||||
# Check exclusion patterns for TPL-001
|
||||
# These are templates that intentionally don't extend admin/base.html
|
||||
tpl_001_exclusions = [
|
||||
"login.html", # Standalone login page
|
||||
"errors/", # Error pages extend errors/base.html
|
||||
"test-", # Test templates
|
||||
]
|
||||
for exclusion in tpl_001_exclusions:
|
||||
if exclusion in file_path_str:
|
||||
print(f"⏭️ Template matches exclusion pattern '{exclusion}', skipping")
|
||||
return
|
||||
|
||||
# TPL-001: Check for extends
|
||||
has_extends = any(
|
||||
"{% extends" in line and "admin/base.html" in line for line in lines
|
||||
@@ -395,7 +414,7 @@ class ArchitectureValidator:
|
||||
line_number=1,
|
||||
message="Admin template does not extend admin/base.html",
|
||||
context=file_path.name,
|
||||
suggestion="Add {% extends 'admin/base.html' %} at the top",
|
||||
suggestion="Add {% extends 'admin/base.html' %} at the top, or add {# standalone #} if intentional",
|
||||
)
|
||||
|
||||
def _validate_api_endpoints(self, target_path: Path):
|
||||
@@ -535,26 +554,44 @@ class ArchitectureValidator:
|
||||
def _check_endpoint_authentication(
|
||||
self, file_path: Path, content: str, lines: list[str]
|
||||
):
|
||||
"""API-004: Check authentication on endpoints"""
|
||||
"""API-004: Check authentication on endpoints
|
||||
|
||||
Automatically skips:
|
||||
- Auth endpoint files (*/auth.py) - login/logout are intentionally public
|
||||
- Endpoints marked with '# public' comment
|
||||
"""
|
||||
rule = self._get_rule("API-004")
|
||||
if not rule:
|
||||
return
|
||||
|
||||
# Skip auth endpoint files entirely - they are intentionally public
|
||||
file_path_str = str(file_path)
|
||||
if file_path_str.endswith("/auth.py") or file_path_str.endswith("\\auth.py"):
|
||||
return
|
||||
|
||||
# This is a warning-level check
|
||||
# Look for endpoints without Depends(get_current_*)
|
||||
for i, line in enumerate(lines, 1):
|
||||
if "@router." in line and (
|
||||
"post" in line or "put" in line or "delete" in line
|
||||
):
|
||||
# Check next 5 lines for auth
|
||||
# Check next 15 lines for auth or public marker
|
||||
# (increased from 5 to handle multi-line decorators and long function signatures)
|
||||
has_auth = False
|
||||
for j in range(i, min(i + 5, len(lines))):
|
||||
if "Depends(get_current_" in lines[j]:
|
||||
is_public = False
|
||||
context_lines = lines[i - 1 : i + 15] # Include line before decorator
|
||||
|
||||
for ctx_line in context_lines:
|
||||
if "Depends(get_current_" in ctx_line:
|
||||
has_auth = True
|
||||
break
|
||||
# Check for public endpoint markers
|
||||
if "# public" in ctx_line.lower() or "# noqa: api-004" in ctx_line.lower():
|
||||
is_public = True
|
||||
break
|
||||
|
||||
if not has_auth and "include_in_schema=False" not in " ".join(
|
||||
lines[i : i + 5]
|
||||
if not has_auth and not is_public and "include_in_schema=False" not in " ".join(
|
||||
lines[i : i + 15]
|
||||
):
|
||||
self._add_violation(
|
||||
rule_id="API-004",
|
||||
@@ -564,7 +601,7 @@ class ArchitectureValidator:
|
||||
line_number=i,
|
||||
message="Endpoint may be missing authentication",
|
||||
context=line.strip(),
|
||||
suggestion="Add Depends(get_current_user) or similar if endpoint should be protected",
|
||||
suggestion="Add Depends(get_current_user) or mark as '# public' if intentionally unauthenticated",
|
||||
)
|
||||
|
||||
def _validate_service_layer(self, target_path: Path):
|
||||
@@ -816,14 +853,37 @@ class ArchitectureValidator:
|
||||
template_files = list(target_path.glob("app/templates/admin/**/*.html"))
|
||||
self.result.files_checked += len(template_files)
|
||||
|
||||
# TPL-001 exclusion patterns
|
||||
tpl_001_exclusions = [
|
||||
"login.html", # Standalone login page
|
||||
"errors/", # Error pages extend errors/base.html
|
||||
"test-", # Test templates
|
||||
]
|
||||
|
||||
for file_path in template_files:
|
||||
# Skip base template and partials
|
||||
if "base.html" in file_path.name or "partials" in str(file_path):
|
||||
continue
|
||||
|
||||
file_path_str = str(file_path)
|
||||
|
||||
# Check exclusion patterns
|
||||
skip = False
|
||||
for exclusion in tpl_001_exclusions:
|
||||
if exclusion in file_path_str:
|
||||
skip = True
|
||||
break
|
||||
if skip:
|
||||
continue
|
||||
|
||||
content = file_path.read_text()
|
||||
lines = content.split("\n")
|
||||
|
||||
# Check for standalone marker in template (first 5 lines)
|
||||
first_lines = "\n".join(lines[:5]).lower()
|
||||
if "standalone" in first_lines or "noqa: tpl-001" in first_lines:
|
||||
continue
|
||||
|
||||
# TPL-001: Check for extends
|
||||
has_extends = any(
|
||||
"{% extends" in line and "admin/base.html" in line for line in lines
|
||||
@@ -838,7 +898,7 @@ class ArchitectureValidator:
|
||||
line_number=1,
|
||||
message="Admin template does not extend admin/base.html",
|
||||
context=file_path.name,
|
||||
suggestion="Add {% extends 'admin/base.html' %} at the top",
|
||||
suggestion="Add {% extends 'admin/base.html' %} at the top, or add {# standalone #} if intentional",
|
||||
)
|
||||
|
||||
def _get_rule(self, rule_id: str) -> dict[str, Any]:
|
||||
|
||||
@@ -1,4 +1,254 @@
|
||||
/* Tailwind CSS source file for admin panel */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/* Tailwind CSS v4 - Admin Panel Styles */
|
||||
/* Configuration is CSS-first in v4 */
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Source paths for content scanning */
|
||||
@source "../../js/**/*.js";
|
||||
@source "../../../app/templates/**/*.html";
|
||||
|
||||
/* Custom theme configuration */
|
||||
@theme {
|
||||
/* Custom gray palette (Windmill Dashboard) */
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f4f5f7;
|
||||
--color-gray-200: #e5e7eb;
|
||||
--color-gray-300: #d5d6d7;
|
||||
--color-gray-400: #9e9e9e;
|
||||
--color-gray-500: #707275;
|
||||
--color-gray-600: #4c4f52;
|
||||
--color-gray-700: #24262d;
|
||||
--color-gray-800: #1a1c23;
|
||||
--color-gray-900: #121317;
|
||||
|
||||
/* Custom purple palette (Windmill Dashboard) */
|
||||
--color-purple-50: #f6f5ff;
|
||||
--color-purple-100: #edebfe;
|
||||
--color-purple-200: #dcd7fe;
|
||||
--color-purple-300: #cabffd;
|
||||
--color-purple-400: #ac94fa;
|
||||
--color-purple-500: #9061f9;
|
||||
--color-purple-600: #7e3af2;
|
||||
--color-purple-700: #6c2bd9;
|
||||
--color-purple-800: #5521b5;
|
||||
--color-purple-900: #4a1d96;
|
||||
|
||||
/* Custom orange palette */
|
||||
--color-orange-50: #fff8f1;
|
||||
--color-orange-100: #feecdc;
|
||||
--color-orange-200: #fcd9bd;
|
||||
--color-orange-300: #fdba8c;
|
||||
--color-orange-400: #ff8a4c;
|
||||
--color-orange-500: #ff5a1f;
|
||||
--color-orange-600: #d03801;
|
||||
--color-orange-700: #b43403;
|
||||
--color-orange-800: #8a2c0d;
|
||||
--color-orange-900: #771d1d;
|
||||
|
||||
/* Custom green palette */
|
||||
--color-green-50: #f3faf7;
|
||||
--color-green-100: #def7ec;
|
||||
--color-green-200: #bcf0da;
|
||||
--color-green-300: #84e1bc;
|
||||
--color-green-400: #31c48d;
|
||||
--color-green-500: #0e9f6e;
|
||||
--color-green-600: #057a55;
|
||||
--color-green-700: #046c4e;
|
||||
--color-green-800: #03543f;
|
||||
--color-green-900: #014737;
|
||||
|
||||
/* Custom red palette */
|
||||
--color-red-50: #fdf2f2;
|
||||
--color-red-100: #fde8e8;
|
||||
--color-red-200: #fbd5d5;
|
||||
--color-red-300: #f8b4b4;
|
||||
--color-red-400: #f98080;
|
||||
--color-red-500: #f05252;
|
||||
--color-red-600: #e02424;
|
||||
--color-red-700: #c81e1e;
|
||||
--color-red-800: #9b1c1c;
|
||||
--color-red-900: #771d1d;
|
||||
|
||||
/* Custom yellow palette */
|
||||
--color-yellow-50: #fdfdea;
|
||||
--color-yellow-100: #fdf6b2;
|
||||
--color-yellow-200: #fce96a;
|
||||
--color-yellow-300: #faca15;
|
||||
--color-yellow-400: #e3a008;
|
||||
--color-yellow-500: #c27803;
|
||||
--color-yellow-600: #9f580a;
|
||||
--color-yellow-700: #8e4b10;
|
||||
--color-yellow-800: #723b13;
|
||||
--color-yellow-900: #633112;
|
||||
|
||||
/* Custom teal palette */
|
||||
--color-teal-50: #edfafa;
|
||||
--color-teal-100: #d5f5f6;
|
||||
--color-teal-200: #afecef;
|
||||
--color-teal-300: #7edce2;
|
||||
--color-teal-400: #16bdca;
|
||||
--color-teal-500: #0694a2;
|
||||
--color-teal-600: #047481;
|
||||
--color-teal-700: #036672;
|
||||
--color-teal-800: #05505c;
|
||||
--color-teal-900: #014451;
|
||||
|
||||
/* Custom blue palette */
|
||||
--color-blue-50: #ebf5ff;
|
||||
--color-blue-100: #e1effe;
|
||||
--color-blue-200: #c3ddfd;
|
||||
--color-blue-300: #a4cafe;
|
||||
--color-blue-400: #76a9fa;
|
||||
--color-blue-500: #3f83f8;
|
||||
--color-blue-600: #1c64f2;
|
||||
--color-blue-700: #1a56db;
|
||||
--color-blue-800: #1e429f;
|
||||
--color-blue-900: #233876;
|
||||
|
||||
/* Custom indigo palette */
|
||||
--color-indigo-50: #f0f5ff;
|
||||
--color-indigo-100: #e5edff;
|
||||
--color-indigo-200: #cddbfe;
|
||||
--color-indigo-300: #b4c6fc;
|
||||
--color-indigo-400: #8da2fb;
|
||||
--color-indigo-500: #6875f5;
|
||||
--color-indigo-600: #5850ec;
|
||||
--color-indigo-700: #5145cd;
|
||||
--color-indigo-800: #42389d;
|
||||
--color-indigo-900: #362f78;
|
||||
|
||||
/* Custom pink palette */
|
||||
--color-pink-50: #fdf2f8;
|
||||
--color-pink-100: #fce8f3;
|
||||
--color-pink-200: #fad1e8;
|
||||
--color-pink-300: #f8b4d9;
|
||||
--color-pink-400: #f17eb8;
|
||||
--color-pink-500: #e74694;
|
||||
--color-pink-600: #d61f69;
|
||||
--color-pink-700: #bf125d;
|
||||
--color-pink-800: #99154b;
|
||||
--color-pink-900: #751a3d;
|
||||
|
||||
/* Cool gray palette */
|
||||
--color-cool-gray-50: #fbfdfe;
|
||||
--color-cool-gray-100: #f1f5f9;
|
||||
--color-cool-gray-200: #e2e8f0;
|
||||
--color-cool-gray-300: #cfd8e3;
|
||||
--color-cool-gray-400: #97a6ba;
|
||||
--color-cool-gray-500: #64748b;
|
||||
--color-cool-gray-600: #475569;
|
||||
--color-cool-gray-700: #364152;
|
||||
--color-cool-gray-800: #27303f;
|
||||
--color-cool-gray-900: #1a202e;
|
||||
|
||||
/* Font family */
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
/* Custom max-height */
|
||||
--spacing-xl: 36rem;
|
||||
}
|
||||
|
||||
/* Dark mode variant - uses class on html element */
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Custom utilities layer */
|
||||
@layer utilities {
|
||||
/* Shadow outline utilities for focus states */
|
||||
.shadow-outline-gray {
|
||||
box-shadow: 0 0 0 3px hsla(220, 9%, 83%, 0.45);
|
||||
}
|
||||
.shadow-outline-purple {
|
||||
box-shadow: 0 0 0 3px hsla(262, 97%, 81%, 0.45);
|
||||
}
|
||||
.shadow-outline-red {
|
||||
box-shadow: 0 0 0 3px hsla(0, 91%, 85%, 0.45);
|
||||
}
|
||||
.shadow-outline-orange {
|
||||
box-shadow: 0 0 0 3px hsla(22, 97%, 77%, 0.45);
|
||||
}
|
||||
.shadow-outline-green {
|
||||
box-shadow: 0 0 0 3px hsla(152, 68%, 70%, 0.45);
|
||||
}
|
||||
.shadow-outline-blue {
|
||||
box-shadow: 0 0 0 3px hsla(215, 96%, 81%, 0.45);
|
||||
}
|
||||
.shadow-outline-yellow {
|
||||
box-shadow: 0 0 0 3px hsla(46, 97%, 65%, 0.45);
|
||||
}
|
||||
.shadow-outline-teal {
|
||||
box-shadow: 0 0 0 3px hsla(182, 68%, 69%, 0.45);
|
||||
}
|
||||
.shadow-outline-indigo {
|
||||
box-shadow: 0 0 0 3px hsla(226, 95%, 85%, 0.45);
|
||||
}
|
||||
.shadow-outline-pink {
|
||||
box-shadow: 0 0 0 3px hsla(330, 89%, 83%, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
/* Component layer for form styles (replaces @tailwindcss/custom-forms) */
|
||||
@layer components {
|
||||
/* Form input styles */
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select,
|
||||
.form-multiselect {
|
||||
@apply block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm transition-colors;
|
||||
@apply focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500;
|
||||
@apply placeholder:text-gray-400;
|
||||
}
|
||||
|
||||
/* Dark mode form inputs */
|
||||
.dark .form-input,
|
||||
.dark .form-textarea,
|
||||
.dark .form-select,
|
||||
.dark .form-multiselect {
|
||||
@apply border-gray-600 bg-gray-700 text-gray-200;
|
||||
@apply focus:border-purple-400 focus:ring-purple-400;
|
||||
@apply placeholder:text-gray-500;
|
||||
}
|
||||
|
||||
/* Checkbox styles */
|
||||
.form-checkbox {
|
||||
@apply h-4 w-4 rounded border-gray-300 text-purple-600 transition-colors;
|
||||
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.dark .form-checkbox {
|
||||
@apply border-gray-600 bg-gray-700;
|
||||
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
|
||||
}
|
||||
|
||||
/* Radio styles */
|
||||
.form-radio {
|
||||
@apply h-4 w-4 border-gray-300 text-purple-600 transition-colors;
|
||||
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.dark .form-radio {
|
||||
@apply border-gray-600 bg-gray-700;
|
||||
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
/* Base layer for default styles */
|
||||
@layer base {
|
||||
/* Default body styles */
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 antialiased;
|
||||
}
|
||||
|
||||
/* Dark mode body */
|
||||
.dark body {
|
||||
@apply bg-gray-900 text-gray-200;
|
||||
}
|
||||
|
||||
/* Focus visible for accessibility */
|
||||
:focus-visible {
|
||||
@apply outline-2 outline-offset-2 outline-purple-500;
|
||||
}
|
||||
|
||||
.dark :focus-visible {
|
||||
@apply outline-purple-400;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
286
static/platform/css/tailwind.css
Normal file
286
static/platform/css/tailwind.css
Normal file
@@ -0,0 +1,286 @@
|
||||
/* static/platform/css/tailwind.css */
|
||||
/* Tailwind CSS v4 source file for Platform frontend */
|
||||
/* CSS-first configuration - no tailwind.config.js needed */
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Content sources for tree-shaking */
|
||||
@source "../../../app/templates/platform/**/*.html";
|
||||
@source "../js/**/*.js";
|
||||
|
||||
/* ==========================================================================
|
||||
THEME CONFIGURATION
|
||||
Migrated from legacy tailwind.config.js with Windmill color palette
|
||||
========================================================================== */
|
||||
|
||||
@theme {
|
||||
/* Font Family - Inter as primary sans-serif */
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
/* ==========================================================================
|
||||
COLOR PALETTE
|
||||
Custom colors from Windmill/Flowbite design system
|
||||
========================================================================== */
|
||||
|
||||
/* Gray - Custom darker palette */
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f4f5f7;
|
||||
--color-gray-200: #e5e7eb;
|
||||
--color-gray-300: #d5d6d7;
|
||||
--color-gray-400: #9e9e9e;
|
||||
--color-gray-500: #707275;
|
||||
--color-gray-600: #4c4f52;
|
||||
--color-gray-700: #24262d;
|
||||
--color-gray-800: #1a1c23;
|
||||
--color-gray-900: #121317;
|
||||
|
||||
/* Cool Gray */
|
||||
--color-cool-gray-50: #fbfdfe;
|
||||
--color-cool-gray-100: #f1f5f9;
|
||||
--color-cool-gray-200: #e2e8f0;
|
||||
--color-cool-gray-300: #cfd8e3;
|
||||
--color-cool-gray-400: #97a6ba;
|
||||
--color-cool-gray-500: #64748b;
|
||||
--color-cool-gray-600: #475569;
|
||||
--color-cool-gray-700: #364152;
|
||||
--color-cool-gray-800: #27303f;
|
||||
--color-cool-gray-900: #1a202e;
|
||||
|
||||
/* Red */
|
||||
--color-red-50: #fdf2f2;
|
||||
--color-red-100: #fde8e8;
|
||||
--color-red-200: #fbd5d5;
|
||||
--color-red-300: #f8b4b4;
|
||||
--color-red-400: #f98080;
|
||||
--color-red-500: #f05252;
|
||||
--color-red-600: #e02424;
|
||||
--color-red-700: #c81e1e;
|
||||
--color-red-800: #9b1c1c;
|
||||
--color-red-900: #771d1d;
|
||||
|
||||
/* Orange */
|
||||
--color-orange-50: #fff8f1;
|
||||
--color-orange-100: #feecdc;
|
||||
--color-orange-200: #fcd9bd;
|
||||
--color-orange-300: #fdba8c;
|
||||
--color-orange-400: #ff8a4c;
|
||||
--color-orange-500: #ff5a1f;
|
||||
--color-orange-600: #d03801;
|
||||
--color-orange-700: #b43403;
|
||||
--color-orange-800: #8a2c0d;
|
||||
--color-orange-900: #771d1d;
|
||||
|
||||
/* Yellow */
|
||||
--color-yellow-50: #fdfdea;
|
||||
--color-yellow-100: #fdf6b2;
|
||||
--color-yellow-200: #fce96a;
|
||||
--color-yellow-300: #faca15;
|
||||
--color-yellow-400: #e3a008;
|
||||
--color-yellow-500: #c27803;
|
||||
--color-yellow-600: #9f580a;
|
||||
--color-yellow-700: #8e4b10;
|
||||
--color-yellow-800: #723b13;
|
||||
--color-yellow-900: #633112;
|
||||
|
||||
/* Green */
|
||||
--color-green-50: #f3faf7;
|
||||
--color-green-100: #def7ec;
|
||||
--color-green-200: #bcf0da;
|
||||
--color-green-300: #84e1bc;
|
||||
--color-green-400: #31c48d;
|
||||
--color-green-500: #0e9f6e;
|
||||
--color-green-600: #057a55;
|
||||
--color-green-700: #046c4e;
|
||||
--color-green-800: #03543f;
|
||||
--color-green-900: #014737;
|
||||
|
||||
/* Teal */
|
||||
--color-teal-50: #edfafa;
|
||||
--color-teal-100: #d5f5f6;
|
||||
--color-teal-200: #afecef;
|
||||
--color-teal-300: #7edce2;
|
||||
--color-teal-400: #16bdca;
|
||||
--color-teal-500: #0694a2;
|
||||
--color-teal-600: #047481;
|
||||
--color-teal-700: #036672;
|
||||
--color-teal-800: #05505c;
|
||||
--color-teal-900: #014451;
|
||||
|
||||
/* Blue */
|
||||
--color-blue-50: #ebf5ff;
|
||||
--color-blue-100: #e1effe;
|
||||
--color-blue-200: #c3ddfd;
|
||||
--color-blue-300: #a4cafe;
|
||||
--color-blue-400: #76a9fa;
|
||||
--color-blue-500: #3f83f8;
|
||||
--color-blue-600: #1c64f2;
|
||||
--color-blue-700: #1a56db;
|
||||
--color-blue-800: #1e429f;
|
||||
--color-blue-900: #233876;
|
||||
|
||||
/* Indigo */
|
||||
--color-indigo-50: #f0f5ff;
|
||||
--color-indigo-100: #e5edff;
|
||||
--color-indigo-200: #cddbfe;
|
||||
--color-indigo-300: #b4c6fc;
|
||||
--color-indigo-400: #8da2fb;
|
||||
--color-indigo-500: #6875f5;
|
||||
--color-indigo-600: #5850ec;
|
||||
--color-indigo-700: #5145cd;
|
||||
--color-indigo-800: #42389d;
|
||||
--color-indigo-900: #362f78;
|
||||
|
||||
/* Purple */
|
||||
--color-purple-50: #f6f5ff;
|
||||
--color-purple-100: #edebfe;
|
||||
--color-purple-200: #dcd7fe;
|
||||
--color-purple-300: #cabffd;
|
||||
--color-purple-400: #ac94fa;
|
||||
--color-purple-500: #9061f9;
|
||||
--color-purple-600: #7e3af2;
|
||||
--color-purple-700: #6c2bd9;
|
||||
--color-purple-800: #5521b5;
|
||||
--color-purple-900: #4a1d96;
|
||||
|
||||
/* Pink */
|
||||
--color-pink-50: #fdf2f8;
|
||||
--color-pink-100: #fce8f3;
|
||||
--color-pink-200: #fad1e8;
|
||||
--color-pink-300: #f8b4d9;
|
||||
--color-pink-400: #f17eb8;
|
||||
--color-pink-500: #e74694;
|
||||
--color-pink-600: #d61f69;
|
||||
--color-pink-700: #bf125d;
|
||||
--color-pink-800: #99154b;
|
||||
--color-pink-900: #751a3d;
|
||||
|
||||
/* Extended max-height */
|
||||
--max-height-xl: 36rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
DARK MODE VARIANT
|
||||
Matches the .dark class pattern used in templates
|
||||
========================================================================== */
|
||||
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* ==========================================================================
|
||||
CUSTOM UTILITIES
|
||||
Shadow outline utilities (migrated from plugin)
|
||||
========================================================================== */
|
||||
|
||||
@utility shadow-outline-gray {
|
||||
box-shadow: 0 0 0 3px hsla(0, 0%, 84%, 0.45);
|
||||
}
|
||||
|
||||
@utility shadow-outline-red {
|
||||
box-shadow: 0 0 0 3px hsla(0, 90%, 84%, 0.45);
|
||||
}
|
||||
|
||||
@utility shadow-outline-orange {
|
||||
box-shadow: 0 0 0 3px hsla(24, 97%, 77%, 0.45);
|
||||
}
|
||||
|
||||
@utility shadow-outline-yellow {
|
||||
box-shadow: 0 0 0 3px hsla(45, 97%, 53%, 0.45);
|
||||
}
|
||||
|
||||
@utility shadow-outline-green {
|
||||
box-shadow: 0 0 0 3px hsla(156, 70%, 70%, 0.45);
|
||||
}
|
||||
|
||||
@utility shadow-outline-teal {
|
||||
box-shadow: 0 0 0 3px hsla(183, 71%, 69%, 0.45);
|
||||
}
|
||||
|
||||
@utility shadow-outline-blue {
|
||||
box-shadow: 0 0 0 3px hsla(214, 95%, 84%, 0.45);
|
||||
}
|
||||
|
||||
@utility shadow-outline-indigo {
|
||||
box-shadow: 0 0 0 3px hsla(227, 94%, 85%, 0.45);
|
||||
}
|
||||
|
||||
@utility shadow-outline-purple {
|
||||
box-shadow: 0 0 0 3px hsla(259, 97%, 87%, 0.45);
|
||||
}
|
||||
|
||||
@utility shadow-outline-pink {
|
||||
box-shadow: 0 0 0 3px hsla(327, 87%, 84%, 0.45);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
FORM STYLES
|
||||
Custom form placeholder styling (replaces @tailwindcss/custom-forms)
|
||||
========================================================================== */
|
||||
|
||||
@layer base {
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
PLATFORM-SPECIFIC COMPONENTS
|
||||
========================================================================== */
|
||||
|
||||
@layer components {
|
||||
/* Gradient backgrounds */
|
||||
.gradient-primary {
|
||||
background: linear-gradient(135deg, var(--color-indigo-500) 0%, var(--color-purple-500) 100%);
|
||||
}
|
||||
|
||||
.gradient-accent {
|
||||
background: linear-gradient(135deg, var(--color-purple-500) 0%, var(--color-pink-500) 100%);
|
||||
}
|
||||
|
||||
/* Primary button */
|
||||
.btn-primary {
|
||||
background-color: var(--color-indigo-500);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Secondary button */
|
||||
.btn-secondary {
|
||||
background-color: var(--color-gray-100);
|
||||
color: var(--color-gray-700);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dark .btn-secondary {
|
||||
background-color: var(--color-gray-700);
|
||||
color: var(--color-gray-100);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-gray-200);
|
||||
}
|
||||
|
||||
.dark .btn-secondary:hover {
|
||||
background-color: var(--color-gray-600);
|
||||
}
|
||||
|
||||
/* Card hover effect */
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
4231
static/platform/css/tailwind.output.css
Normal file
4231
static/platform/css/tailwind.output.css
Normal file
File diff suppressed because it is too large
Load Diff
1
static/shared/css/tailwind.min.css
vendored
1
static/shared/css/tailwind.min.css
vendored
File diff suppressed because one or more lines are too long
274
static/shop/css/tailwind.css
Normal file
274
static/shop/css/tailwind.css
Normal file
@@ -0,0 +1,274 @@
|
||||
/* Tailwind CSS v4 - Shop Frontend Styles */
|
||||
/* Configuration is CSS-first in v4 */
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Source paths for content scanning */
|
||||
@source "../../js/**/*.js";
|
||||
@source "../../../app/templates/shop/**/*.html";
|
||||
@source "../../../app/templates/shared/**/*.html";
|
||||
|
||||
/* Custom theme configuration */
|
||||
@theme {
|
||||
/* Custom gray palette (Windmill Dashboard) */
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f4f5f7;
|
||||
--color-gray-200: #e5e7eb;
|
||||
--color-gray-300: #d5d6d7;
|
||||
--color-gray-400: #9e9e9e;
|
||||
--color-gray-500: #707275;
|
||||
--color-gray-600: #4c4f52;
|
||||
--color-gray-700: #24262d;
|
||||
--color-gray-800: #1a1c23;
|
||||
--color-gray-900: #121317;
|
||||
|
||||
/* Custom purple palette (Windmill Dashboard) */
|
||||
--color-purple-50: #f6f5ff;
|
||||
--color-purple-100: #edebfe;
|
||||
--color-purple-200: #dcd7fe;
|
||||
--color-purple-300: #cabffd;
|
||||
--color-purple-400: #ac94fa;
|
||||
--color-purple-500: #9061f9;
|
||||
--color-purple-600: #7e3af2;
|
||||
--color-purple-700: #6c2bd9;
|
||||
--color-purple-800: #5521b5;
|
||||
--color-purple-900: #4a1d96;
|
||||
|
||||
/* Custom orange palette */
|
||||
--color-orange-50: #fff8f1;
|
||||
--color-orange-100: #feecdc;
|
||||
--color-orange-200: #fcd9bd;
|
||||
--color-orange-300: #fdba8c;
|
||||
--color-orange-400: #ff8a4c;
|
||||
--color-orange-500: #ff5a1f;
|
||||
--color-orange-600: #d03801;
|
||||
--color-orange-700: #b43403;
|
||||
--color-orange-800: #8a2c0d;
|
||||
--color-orange-900: #771d1d;
|
||||
|
||||
/* Custom green palette */
|
||||
--color-green-50: #f3faf7;
|
||||
--color-green-100: #def7ec;
|
||||
--color-green-200: #bcf0da;
|
||||
--color-green-300: #84e1bc;
|
||||
--color-green-400: #31c48d;
|
||||
--color-green-500: #0e9f6e;
|
||||
--color-green-600: #057a55;
|
||||
--color-green-700: #046c4e;
|
||||
--color-green-800: #03543f;
|
||||
--color-green-900: #014737;
|
||||
|
||||
/* Custom red palette */
|
||||
--color-red-50: #fdf2f2;
|
||||
--color-red-100: #fde8e8;
|
||||
--color-red-200: #fbd5d5;
|
||||
--color-red-300: #f8b4b4;
|
||||
--color-red-400: #f98080;
|
||||
--color-red-500: #f05252;
|
||||
--color-red-600: #e02424;
|
||||
--color-red-700: #c81e1e;
|
||||
--color-red-800: #9b1c1c;
|
||||
--color-red-900: #771d1d;
|
||||
|
||||
/* Custom yellow palette */
|
||||
--color-yellow-50: #fdfdea;
|
||||
--color-yellow-100: #fdf6b2;
|
||||
--color-yellow-200: #fce96a;
|
||||
--color-yellow-300: #faca15;
|
||||
--color-yellow-400: #e3a008;
|
||||
--color-yellow-500: #c27803;
|
||||
--color-yellow-600: #9f580a;
|
||||
--color-yellow-700: #8e4b10;
|
||||
--color-yellow-800: #723b13;
|
||||
--color-yellow-900: #633112;
|
||||
|
||||
/* Custom teal palette */
|
||||
--color-teal-50: #edfafa;
|
||||
--color-teal-100: #d5f5f6;
|
||||
--color-teal-200: #afecef;
|
||||
--color-teal-300: #7edce2;
|
||||
--color-teal-400: #16bdca;
|
||||
--color-teal-500: #0694a2;
|
||||
--color-teal-600: #047481;
|
||||
--color-teal-700: #036672;
|
||||
--color-teal-800: #05505c;
|
||||
--color-teal-900: #014451;
|
||||
|
||||
/* Custom blue palette */
|
||||
--color-blue-50: #ebf5ff;
|
||||
--color-blue-100: #e1effe;
|
||||
--color-blue-200: #c3ddfd;
|
||||
--color-blue-300: #a4cafe;
|
||||
--color-blue-400: #76a9fa;
|
||||
--color-blue-500: #3f83f8;
|
||||
--color-blue-600: #1c64f2;
|
||||
--color-blue-700: #1a56db;
|
||||
--color-blue-800: #1e429f;
|
||||
--color-blue-900: #233876;
|
||||
|
||||
/* Custom indigo palette */
|
||||
--color-indigo-50: #f0f5ff;
|
||||
--color-indigo-100: #e5edff;
|
||||
--color-indigo-200: #cddbfe;
|
||||
--color-indigo-300: #b4c6fc;
|
||||
--color-indigo-400: #8da2fb;
|
||||
--color-indigo-500: #6875f5;
|
||||
--color-indigo-600: #5850ec;
|
||||
--color-indigo-700: #5145cd;
|
||||
--color-indigo-800: #42389d;
|
||||
--color-indigo-900: #362f78;
|
||||
|
||||
/* Custom pink palette */
|
||||
--color-pink-50: #fdf2f8;
|
||||
--color-pink-100: #fce8f3;
|
||||
--color-pink-200: #fad1e8;
|
||||
--color-pink-300: #f8b4d9;
|
||||
--color-pink-400: #f17eb8;
|
||||
--color-pink-500: #e74694;
|
||||
--color-pink-600: #d61f69;
|
||||
--color-pink-700: #bf125d;
|
||||
--color-pink-800: #99154b;
|
||||
--color-pink-900: #751a3d;
|
||||
|
||||
/* Cool gray palette */
|
||||
--color-cool-gray-50: #fbfdfe;
|
||||
--color-cool-gray-100: #f1f5f9;
|
||||
--color-cool-gray-200: #e2e8f0;
|
||||
--color-cool-gray-300: #cfd8e3;
|
||||
--color-cool-gray-400: #97a6ba;
|
||||
--color-cool-gray-500: #64748b;
|
||||
--color-cool-gray-600: #475569;
|
||||
--color-cool-gray-700: #364152;
|
||||
--color-cool-gray-800: #27303f;
|
||||
--color-cool-gray-900: #1a202e;
|
||||
|
||||
/* Font family */
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
/* Custom max-height */
|
||||
--spacing-xl: 36rem;
|
||||
}
|
||||
|
||||
/* Dark mode variant - uses class on html element */
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Custom utilities layer */
|
||||
@layer utilities {
|
||||
/* Shadow outline utilities for focus states */
|
||||
.shadow-outline-gray {
|
||||
box-shadow: 0 0 0 3px hsla(220, 9%, 83%, 0.45);
|
||||
}
|
||||
.shadow-outline-purple {
|
||||
box-shadow: 0 0 0 3px hsla(262, 97%, 81%, 0.45);
|
||||
}
|
||||
.shadow-outline-red {
|
||||
box-shadow: 0 0 0 3px hsla(0, 91%, 85%, 0.45);
|
||||
}
|
||||
.shadow-outline-orange {
|
||||
box-shadow: 0 0 0 3px hsla(22, 97%, 77%, 0.45);
|
||||
}
|
||||
.shadow-outline-green {
|
||||
box-shadow: 0 0 0 3px hsla(152, 68%, 70%, 0.45);
|
||||
}
|
||||
.shadow-outline-blue {
|
||||
box-shadow: 0 0 0 3px hsla(215, 96%, 81%, 0.45);
|
||||
}
|
||||
.shadow-outline-yellow {
|
||||
box-shadow: 0 0 0 3px hsla(46, 97%, 65%, 0.45);
|
||||
}
|
||||
.shadow-outline-teal {
|
||||
box-shadow: 0 0 0 3px hsla(182, 68%, 69%, 0.45);
|
||||
}
|
||||
.shadow-outline-indigo {
|
||||
box-shadow: 0 0 0 3px hsla(226, 95%, 85%, 0.45);
|
||||
}
|
||||
.shadow-outline-pink {
|
||||
box-shadow: 0 0 0 3px hsla(330, 89%, 83%, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
/* Shop-specific component styles */
|
||||
@layer components {
|
||||
/* Form input styles */
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select,
|
||||
.form-multiselect {
|
||||
@apply block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm transition-colors;
|
||||
@apply focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500;
|
||||
@apply placeholder:text-gray-400;
|
||||
}
|
||||
|
||||
/* Dark mode form inputs */
|
||||
.dark .form-input,
|
||||
.dark .form-textarea,
|
||||
.dark .form-select,
|
||||
.dark .form-multiselect {
|
||||
@apply border-gray-600 bg-gray-700 text-gray-200;
|
||||
@apply focus:border-purple-400 focus:ring-purple-400;
|
||||
@apply placeholder:text-gray-500;
|
||||
}
|
||||
|
||||
/* Checkbox styles */
|
||||
.form-checkbox {
|
||||
@apply h-4 w-4 rounded border-gray-300 text-purple-600 transition-colors;
|
||||
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.dark .form-checkbox {
|
||||
@apply border-gray-600 bg-gray-700;
|
||||
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
|
||||
}
|
||||
|
||||
/* Radio styles */
|
||||
.form-radio {
|
||||
@apply h-4 w-4 border-gray-300 text-purple-600 transition-colors;
|
||||
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.dark .form-radio {
|
||||
@apply border-gray-600 bg-gray-700;
|
||||
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
|
||||
}
|
||||
|
||||
/* Shop-specific button using CSS variable for vendor theming */
|
||||
.btn-shop-primary {
|
||||
background-color: var(--color-primary, #7e3af2);
|
||||
@apply text-white px-4 py-2 rounded-lg font-medium transition-all;
|
||||
}
|
||||
|
||||
.btn-shop-primary:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
/* Product card hover effect */
|
||||
.product-card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden transition-all duration-300;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
@apply shadow-xl -translate-y-1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Base layer for default styles */
|
||||
@layer base {
|
||||
/* Default body styles */
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 antialiased;
|
||||
}
|
||||
|
||||
/* Dark mode body */
|
||||
.dark body {
|
||||
@apply bg-gray-900 text-gray-200;
|
||||
}
|
||||
|
||||
/* Focus visible for accessibility */
|
||||
:focus-visible {
|
||||
@apply outline-2 outline-offset-2 outline-purple-500;
|
||||
}
|
||||
|
||||
.dark :focus-visible {
|
||||
@apply outline-purple-400;
|
||||
}
|
||||
}
|
||||
4345
static/shop/css/tailwind.output.css
Normal file
4345
static/shop/css/tailwind.output.css
Normal file
File diff suppressed because it is too large
Load Diff
254
static/vendor/css/tailwind.css
vendored
Normal file
254
static/vendor/css/tailwind.css
vendored
Normal file
@@ -0,0 +1,254 @@
|
||||
/* Tailwind CSS v4 - Vendor Panel Styles */
|
||||
/* Configuration is CSS-first in v4 */
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Source paths for content scanning */
|
||||
@source "../../js/**/*.js";
|
||||
@source "../../../app/templates/vendor/**/*.html";
|
||||
|
||||
/* Custom theme configuration */
|
||||
@theme {
|
||||
/* Custom gray palette (Windmill Dashboard) */
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f4f5f7;
|
||||
--color-gray-200: #e5e7eb;
|
||||
--color-gray-300: #d5d6d7;
|
||||
--color-gray-400: #9e9e9e;
|
||||
--color-gray-500: #707275;
|
||||
--color-gray-600: #4c4f52;
|
||||
--color-gray-700: #24262d;
|
||||
--color-gray-800: #1a1c23;
|
||||
--color-gray-900: #121317;
|
||||
|
||||
/* Custom purple palette (Windmill Dashboard) */
|
||||
--color-purple-50: #f6f5ff;
|
||||
--color-purple-100: #edebfe;
|
||||
--color-purple-200: #dcd7fe;
|
||||
--color-purple-300: #cabffd;
|
||||
--color-purple-400: #ac94fa;
|
||||
--color-purple-500: #9061f9;
|
||||
--color-purple-600: #7e3af2;
|
||||
--color-purple-700: #6c2bd9;
|
||||
--color-purple-800: #5521b5;
|
||||
--color-purple-900: #4a1d96;
|
||||
|
||||
/* Custom orange palette */
|
||||
--color-orange-50: #fff8f1;
|
||||
--color-orange-100: #feecdc;
|
||||
--color-orange-200: #fcd9bd;
|
||||
--color-orange-300: #fdba8c;
|
||||
--color-orange-400: #ff8a4c;
|
||||
--color-orange-500: #ff5a1f;
|
||||
--color-orange-600: #d03801;
|
||||
--color-orange-700: #b43403;
|
||||
--color-orange-800: #8a2c0d;
|
||||
--color-orange-900: #771d1d;
|
||||
|
||||
/* Custom green palette */
|
||||
--color-green-50: #f3faf7;
|
||||
--color-green-100: #def7ec;
|
||||
--color-green-200: #bcf0da;
|
||||
--color-green-300: #84e1bc;
|
||||
--color-green-400: #31c48d;
|
||||
--color-green-500: #0e9f6e;
|
||||
--color-green-600: #057a55;
|
||||
--color-green-700: #046c4e;
|
||||
--color-green-800: #03543f;
|
||||
--color-green-900: #014737;
|
||||
|
||||
/* Custom red palette */
|
||||
--color-red-50: #fdf2f2;
|
||||
--color-red-100: #fde8e8;
|
||||
--color-red-200: #fbd5d5;
|
||||
--color-red-300: #f8b4b4;
|
||||
--color-red-400: #f98080;
|
||||
--color-red-500: #f05252;
|
||||
--color-red-600: #e02424;
|
||||
--color-red-700: #c81e1e;
|
||||
--color-red-800: #9b1c1c;
|
||||
--color-red-900: #771d1d;
|
||||
|
||||
/* Custom yellow palette */
|
||||
--color-yellow-50: #fdfdea;
|
||||
--color-yellow-100: #fdf6b2;
|
||||
--color-yellow-200: #fce96a;
|
||||
--color-yellow-300: #faca15;
|
||||
--color-yellow-400: #e3a008;
|
||||
--color-yellow-500: #c27803;
|
||||
--color-yellow-600: #9f580a;
|
||||
--color-yellow-700: #8e4b10;
|
||||
--color-yellow-800: #723b13;
|
||||
--color-yellow-900: #633112;
|
||||
|
||||
/* Custom teal palette */
|
||||
--color-teal-50: #edfafa;
|
||||
--color-teal-100: #d5f5f6;
|
||||
--color-teal-200: #afecef;
|
||||
--color-teal-300: #7edce2;
|
||||
--color-teal-400: #16bdca;
|
||||
--color-teal-500: #0694a2;
|
||||
--color-teal-600: #047481;
|
||||
--color-teal-700: #036672;
|
||||
--color-teal-800: #05505c;
|
||||
--color-teal-900: #014451;
|
||||
|
||||
/* Custom blue palette */
|
||||
--color-blue-50: #ebf5ff;
|
||||
--color-blue-100: #e1effe;
|
||||
--color-blue-200: #c3ddfd;
|
||||
--color-blue-300: #a4cafe;
|
||||
--color-blue-400: #76a9fa;
|
||||
--color-blue-500: #3f83f8;
|
||||
--color-blue-600: #1c64f2;
|
||||
--color-blue-700: #1a56db;
|
||||
--color-blue-800: #1e429f;
|
||||
--color-blue-900: #233876;
|
||||
|
||||
/* Custom indigo palette */
|
||||
--color-indigo-50: #f0f5ff;
|
||||
--color-indigo-100: #e5edff;
|
||||
--color-indigo-200: #cddbfe;
|
||||
--color-indigo-300: #b4c6fc;
|
||||
--color-indigo-400: #8da2fb;
|
||||
--color-indigo-500: #6875f5;
|
||||
--color-indigo-600: #5850ec;
|
||||
--color-indigo-700: #5145cd;
|
||||
--color-indigo-800: #42389d;
|
||||
--color-indigo-900: #362f78;
|
||||
|
||||
/* Custom pink palette */
|
||||
--color-pink-50: #fdf2f8;
|
||||
--color-pink-100: #fce8f3;
|
||||
--color-pink-200: #fad1e8;
|
||||
--color-pink-300: #f8b4d9;
|
||||
--color-pink-400: #f17eb8;
|
||||
--color-pink-500: #e74694;
|
||||
--color-pink-600: #d61f69;
|
||||
--color-pink-700: #bf125d;
|
||||
--color-pink-800: #99154b;
|
||||
--color-pink-900: #751a3d;
|
||||
|
||||
/* Cool gray palette */
|
||||
--color-cool-gray-50: #fbfdfe;
|
||||
--color-cool-gray-100: #f1f5f9;
|
||||
--color-cool-gray-200: #e2e8f0;
|
||||
--color-cool-gray-300: #cfd8e3;
|
||||
--color-cool-gray-400: #97a6ba;
|
||||
--color-cool-gray-500: #64748b;
|
||||
--color-cool-gray-600: #475569;
|
||||
--color-cool-gray-700: #364152;
|
||||
--color-cool-gray-800: #27303f;
|
||||
--color-cool-gray-900: #1a202e;
|
||||
|
||||
/* Font family */
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
/* Custom max-height */
|
||||
--spacing-xl: 36rem;
|
||||
}
|
||||
|
||||
/* Dark mode variant - uses class on html element */
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Custom utilities layer */
|
||||
@layer utilities {
|
||||
/* Shadow outline utilities for focus states */
|
||||
.shadow-outline-gray {
|
||||
box-shadow: 0 0 0 3px hsla(220, 9%, 83%, 0.45);
|
||||
}
|
||||
.shadow-outline-purple {
|
||||
box-shadow: 0 0 0 3px hsla(262, 97%, 81%, 0.45);
|
||||
}
|
||||
.shadow-outline-red {
|
||||
box-shadow: 0 0 0 3px hsla(0, 91%, 85%, 0.45);
|
||||
}
|
||||
.shadow-outline-orange {
|
||||
box-shadow: 0 0 0 3px hsla(22, 97%, 77%, 0.45);
|
||||
}
|
||||
.shadow-outline-green {
|
||||
box-shadow: 0 0 0 3px hsla(152, 68%, 70%, 0.45);
|
||||
}
|
||||
.shadow-outline-blue {
|
||||
box-shadow: 0 0 0 3px hsla(215, 96%, 81%, 0.45);
|
||||
}
|
||||
.shadow-outline-yellow {
|
||||
box-shadow: 0 0 0 3px hsla(46, 97%, 65%, 0.45);
|
||||
}
|
||||
.shadow-outline-teal {
|
||||
box-shadow: 0 0 0 3px hsla(182, 68%, 69%, 0.45);
|
||||
}
|
||||
.shadow-outline-indigo {
|
||||
box-shadow: 0 0 0 3px hsla(226, 95%, 85%, 0.45);
|
||||
}
|
||||
.shadow-outline-pink {
|
||||
box-shadow: 0 0 0 3px hsla(330, 89%, 83%, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
/* Component layer for form styles (replaces @tailwindcss/custom-forms) */
|
||||
@layer components {
|
||||
/* Form input styles */
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select,
|
||||
.form-multiselect {
|
||||
@apply block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm transition-colors;
|
||||
@apply focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500;
|
||||
@apply placeholder:text-gray-400;
|
||||
}
|
||||
|
||||
/* Dark mode form inputs */
|
||||
.dark .form-input,
|
||||
.dark .form-textarea,
|
||||
.dark .form-select,
|
||||
.dark .form-multiselect {
|
||||
@apply border-gray-600 bg-gray-700 text-gray-200;
|
||||
@apply focus:border-purple-400 focus:ring-purple-400;
|
||||
@apply placeholder:text-gray-500;
|
||||
}
|
||||
|
||||
/* Checkbox styles */
|
||||
.form-checkbox {
|
||||
@apply h-4 w-4 rounded border-gray-300 text-purple-600 transition-colors;
|
||||
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.dark .form-checkbox {
|
||||
@apply border-gray-600 bg-gray-700;
|
||||
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
|
||||
}
|
||||
|
||||
/* Radio styles */
|
||||
.form-radio {
|
||||
@apply h-4 w-4 border-gray-300 text-purple-600 transition-colors;
|
||||
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.dark .form-radio {
|
||||
@apply border-gray-600 bg-gray-700;
|
||||
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
/* Base layer for default styles */
|
||||
@layer base {
|
||||
/* Default body styles */
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 antialiased;
|
||||
}
|
||||
|
||||
/* Dark mode body */
|
||||
.dark body {
|
||||
@apply bg-gray-900 text-gray-200;
|
||||
}
|
||||
|
||||
/* Focus visible for accessibility */
|
||||
:focus-visible {
|
||||
@apply outline-2 outline-offset-2 outline-purple-500;
|
||||
}
|
||||
|
||||
.dark :focus-visible {
|
||||
@apply outline-purple-400;
|
||||
}
|
||||
}
|
||||
69357
static/vendor/css/tailwind.output.css
vendored
69357
static/vendor/css/tailwind.output.css
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,233 +0,0 @@
|
||||
const defaultTheme = require('tailwindcss/defaultTheme')
|
||||
const plugin = require('tailwindcss/plugin')
|
||||
const Color = require('color')
|
||||
|
||||
module.exports = {
|
||||
purge: {
|
||||
content: [
|
||||
'public/**/*.html',
|
||||
'app/templates/**/*.html',
|
||||
'static/**/*.js',
|
||||
],
|
||||
// Safelist classes used dynamically in Alpine.js expressions
|
||||
safelist: [
|
||||
'bg-orange-600',
|
||||
'bg-green-600',
|
||||
'bg-red-600',
|
||||
'hover:bg-orange-700',
|
||||
'hover:bg-green-700',
|
||||
'hover:bg-red-700',
|
||||
],
|
||||
},
|
||||
theme: {
|
||||
themeVariants: ['dark'],
|
||||
customForms: (theme) => ({
|
||||
default: {
|
||||
'input, textarea': {
|
||||
'&::placeholder': {
|
||||
color: theme('colors.gray.400'),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
colors: {
|
||||
transparent: 'transparent',
|
||||
white: '#ffffff',
|
||||
black: '#000000',
|
||||
gray: {
|
||||
'50': '#f9fafb',
|
||||
'100': '#f4f5f7',
|
||||
'200': '#e5e7eb',
|
||||
'300': '#d5d6d7',
|
||||
'400': '#9e9e9e',
|
||||
'500': '#707275',
|
||||
'600': '#4c4f52',
|
||||
'700': '#24262d',
|
||||
'800': '#1a1c23',
|
||||
'900': '#121317',
|
||||
// default values from Tailwind UI palette
|
||||
// '300': '#d2d6dc',
|
||||
// '400': '#9fa6b2',
|
||||
// '500': '#6b7280',
|
||||
// '600': '#4b5563',
|
||||
// '700': '#374151',
|
||||
// '800': '#252f3f',
|
||||
// '900': '#161e2e',
|
||||
},
|
||||
'cool-gray': {
|
||||
'50': '#fbfdfe',
|
||||
'100': '#f1f5f9',
|
||||
'200': '#e2e8f0',
|
||||
'300': '#cfd8e3',
|
||||
'400': '#97a6ba',
|
||||
'500': '#64748b',
|
||||
'600': '#475569',
|
||||
'700': '#364152',
|
||||
'800': '#27303f',
|
||||
'900': '#1a202e',
|
||||
},
|
||||
red: {
|
||||
'50': '#fdf2f2',
|
||||
'100': '#fde8e8',
|
||||
'200': '#fbd5d5',
|
||||
'300': '#f8b4b4',
|
||||
'400': '#f98080',
|
||||
'500': '#f05252',
|
||||
'600': '#e02424',
|
||||
'700': '#c81e1e',
|
||||
'800': '#9b1c1c',
|
||||
'900': '#771d1d',
|
||||
},
|
||||
orange: {
|
||||
'50': '#fff8f1',
|
||||
'100': '#feecdc',
|
||||
'200': '#fcd9bd',
|
||||
'300': '#fdba8c',
|
||||
'400': '#ff8a4c',
|
||||
'500': '#ff5a1f',
|
||||
'600': '#d03801',
|
||||
'700': '#b43403',
|
||||
'800': '#8a2c0d',
|
||||
'900': '#771d1d',
|
||||
},
|
||||
yellow: {
|
||||
'50': '#fdfdea',
|
||||
'100': '#fdf6b2',
|
||||
'200': '#fce96a',
|
||||
'300': '#faca15',
|
||||
'400': '#e3a008',
|
||||
'500': '#c27803',
|
||||
'600': '#9f580a',
|
||||
'700': '#8e4b10',
|
||||
'800': '#723b13',
|
||||
'900': '#633112',
|
||||
},
|
||||
green: {
|
||||
'50': '#f3faf7',
|
||||
'100': '#def7ec',
|
||||
'200': '#bcf0da',
|
||||
'300': '#84e1bc',
|
||||
'400': '#31c48d',
|
||||
'500': '#0e9f6e',
|
||||
'600': '#057a55',
|
||||
'700': '#046c4e',
|
||||
'800': '#03543f',
|
||||
'900': '#014737',
|
||||
},
|
||||
teal: {
|
||||
'50': '#edfafa',
|
||||
'100': '#d5f5f6',
|
||||
'200': '#afecef',
|
||||
'300': '#7edce2',
|
||||
'400': '#16bdca',
|
||||
'500': '#0694a2',
|
||||
'600': '#047481',
|
||||
'700': '#036672',
|
||||
'800': '#05505c',
|
||||
'900': '#014451',
|
||||
},
|
||||
blue: {
|
||||
'50': '#ebf5ff',
|
||||
'100': '#e1effe',
|
||||
'200': '#c3ddfd',
|
||||
'300': '#a4cafe',
|
||||
'400': '#76a9fa',
|
||||
'500': '#3f83f8',
|
||||
'600': '#1c64f2',
|
||||
'700': '#1a56db',
|
||||
'800': '#1e429f',
|
||||
'900': '#233876',
|
||||
},
|
||||
indigo: {
|
||||
'50': '#f0f5ff',
|
||||
'100': '#e5edff',
|
||||
'200': '#cddbfe',
|
||||
'300': '#b4c6fc',
|
||||
'400': '#8da2fb',
|
||||
'500': '#6875f5',
|
||||
'600': '#5850ec',
|
||||
'700': '#5145cd',
|
||||
'800': '#42389d',
|
||||
'900': '#362f78',
|
||||
},
|
||||
purple: {
|
||||
'50': '#f6f5ff',
|
||||
'100': '#edebfe',
|
||||
'200': '#dcd7fe',
|
||||
'300': '#cabffd',
|
||||
'400': '#ac94fa',
|
||||
'500': '#9061f9',
|
||||
'600': '#7e3af2',
|
||||
'700': '#6c2bd9',
|
||||
'800': '#5521b5',
|
||||
'900': '#4a1d96',
|
||||
},
|
||||
pink: {
|
||||
'50': '#fdf2f8',
|
||||
'100': '#fce8f3',
|
||||
'200': '#fad1e8',
|
||||
'300': '#f8b4d9',
|
||||
'400': '#f17eb8',
|
||||
'500': '#e74694',
|
||||
'600': '#d61f69',
|
||||
'700': '#bf125d',
|
||||
'800': '#99154b',
|
||||
'900': '#751a3d',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
maxHeight: {
|
||||
'0': '0',
|
||||
xl: '36rem',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', ...defaultTheme.fontFamily.sans],
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
backgroundColor: [
|
||||
'hover',
|
||||
'focus',
|
||||
'active',
|
||||
'odd',
|
||||
'dark',
|
||||
'dark:hover',
|
||||
'dark:focus',
|
||||
'dark:active',
|
||||
'dark:odd',
|
||||
],
|
||||
display: ['responsive', 'dark'],
|
||||
textColor: [
|
||||
'focus-within',
|
||||
'hover',
|
||||
'active',
|
||||
'dark',
|
||||
'dark:focus-within',
|
||||
'dark:hover',
|
||||
'dark:active',
|
||||
],
|
||||
placeholderColor: ['focus', 'dark', 'dark:focus'],
|
||||
borderColor: ['focus', 'hover', 'dark', 'dark:focus', 'dark:hover'],
|
||||
divideColor: ['dark'],
|
||||
boxShadow: ['focus', 'dark:focus'],
|
||||
},
|
||||
plugins: [
|
||||
require('tailwindcss-multi-theme'),
|
||||
require('@tailwindcss/custom-forms'),
|
||||
plugin(({ addUtilities, e, theme, variants }) => {
|
||||
const newUtilities = {}
|
||||
Object.entries(theme('colors')).map(([name, value]) => {
|
||||
if (name === 'transparent' || name === 'current') return
|
||||
const color = value[300] ? value[300] : value
|
||||
const hsla = Color(color).alpha(0.45).hsl().string()
|
||||
|
||||
newUtilities[`.shadow-outline-${name}`] = {
|
||||
'box-shadow': `0 0 0 3px ${hsla}`,
|
||||
}
|
||||
})
|
||||
|
||||
addUtilities(newUtilities, variants('boxShadow'))
|
||||
}),
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user