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: |
|
description: |
|
||||||
Protected endpoints must use Depends() for authentication.
|
Protected endpoints must use Depends() for authentication.
|
||||||
Use get_current_user, get_current_admin, etc.
|
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:
|
pattern:
|
||||||
file_pattern: "app/api/v1/**/*.py"
|
file_pattern: "app/api/v1/**/*.py"
|
||||||
required_if_not_public:
|
required_if_not_public:
|
||||||
- "Depends(get_current_"
|
- "Depends(get_current_"
|
||||||
|
auto_exclude_files:
|
||||||
|
- "*/auth.py"
|
||||||
|
public_markers:
|
||||||
|
- "# public"
|
||||||
|
- "# noqa: api-004"
|
||||||
|
|
||||||
- id: "API-005"
|
- id: "API-005"
|
||||||
name: "Multi-tenant endpoints must scope queries to vendor_id"
|
name: "Multi-tenant endpoints must scope queries to vendor_id"
|
||||||
@@ -466,11 +484,30 @@ template_rules:
|
|||||||
- id: "TPL-001"
|
- id: "TPL-001"
|
||||||
name: "Admin templates must extend admin/base.html"
|
name: "Admin templates must extend admin/base.html"
|
||||||
severity: "error"
|
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:
|
pattern:
|
||||||
file_pattern: "app/templates/admin/**/*.html"
|
file_pattern: "app/templates/admin/**/*.html"
|
||||||
required_patterns:
|
required_patterns:
|
||||||
- "{% extends ['\"]admin/base\\.html['\"] %}"
|
- "{% extends ['\"]admin/base\\.html['\"] %}"
|
||||||
|
auto_exclude_files:
|
||||||
|
- "login.html"
|
||||||
|
- "errors/"
|
||||||
|
- "test-"
|
||||||
|
standalone_markers:
|
||||||
|
- "{# standalone #}"
|
||||||
|
- "{# noqa: tpl-001 #}"
|
||||||
|
- "<!-- standalone -->"
|
||||||
exceptions:
|
exceptions:
|
||||||
- "base.html"
|
- "base.html"
|
||||||
- "partials/"
|
- "partials/"
|
||||||
@@ -660,6 +697,37 @@ auth_rules:
|
|||||||
file_pattern: "app/services/auth_service.py"
|
file_pattern: "app/services/auth_service.py"
|
||||||
required: "bcrypt"
|
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
|
# CODE QUALITY RULES
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
45
Makefile
45
Makefile
@@ -1,7 +1,7 @@
|
|||||||
# Wizamart Multi-Tenant E-Commerce Platform Makefile
|
# Wizamart Multi-Tenant E-Commerce Platform Makefile
|
||||||
# Cross-platform compatible (Windows & Linux)
|
# 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
|
# Detect OS
|
||||||
ifeq ($(OS),Windows_NT)
|
ifeq ($(OS),Windows_NT)
|
||||||
@@ -282,29 +282,40 @@ docs-check:
|
|||||||
$(PYTHON) -m mkdocs build --strict --verbose
|
$(PYTHON) -m mkdocs build --strict --verbose
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# FRONTEND / TAILWIND CSS
|
# FRONTEND / TAILWIND CSS (Standalone CLI - No Node.js Required)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
npm-install:
|
# Tailwind CLI binary location
|
||||||
@echo "Installing npm dependencies..."
|
TAILWIND_CLI := $(HOME)/.local/bin/tailwindcss
|
||||||
npm install
|
|
||||||
@echo "npm dependencies installed"
|
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:
|
tailwind-dev:
|
||||||
@echo "Building Tailwind CSS (development - all classes)..."
|
@echo "Building Tailwind CSS (development)..."
|
||||||
npm run tailwind:admin
|
$(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css
|
||||||
npm run tailwind:vendor
|
$(TAILWIND_CLI) -i static/vendor/css/tailwind.css -o static/vendor/css/tailwind.output.css
|
||||||
@echo "Tailwind CSS built (admin + vendor)"
|
$(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:
|
tailwind-build:
|
||||||
@echo "Building Tailwind CSS (production - purged)..."
|
@echo "Building Tailwind CSS (production - minified)..."
|
||||||
npm run build
|
$(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --minify
|
||||||
@echo "Tailwind CSS built for production"
|
$(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:
|
tailwind-watch:
|
||||||
@echo "Watching Tailwind CSS for changes..."
|
@echo "Watching Tailwind CSS for changes..."
|
||||||
@echo "Note: This watches admin CSS only. Run in separate terminal."
|
@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
|
# DOCKER
|
||||||
@@ -425,10 +436,10 @@ help:
|
|||||||
@echo " docs-serve - Start documentation server"
|
@echo " docs-serve - Start documentation server"
|
||||||
@echo " docs-build - Build documentation"
|
@echo " docs-build - Build documentation"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "=== FRONTEND / TAILWIND ==="
|
@echo "=== FRONTEND / TAILWIND (No Node.js Required) ==="
|
||||||
@echo " npm-install - Install npm dependencies"
|
@echo " tailwind-install - Install Tailwind standalone CLI"
|
||||||
@echo " tailwind-dev - Build Tailwind CSS (development)"
|
@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 " tailwind-watch - Watch and rebuild on changes"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "=== DOCKER ==="
|
@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 (
|
from app.exceptions import (
|
||||||
AdminRequiredException,
|
AdminRequiredException,
|
||||||
InsufficientPermissionsException,
|
InsufficientPermissionsException,
|
||||||
|
InsufficientVendorPermissionsException,
|
||||||
InvalidTokenException,
|
InvalidTokenException,
|
||||||
UnauthorizedVendorAccessException,
|
UnauthorizedVendorAccessException,
|
||||||
|
VendorAccessDeniedException,
|
||||||
VendorNotFoundException,
|
VendorNotFoundException,
|
||||||
|
VendorOwnerOnlyException,
|
||||||
)
|
)
|
||||||
|
from app.services.vendor_service import vendor_service
|
||||||
from middleware.auth import AuthManager
|
from middleware.auth import AuthManager
|
||||||
from middleware.rate_limiter import RateLimiter
|
from middleware.rate_limiter import RateLimiter
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
@@ -545,12 +549,16 @@ def require_vendor_permission(permission: str):
|
|||||||
"""
|
"""
|
||||||
Dependency factory to require a specific vendor permission.
|
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:
|
Usage:
|
||||||
@router.get("/products")
|
@router.get("/products")
|
||||||
def list_products(
|
def list_products(
|
||||||
vendor: Vendor = Depends(get_vendor_from_code),
|
request: Request,
|
||||||
user: User = Depends(require_vendor_permission(VendorPermissions.PRODUCTS_VIEW.value))
|
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),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
||||||
) -> User:
|
) -> User:
|
||||||
# Get vendor from request state (set by middleware)
|
# Get vendor ID from JWT token
|
||||||
vendor = getattr(request.state, "vendor", None)
|
if not hasattr(current_user, "token_vendor_id"):
|
||||||
if not vendor:
|
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||||
raise VendorAccessDeniedException("No vendor context")
|
|
||||||
|
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
|
# Check if user has permission
|
||||||
if not current_user.has_vendor_permission(vendor.id, 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.
|
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:
|
Usage:
|
||||||
@router.delete("/team/{user_id}")
|
@router.delete("/team/{user_id}")
|
||||||
def remove_team_member(
|
def remove_team_member(
|
||||||
|
request: Request,
|
||||||
user: User = Depends(require_vendor_owner)
|
user: User = Depends(require_vendor_owner)
|
||||||
):
|
):
|
||||||
|
vendor = request.state.vendor # Vendor is set by this dependency
|
||||||
...
|
...
|
||||||
"""
|
"""
|
||||||
vendor = getattr(request.state, "vendor", None)
|
# Get vendor ID from JWT token
|
||||||
if not vendor:
|
if not hasattr(current_user, "token_vendor_id"):
|
||||||
raise VendorAccessDeniedException("No vendor context")
|
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):
|
if not current_user.is_owner_of(vendor.id):
|
||||||
raise VendorOwnerOnlyException(
|
raise VendorOwnerOnlyException(
|
||||||
@@ -608,14 +636,19 @@ def require_any_vendor_permission(*permissions: str):
|
|||||||
"""
|
"""
|
||||||
Dependency factory to require ANY of the specified permissions.
|
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:
|
Usage:
|
||||||
@router.get("/dashboard")
|
@router.get("/dashboard")
|
||||||
def dashboard(
|
def dashboard(
|
||||||
|
request: Request,
|
||||||
user: User = Depends(require_any_vendor_permission(
|
user: User = Depends(require_any_vendor_permission(
|
||||||
VendorPermissions.DASHBOARD_VIEW.value,
|
VendorPermissions.DASHBOARD_VIEW.value,
|
||||||
VendorPermissions.REPORTS_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),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
||||||
) -> User:
|
) -> User:
|
||||||
vendor = getattr(request.state, "vendor", None)
|
# Get vendor ID from JWT token
|
||||||
if not vendor:
|
if not hasattr(current_user, "token_vendor_id"):
|
||||||
raise VendorAccessDeniedException("No vendor context")
|
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
|
# Check if user has ANY of the required permissions
|
||||||
has_permission = any(
|
has_permission = any(
|
||||||
@@ -648,14 +689,19 @@ def require_all_vendor_permissions(*permissions: str):
|
|||||||
"""
|
"""
|
||||||
Dependency factory to require ALL of the specified permissions.
|
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:
|
Usage:
|
||||||
@router.post("/products/bulk-delete")
|
@router.post("/products/bulk-delete")
|
||||||
def bulk_delete_products(
|
def bulk_delete_products(
|
||||||
|
request: Request,
|
||||||
user: User = Depends(require_all_vendor_permissions(
|
user: User = Depends(require_all_vendor_permissions(
|
||||||
VendorPermissions.PRODUCTS_VIEW.value,
|
VendorPermissions.PRODUCTS_VIEW.value,
|
||||||
VendorPermissions.PRODUCTS_DELETE.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),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
||||||
) -> User:
|
) -> User:
|
||||||
vendor = getattr(request.state, "vendor", None)
|
# Get vendor ID from JWT token
|
||||||
if not vendor:
|
if not hasattr(current_user, "token_vendor_id"):
|
||||||
raise VendorAccessDeniedException("No vendor context")
|
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
|
# Check if user has ALL required permissions
|
||||||
missing_permissions = [
|
missing_permissions = [
|
||||||
@@ -688,17 +742,29 @@ def require_all_vendor_permissions(*permissions: str):
|
|||||||
|
|
||||||
def get_user_permissions(
|
def get_user_permissions(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
||||||
) -> list:
|
) -> list:
|
||||||
"""
|
"""
|
||||||
Get all permissions for current user in current vendor.
|
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)
|
# Get vendor ID from JWT token
|
||||||
if not vendor:
|
if not hasattr(current_user, "token_vendor_id"):
|
||||||
return []
|
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 owner, return all permissions
|
||||||
if current_user.is_owner_of(vendor.id):
|
if current_user.is_owner_of(vendor.id):
|
||||||
from app.core.permissions import VendorPermissions
|
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.exceptions import InvalidCredentialsException
|
||||||
from app.services.auth_service import auth_service
|
from app.services.auth_service import auth_service
|
||||||
from models.database.user import User
|
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")
|
router = APIRouter(prefix="/auth")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -97,7 +97,7 @@ def get_current_admin(current_user: User = Depends(get_current_admin_api)):
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout", response_model=LogoutResponse)
|
||||||
def admin_logout(response: Response):
|
def admin_logout(response: Response):
|
||||||
"""
|
"""
|
||||||
Admin logout endpoint.
|
Admin logout endpoint.
|
||||||
@@ -115,4 +115,4 @@ def admin_logout(response: Response):
|
|||||||
|
|
||||||
logger.debug("Deleted admin_token cookie")
|
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 datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_admin_api
|
from app.api.deps import get_current_admin_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.exceptions import ViolationNotFoundException
|
||||||
from app.services.code_quality_service import code_quality_service
|
from app.services.code_quality_service import code_quality_service
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
|
|
||||||
@@ -136,25 +137,23 @@ async def trigger_scan(
|
|||||||
Trigger a new architecture scan
|
Trigger a new architecture scan
|
||||||
|
|
||||||
Requires authentication. Runs the validator script and stores results.
|
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(
|
||||||
scan = code_quality_service.run_scan(
|
db, triggered_by=f"manual:{current_user.username}"
|
||||||
db, triggered_by=f"manual:{current_user.username}"
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return ScanResponse(
|
return ScanResponse(
|
||||||
id=scan.id,
|
id=scan.id,
|
||||||
timestamp=scan.timestamp.isoformat(),
|
timestamp=scan.timestamp.isoformat(),
|
||||||
total_files=scan.total_files,
|
total_files=scan.total_files,
|
||||||
total_violations=scan.total_violations,
|
total_violations=scan.total_violations,
|
||||||
errors=scan.errors,
|
errors=scan.errors,
|
||||||
warnings=scan.warnings,
|
warnings=scan.warnings,
|
||||||
duration_seconds=scan.duration_seconds,
|
duration_seconds=scan.duration_seconds,
|
||||||
triggered_by=scan.triggered_by,
|
triggered_by=scan.triggered_by,
|
||||||
git_commit_hash=scan.git_commit_hash,
|
git_commit_hash=scan.git_commit_hash,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Scan failed: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/scans", response_model=list[ScanResponse])
|
@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)
|
violation = code_quality_service.get_violation_by_id(db, violation_id)
|
||||||
|
|
||||||
if not violation:
|
if not violation:
|
||||||
raise HTTPException(status_code=404, detail="Violation not found")
|
raise ViolationNotFoundException(violation_id)
|
||||||
|
|
||||||
# Format assignments
|
# Format assignments
|
||||||
assignments = [
|
assignments = [
|
||||||
@@ -331,29 +330,26 @@ async def assign_violation(
|
|||||||
|
|
||||||
Updates violation status to 'assigned'.
|
Updates violation status to 'assigned'.
|
||||||
"""
|
"""
|
||||||
try:
|
assignment = code_quality_service.assign_violation(
|
||||||
assignment = code_quality_service.assign_violation(
|
db,
|
||||||
db,
|
violation_id=violation_id,
|
||||||
violation_id=violation_id,
|
user_id=request.user_id,
|
||||||
user_id=request.user_id,
|
assigned_by=current_user.id,
|
||||||
assigned_by=current_user.id,
|
due_date=request.due_date,
|
||||||
due_date=request.due_date,
|
priority=request.priority,
|
||||||
priority=request.priority,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": assignment.id,
|
"id": assignment.id,
|
||||||
"violation_id": assignment.violation_id,
|
"violation_id": assignment.violation_id,
|
||||||
"user_id": assignment.user_id,
|
"user_id": assignment.user_id,
|
||||||
"assigned_at": assignment.assigned_at.isoformat(),
|
"assigned_at": assignment.assigned_at.isoformat(),
|
||||||
"assigned_by": assignment.assigned_by,
|
"assigned_by": assignment.assigned_by,
|
||||||
"due_date": (
|
"due_date": (
|
||||||
assignment.due_date.isoformat() if assignment.due_date else None
|
assignment.due_date.isoformat() if assignment.due_date else None
|
||||||
),
|
),
|
||||||
"priority": assignment.priority,
|
"priority": assignment.priority,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/violations/{violation_id}/resolve")
|
@router.post("/violations/{violation_id}/resolve")
|
||||||
@@ -367,28 +363,24 @@ async def resolve_violation(
|
|||||||
Mark violation as resolved
|
Mark violation as resolved
|
||||||
|
|
||||||
Records resolution timestamp and user.
|
Records resolution timestamp and user.
|
||||||
|
ViolationNotFoundException bubbles up if violation doesn't exist.
|
||||||
"""
|
"""
|
||||||
try:
|
violation = code_quality_service.resolve_violation(
|
||||||
violation = code_quality_service.resolve_violation(
|
db,
|
||||||
db,
|
violation_id=violation_id,
|
||||||
violation_id=violation_id,
|
resolved_by=current_user.id,
|
||||||
resolved_by=current_user.id,
|
resolution_note=request.resolution_note,
|
||||||
resolution_note=request.resolution_note,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": violation.id,
|
"id": violation.id,
|
||||||
"status": violation.status,
|
"status": violation.status,
|
||||||
"resolved_at": (
|
"resolved_at": (
|
||||||
violation.resolved_at.isoformat() if violation.resolved_at else None
|
violation.resolved_at.isoformat() if violation.resolved_at else None
|
||||||
),
|
),
|
||||||
"resolved_by": violation.resolved_by,
|
"resolved_by": violation.resolved_by,
|
||||||
"resolution_note": violation.resolution_note,
|
"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))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/violations/{violation_id}/ignore")
|
@router.post("/violations/{violation_id}/ignore")
|
||||||
@@ -402,28 +394,24 @@ async def ignore_violation(
|
|||||||
Mark violation as ignored (won't fix)
|
Mark violation as ignored (won't fix)
|
||||||
|
|
||||||
Records reason for ignoring.
|
Records reason for ignoring.
|
||||||
|
ViolationNotFoundException bubbles up if violation doesn't exist.
|
||||||
"""
|
"""
|
||||||
try:
|
violation = code_quality_service.ignore_violation(
|
||||||
violation = code_quality_service.ignore_violation(
|
db,
|
||||||
db,
|
violation_id=violation_id,
|
||||||
violation_id=violation_id,
|
ignored_by=current_user.id,
|
||||||
ignored_by=current_user.id,
|
reason=request.reason,
|
||||||
reason=request.reason,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": violation.id,
|
"id": violation.id,
|
||||||
"status": violation.status,
|
"status": violation.status,
|
||||||
"resolved_at": (
|
"resolved_at": (
|
||||||
violation.resolved_at.isoformat() if violation.resolved_at else None
|
violation.resolved_at.isoformat() if violation.resolved_at else None
|
||||||
),
|
),
|
||||||
"resolved_by": violation.resolved_by,
|
"resolved_by": violation.resolved_by,
|
||||||
"resolution_note": violation.resolution_note,
|
"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))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/violations/{violation_id}/comments")
|
@router.post("/violations/{violation_id}/comments")
|
||||||
@@ -438,23 +426,20 @@ async def add_comment(
|
|||||||
|
|
||||||
For team collaboration and discussion.
|
For team collaboration and discussion.
|
||||||
"""
|
"""
|
||||||
try:
|
comment = code_quality_service.add_comment(
|
||||||
comment = code_quality_service.add_comment(
|
db,
|
||||||
db,
|
violation_id=violation_id,
|
||||||
violation_id=violation_id,
|
user_id=current_user.id,
|
||||||
user_id=current_user.id,
|
comment=request.comment,
|
||||||
comment=request.comment,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": comment.id,
|
"id": comment.id,
|
||||||
"violation_id": comment.violation_id,
|
"violation_id": comment.violation_id,
|
||||||
"user_id": comment.user_id,
|
"user_id": comment.user_id,
|
||||||
"comment": comment.comment,
|
"comment": comment.comment,
|
||||||
"created_at": comment.created_at.isoformat(),
|
"created_at": comment.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats", response_model=DashboardStatsResponse)
|
@router.get("/stats", response_model=DashboardStatsResponse)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Platform administrators can:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -170,28 +170,9 @@ def list_all_pages(
|
|||||||
|
|
||||||
Filter by vendor_id to see specific vendor pages.
|
Filter by vendor_id to see specific vendor pages.
|
||||||
"""
|
"""
|
||||||
if vendor_id:
|
pages = content_page_service.list_all_pages(
|
||||||
pages = content_page_service.list_all_vendor_pages(
|
db, vendor_id=vendor_id, include_unpublished=include_unpublished
|
||||||
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()
|
|
||||||
)
|
|
||||||
|
|
||||||
return [page.to_dict() for page in pages]
|
return [page.to_dict() for page in pages]
|
||||||
|
|
||||||
@@ -203,11 +184,7 @@ def get_page(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get a specific content page by ID."""
|
"""Get a specific content page by ID."""
|
||||||
page = content_page_service.get_page_by_id(db, page_id)
|
page = content_page_service.get_page_by_id_or_raise(db, page_id)
|
||||||
|
|
||||||
if not page:
|
|
||||||
raise HTTPException(status_code=404, detail="Content page not found")
|
|
||||||
|
|
||||||
return page.to_dict()
|
return page.to_dict()
|
||||||
|
|
||||||
|
|
||||||
@@ -219,7 +196,7 @@ def update_page(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Update a content page (platform or vendor)."""
|
"""Update a content page (platform or vendor)."""
|
||||||
page = content_page_service.update_page(
|
page = content_page_service.update_page_or_raise(
|
||||||
db,
|
db,
|
||||||
page_id=page_id,
|
page_id=page_id,
|
||||||
title=page_data.title,
|
title=page_data.title,
|
||||||
@@ -234,10 +211,6 @@ def update_page(
|
|||||||
display_order=page_data.display_order,
|
display_order=page_data.display_order,
|
||||||
updated_by=current_user.id,
|
updated_by=current_user.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not page:
|
|
||||||
raise HTTPException(status_code=404, detail="Content page not found")
|
|
||||||
|
|
||||||
return page.to_dict()
|
return page.to_dict()
|
||||||
|
|
||||||
|
|
||||||
@@ -248,9 +221,4 @@ def delete_page(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Delete a content page."""
|
"""Delete a content page."""
|
||||||
success = content_page_service.delete_page(db, page_id)
|
content_page_service.delete_page_or_raise(db, page_id)
|
||||||
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(status_code=404, detail="Content page not found")
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.api.deps import get_current_admin_api
|
from app.api.deps import get_current_admin_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.logging import reload_log_level
|
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_audit_service import admin_audit_service
|
||||||
from app.services.admin_settings_service import admin_settings_service
|
from app.services.admin_settings_service import admin_settings_service
|
||||||
from app.services.log_service import log_service
|
from app.services.log_service import log_service
|
||||||
@@ -26,8 +27,12 @@ from models.schema.admin import (
|
|||||||
ApplicationLogFilters,
|
ApplicationLogFilters,
|
||||||
ApplicationLogListResponse,
|
ApplicationLogListResponse,
|
||||||
FileLogResponse,
|
FileLogResponse,
|
||||||
|
LogCleanupResponse,
|
||||||
|
LogDeleteResponse,
|
||||||
|
LogFileListResponse,
|
||||||
LogSettingsResponse,
|
LogSettingsResponse,
|
||||||
LogSettingsUpdate,
|
LogSettingsUpdate,
|
||||||
|
LogSettingsUpdateResponse,
|
||||||
LogStatistics,
|
LogStatistics,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,7 +92,7 @@ def get_log_statistics(
|
|||||||
return log_service.get_log_statistics(db, days)
|
return log_service.get_log_statistics(db, days)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/database/cleanup")
|
@router.delete("/database/cleanup", response_model=LogCleanupResponse)
|
||||||
def cleanup_old_logs(
|
def cleanup_old_logs(
|
||||||
retention_days: int = Query(30, ge=1, le=365),
|
retention_days: int = Query(30, ge=1, le=365),
|
||||||
confirm: bool = Query(False, description="Must be true to confirm cleanup"),
|
confirm: bool = Query(False, description="Must be true to confirm cleanup"),
|
||||||
@@ -99,13 +104,8 @@ def cleanup_old_logs(
|
|||||||
|
|
||||||
Requires confirmation parameter.
|
Requires confirmation parameter.
|
||||||
"""
|
"""
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
if not confirm:
|
if not confirm:
|
||||||
raise HTTPException(
|
raise ConfirmationRequiredException(operation="cleanup_logs")
|
||||||
status_code=400,
|
|
||||||
detail="Cleanup requires confirmation parameter: confirm=true",
|
|
||||||
)
|
|
||||||
|
|
||||||
deleted_count = log_service.cleanup_old_logs(db, retention_days)
|
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},
|
details={"retention_days": retention_days, "deleted_count": deleted_count},
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return LogCleanupResponse(
|
||||||
"message": f"Deleted {deleted_count} log entries older than {retention_days} days",
|
message=f"Deleted {deleted_count} log entries older than {retention_days} days",
|
||||||
"deleted_count": deleted_count,
|
deleted_count=deleted_count,
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/database/{log_id}")
|
@router.delete("/database/{log_id}", response_model=LogDeleteResponse)
|
||||||
def delete_log(
|
def delete_log(
|
||||||
log_id: int,
|
log_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -144,7 +144,7 @@ def delete_log(
|
|||||||
details={},
|
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(
|
def list_log_files(
|
||||||
current_admin: User = Depends(get_current_admin_api),
|
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.
|
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)
|
@router.get("/files/{filename}", response_model=FileLogResponse)
|
||||||
@@ -191,7 +191,6 @@ def download_log_file(
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from fastapi import HTTPException
|
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
# Determine log file path
|
# Determine log file path
|
||||||
@@ -202,7 +201,7 @@ def download_log_file(
|
|||||||
log_file = Path("logs") / filename
|
log_file = Path("logs") / filename
|
||||||
|
|
||||||
if not log_file.exists():
|
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
|
# Log action
|
||||||
from app.core.database import get_db
|
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(
|
def update_log_settings(
|
||||||
settings_update: LogSettingsUpdate,
|
settings_update: LogSettingsUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -335,8 +334,8 @@ def update_log_settings(
|
|||||||
details={"updated_fields": updated},
|
details={"updated_fields": updated},
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return LogSettingsUpdateResponse(
|
||||||
"message": "Log settings updated successfully",
|
message="Log settings updated successfully",
|
||||||
"updated_fields": updated,
|
updated_fields=updated,
|
||||||
"note": "Log level changes are applied immediately. File rotation settings require restart.",
|
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.api.deps import get_current_admin_api
|
||||||
from app.core.database import get_db
|
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_audit_service import admin_audit_service
|
||||||
from app.services.admin_settings_service import admin_settings_service
|
from app.services.admin_settings_service import admin_settings_service
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
@@ -78,9 +79,9 @@ def get_setting(
|
|||||||
setting = admin_settings_service.get_setting_by_key(db, key)
|
setting = admin_settings_service.get_setting_by_key(db, key)
|
||||||
|
|
||||||
if not setting:
|
if not setting:
|
||||||
from fastapi import HTTPException
|
raise ResourceNotFoundException(
|
||||||
|
resource_type="Setting", identifier=key
|
||||||
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
|
)
|
||||||
|
|
||||||
return AdminSettingResponse.model_validate(setting)
|
return AdminSettingResponse.model_validate(setting)
|
||||||
|
|
||||||
@@ -184,12 +185,10 @@ def delete_setting(
|
|||||||
Requires confirmation parameter.
|
Requires confirmation parameter.
|
||||||
WARNING: Deleting settings may affect platform functionality.
|
WARNING: Deleting settings may affect platform functionality.
|
||||||
"""
|
"""
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
if not confirm:
|
if not confirm:
|
||||||
raise HTTPException(
|
raise ConfirmationRequiredException(
|
||||||
status_code=400,
|
operation="delete_setting",
|
||||||
detail="Deletion requires confirmation parameter: confirm=true",
|
message="Deletion requires confirmation parameter: confirm=true",
|
||||||
)
|
)
|
||||||
|
|
||||||
message = admin_settings_service.delete_setting(
|
message = admin_settings_service.delete_setting(
|
||||||
|
|||||||
@@ -1,25 +1,30 @@
|
|||||||
# app/api/v1/admin/users.py
|
# app/api/v1/admin/users.py
|
||||||
"""
|
"""
|
||||||
User management endpoints for admin.
|
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 logging
|
||||||
import math
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_admin_api
|
from app.api.deps import get_current_admin_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.services.admin_service import admin_service
|
from app.services.admin_service import admin_service
|
||||||
from middleware.auth import AuthManager
|
|
||||||
from app.services.stats_service import stats_service
|
from app.services.stats_service import stats_service
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
from models.schema.auth import (
|
from models.schema.auth import (
|
||||||
UserCreate,
|
UserCreate,
|
||||||
|
UserDeleteResponse,
|
||||||
UserDetailResponse,
|
UserDetailResponse,
|
||||||
UserListResponse,
|
UserListResponse,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
|
UserSearchResponse,
|
||||||
|
UserStatusToggleResponse,
|
||||||
UserUpdate,
|
UserUpdate,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,31 +43,19 @@ def get_all_users(
|
|||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
):
|
||||||
"""Get paginated list of all users (Admin only)."""
|
"""Get paginated list of all users (Admin only)."""
|
||||||
query = db.query(User)
|
# Convert string params to proper types
|
||||||
|
is_active_bool = None
|
||||||
# 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)
|
|
||||||
|
|
||||||
if is_active:
|
if is_active:
|
||||||
query = query.filter(User.is_active == (is_active.lower() == "true"))
|
is_active_bool = is_active.lower() == "true"
|
||||||
|
|
||||||
# Get total count
|
users, total, pages = admin_service.list_users(
|
||||||
total = query.count()
|
db=db,
|
||||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
# Apply pagination
|
search=search if search else None,
|
||||||
skip = (page - 1) * per_page
|
role=role if role else None,
|
||||||
users = query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all()
|
is_active=is_active_bool,
|
||||||
|
)
|
||||||
|
|
||||||
return UserListResponse(
|
return UserListResponse(
|
||||||
items=[UserResponse.model_validate(user) for user in users],
|
items=[UserResponse.model_validate(user) for user in users],
|
||||||
@@ -80,30 +73,16 @@ def create_user(
|
|||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
):
|
||||||
"""Create a new user (Admin only)."""
|
"""Create a new user (Admin only)."""
|
||||||
# Check if email exists
|
user = admin_service.create_user(
|
||||||
if db.query(User).filter(User.email == user_data.email).first():
|
db=db,
|
||||||
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(
|
|
||||||
email=user_data.email,
|
email=user_data.email,
|
||||||
username=user_data.username,
|
username=user_data.username,
|
||||||
hashed_password=auth_manager.hash_password(user_data.password),
|
password=user_data.password,
|
||||||
first_name=user_data.first_name,
|
first_name=user_data.first_name,
|
||||||
last_name=user_data.last_name,
|
last_name=user_data.last_name,
|
||||||
role=user_data.role,
|
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(
|
return UserDetailResponse(
|
||||||
id=user.id,
|
id=user.id,
|
||||||
@@ -118,8 +97,8 @@ def create_user(
|
|||||||
last_name=user.last_name,
|
last_name=user.last_name,
|
||||||
full_name=user.full_name,
|
full_name=user.full_name,
|
||||||
is_email_verified=user.is_email_verified,
|
is_email_verified=user.is_email_verified,
|
||||||
owned_companies_count=len(user.owned_companies),
|
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
|
||||||
vendor_memberships_count=len(user.vendor_memberships),
|
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)
|
return stats_service.get_user_statistics(db)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/search")
|
@router.get("/search", response_model=UserSearchResponse)
|
||||||
def search_users(
|
def search_users(
|
||||||
q: str = Query(..., min_length=2, description="Search query (username or email)"),
|
q: str = Query(..., min_length=2, description="Search query (username or email)"),
|
||||||
limit: int = Query(10, ge=1, le=50),
|
limit: int = Query(10, ge=1, le=50),
|
||||||
@@ -144,25 +123,8 @@ def search_users(
|
|||||||
|
|
||||||
Used for autocomplete in ownership transfer.
|
Used for autocomplete in ownership transfer.
|
||||||
"""
|
"""
|
||||||
search_term = f"%{q.lower()}%"
|
users = admin_service.search_users(db=db, query=q, limit=limit)
|
||||||
users = (
|
return UserSearchResponse(users=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
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_model=UserDetailResponse)
|
@router.get("/{user_id}", response_model=UserDetailResponse)
|
||||||
@@ -172,15 +134,7 @@ def get_user_details(
|
|||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
):
|
||||||
"""Get detailed user information (Admin only)."""
|
"""Get detailed user information (Admin only)."""
|
||||||
user = (
|
user = admin_service.get_user_details(db=db, user_id=user_id)
|
||||||
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")
|
|
||||||
|
|
||||||
return UserDetailResponse(
|
return UserDetailResponse(
|
||||||
id=user.id,
|
id=user.id,
|
||||||
@@ -195,8 +149,8 @@ def get_user_details(
|
|||||||
last_name=user.last_name,
|
last_name=user.last_name,
|
||||||
full_name=user.full_name,
|
full_name=user.full_name,
|
||||||
is_email_verified=user.is_email_verified,
|
is_email_verified=user.is_email_verified,
|
||||||
owned_companies_count=len(user.owned_companies),
|
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
|
||||||
vendor_memberships_count=len(user.vendor_memberships),
|
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),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
):
|
||||||
"""Update user information (Admin only)."""
|
"""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)
|
update_data = user_update.model_dump(exclude_unset=True)
|
||||||
for field, value in update_data.items():
|
|
||||||
setattr(user, field, value)
|
|
||||||
|
|
||||||
db.commit()
|
user = admin_service.update_user(
|
||||||
db.refresh(user)
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
logger.info(f"Admin {current_admin.username} updated user {user.username}")
|
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(
|
return UserDetailResponse(
|
||||||
id=user.id,
|
id=user.id,
|
||||||
@@ -252,68 +189,38 @@ def update_user(
|
|||||||
last_name=user.last_name,
|
last_name=user.last_name,
|
||||||
full_name=user.full_name,
|
full_name=user.full_name,
|
||||||
is_email_verified=user.is_email_verified,
|
is_email_verified=user.is_email_verified,
|
||||||
owned_companies_count=len(user.owned_companies),
|
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
|
||||||
vendor_memberships_count=len(user.vendor_memberships),
|
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(
|
def toggle_user_status(
|
||||||
user_id: int = Path(..., description="User ID"),
|
user_id: int = Path(..., description="User ID"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
):
|
||||||
"""Toggle user active status (Admin only)."""
|
"""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:
|
return UserStatusToggleResponse(message=message, is_active=user.is_active)
|
||||||
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}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{user_id}")
|
@router.delete("/{user_id}", response_model=UserDeleteResponse)
|
||||||
def delete_user(
|
def delete_user(
|
||||||
user_id: int = Path(..., description="User ID"),
|
user_id: int = Path(..., description="User ID"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
):
|
||||||
"""Delete a user (Admin only)."""
|
"""Delete a user (Admin only)."""
|
||||||
user = (
|
message = admin_service.delete_user(
|
||||||
db.query(User)
|
db=db,
|
||||||
.options(joinedload(User.owned_companies))
|
user_id=user_id,
|
||||||
.filter(User.id == user_id)
|
current_admin_id=current_admin.id,
|
||||||
.first()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not user:
|
return UserDeleteResponse(message=message)
|
||||||
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"}
|
|
||||||
|
|||||||
@@ -16,14 +16,20 @@ This prevents:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.environment import should_use_secure_cookies
|
from app.core.environment import should_use_secure_cookies
|
||||||
|
from app.exceptions import VendorNotFoundException
|
||||||
from app.services.customer_service import customer_service
|
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
|
from models.schema.customer import CustomerRegister, CustomerResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -62,10 +68,7 @@ def register_customer(
|
|||||||
vendor = getattr(request.state, "vendor", None)
|
vendor = getattr(request.state, "vendor", None)
|
||||||
|
|
||||||
if not vendor:
|
if not vendor:
|
||||||
raise HTTPException(
|
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||||
status_code=404,
|
|
||||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[SHOP_API] register_customer for vendor {vendor.subdomain}",
|
f"[SHOP_API] register_customer for vendor {vendor.subdomain}",
|
||||||
@@ -122,10 +125,7 @@ def customer_login(
|
|||||||
vendor = getattr(request.state, "vendor", None)
|
vendor = getattr(request.state, "vendor", None)
|
||||||
|
|
||||||
if not vendor:
|
if not vendor:
|
||||||
raise HTTPException(
|
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||||
status_code=404,
|
|
||||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[SHOP_API] customer_login for vendor {vendor.subdomain}",
|
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):
|
def customer_logout(request: Request, response: Response):
|
||||||
"""
|
"""
|
||||||
Customer logout for current vendor.
|
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})")
|
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)):
|
def forgot_password(request: Request, email: str, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Request password reset for customer.
|
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)
|
vendor = getattr(request.state, "vendor", None)
|
||||||
|
|
||||||
if not vendor:
|
if not vendor:
|
||||||
raise HTTPException(
|
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||||
status_code=404,
|
|
||||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[SHOP_API] forgot_password for vendor {vendor.subdomain}",
|
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})")
|
logger.info(f"Password reset requested for {email} (vendor: {vendor.subdomain})")
|
||||||
|
|
||||||
return {
|
return PasswordResetRequestResponse(
|
||||||
"message": "If an account exists with this email, a password reset link has been sent."
|
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(
|
def reset_password(
|
||||||
request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)
|
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)
|
vendor = getattr(request.state, "vendor", None)
|
||||||
|
|
||||||
if not vendor:
|
if not vendor:
|
||||||
raise HTTPException(
|
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||||
status_code=404,
|
|
||||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[SHOP_API] reset_password for vendor {vendor.subdomain}",
|
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})")
|
logger.info(f"Password reset completed (vendor: {vendor.subdomain})")
|
||||||
|
|
||||||
return {
|
return PasswordResetResponse(
|
||||||
"message": "Password reset successfully. You can now log in with your new password."
|
message="Password reset successfully. You can now log in with your new password."
|
||||||
}
|
)
|
||||||
|
|||||||
@@ -3,17 +3,21 @@
|
|||||||
Shop Shopping Cart API (Public)
|
Shop Shopping Cart API (Public)
|
||||||
|
|
||||||
Public endpoints for managing shopping cart in shop frontend.
|
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.
|
No authentication required - uses session ID for cart tracking.
|
||||||
|
|
||||||
|
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request
|
from fastapi import APIRouter, Body, Depends, Path
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.services.cart_service import cart_service
|
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 (
|
from models.schema.cart import (
|
||||||
AddToCartRequest,
|
AddToCartRequest,
|
||||||
CartOperationResponse,
|
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(
|
def get_cart(
|
||||||
request: Request,
|
|
||||||
session_id: str = Path(..., description="Shopping session ID"),
|
session_id: str = Path(..., description="Shopping session ID"),
|
||||||
|
vendor: Vendor = Depends(require_vendor_context()),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> CartResponse:
|
) -> CartResponse:
|
||||||
"""
|
"""
|
||||||
Get shopping cart contents for current vendor.
|
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.
|
No authentication required - uses session ID for cart tracking.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- session_id: Unique session identifier for the cart
|
- 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(
|
logger.info(
|
||||||
f"[SHOP_API] get_cart for session {session_id}, vendor {vendor.id}",
|
f"[SHOP_API] get_cart for session {session_id}, vendor {vendor.id}",
|
||||||
extra={
|
extra={
|
||||||
@@ -79,17 +74,17 @@ def get_cart(
|
|||||||
return CartResponse.from_service_dict(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(
|
def add_to_cart(
|
||||||
request: Request,
|
|
||||||
session_id: str = Path(..., description="Shopping session ID"),
|
session_id: str = Path(..., description="Shopping session ID"),
|
||||||
cart_data: AddToCartRequest = Body(...),
|
cart_data: AddToCartRequest = Body(...),
|
||||||
|
vendor: Vendor = Depends(require_vendor_context()),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> CartOperationResponse:
|
) -> CartOperationResponse:
|
||||||
"""
|
"""
|
||||||
Add product to cart for current vendor.
|
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.
|
No authentication required - uses session ID.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
@@ -99,15 +94,6 @@ def add_to_cart(
|
|||||||
- product_id: ID of product to add
|
- product_id: ID of product to add
|
||||||
- quantity: Quantity to add (default: 1)
|
- 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(
|
logger.info(
|
||||||
f"[SHOP_API] add_to_cart: product {cart_data.product_id}, qty {cart_data.quantity}, session {session_id}",
|
f"[SHOP_API] add_to_cart: product {cart_data.product_id}, qty {cart_data.quantity}, session {session_id}",
|
||||||
extra={
|
extra={
|
||||||
@@ -140,18 +126,18 @@ def add_to_cart(
|
|||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
|
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
|
||||||
)
|
) # public
|
||||||
def update_cart_item(
|
def update_cart_item(
|
||||||
request: Request,
|
|
||||||
session_id: str = Path(..., description="Shopping session ID"),
|
session_id: str = Path(..., description="Shopping session ID"),
|
||||||
product_id: int = Path(..., description="Product ID", gt=0),
|
product_id: int = Path(..., description="Product ID", gt=0),
|
||||||
cart_data: UpdateCartItemRequest = Body(...),
|
cart_data: UpdateCartItemRequest = Body(...),
|
||||||
|
vendor: Vendor = Depends(require_vendor_context()),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> CartOperationResponse:
|
) -> CartOperationResponse:
|
||||||
"""
|
"""
|
||||||
Update cart item quantity for current vendor.
|
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.
|
No authentication required - uses session ID.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
@@ -161,15 +147,6 @@ def update_cart_item(
|
|||||||
Request Body:
|
Request Body:
|
||||||
- quantity: New quantity (must be >= 1)
|
- 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(
|
logger.debug(
|
||||||
f"[SHOP_API] update_cart_item: product {product_id}, qty {cart_data.quantity}",
|
f"[SHOP_API] update_cart_item: product {product_id}, qty {cart_data.quantity}",
|
||||||
extra={
|
extra={
|
||||||
@@ -194,32 +171,23 @@ def update_cart_item(
|
|||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
|
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
|
||||||
)
|
) # public
|
||||||
def remove_from_cart(
|
def remove_from_cart(
|
||||||
request: Request,
|
|
||||||
session_id: str = Path(..., description="Shopping session ID"),
|
session_id: str = Path(..., description="Shopping session ID"),
|
||||||
product_id: int = Path(..., description="Product ID", gt=0),
|
product_id: int = Path(..., description="Product ID", gt=0),
|
||||||
|
vendor: Vendor = Depends(require_vendor_context()),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> CartOperationResponse:
|
) -> CartOperationResponse:
|
||||||
"""
|
"""
|
||||||
Remove item from cart for current vendor.
|
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.
|
No authentication required - uses session ID.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- session_id: Unique session identifier for the cart
|
- session_id: Unique session identifier for the cart
|
||||||
- product_id: ID of product to remove
|
- 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(
|
logger.debug(
|
||||||
f"[SHOP_API] remove_from_cart: product {product_id}",
|
f"[SHOP_API] remove_from_cart: product {product_id}",
|
||||||
extra={
|
extra={
|
||||||
@@ -237,30 +205,21 @@ def remove_from_cart(
|
|||||||
return CartOperationResponse(**result)
|
return CartOperationResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/cart/{session_id}", response_model=ClearCartResponse)
|
@router.delete("/cart/{session_id}", response_model=ClearCartResponse) # public
|
||||||
def clear_cart(
|
def clear_cart(
|
||||||
request: Request,
|
|
||||||
session_id: str = Path(..., description="Shopping session ID"),
|
session_id: str = Path(..., description="Shopping session ID"),
|
||||||
|
vendor: Vendor = Depends(require_vendor_context()),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> ClearCartResponse:
|
) -> ClearCartResponse:
|
||||||
"""
|
"""
|
||||||
Clear all items from cart for current vendor.
|
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.
|
No authentication required - uses session ID.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- session_id: Unique session identifier for the cart
|
- 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(
|
logger.debug(
|
||||||
f"[SHOP_API] clear_cart for session {session_id}",
|
f"[SHOP_API] clear_cart for session {session_id}",
|
||||||
extra={
|
extra={
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ No authentication required.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
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 = getattr(request.state, "vendor", None)
|
||||||
vendor_id = vendor.id if vendor else 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,
|
db,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
vendor_id=vendor_id,
|
vendor_id=vendor_id,
|
||||||
include_unpublished=False, # Only show published pages
|
include_unpublished=False, # Only show published pages
|
||||||
)
|
)
|
||||||
|
|
||||||
if not page:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Content page not found: {slug}")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"slug": page.slug,
|
"slug": page.slug,
|
||||||
"title": page.title,
|
"title": page.title,
|
||||||
|
|||||||
@@ -3,17 +3,21 @@
|
|||||||
Shop Product Catalog API (Public)
|
Shop Product Catalog API (Public)
|
||||||
|
|
||||||
Public endpoints for browsing product catalog in shop frontend.
|
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.
|
No authentication required.
|
||||||
|
|
||||||
|
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.services.product_service import product_service
|
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 (
|
from models.schema.product import (
|
||||||
ProductDetailResponse,
|
ProductDetailResponse,
|
||||||
ProductListResponse,
|
ProductListResponse,
|
||||||
@@ -24,19 +28,19 @@ router = APIRouter()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/products", response_model=ProductListResponse)
|
@router.get("/products", response_model=ProductListResponse) # public
|
||||||
def get_product_catalog(
|
def get_product_catalog(
|
||||||
request: Request,
|
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
search: str | None = Query(None, description="Search products by name"),
|
search: str | None = Query(None, description="Search products by name"),
|
||||||
is_featured: bool | None = Query(None, description="Filter by featured products"),
|
is_featured: bool | None = Query(None, description="Filter by featured products"),
|
||||||
|
vendor: Vendor = Depends(require_vendor_context()),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get product catalog for current vendor.
|
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.
|
Only returns active products visible to customers.
|
||||||
No authentication required.
|
No authentication required.
|
||||||
|
|
||||||
@@ -46,15 +50,6 @@ def get_product_catalog(
|
|||||||
- search: Search query for product name/description
|
- search: Search query for product name/description
|
||||||
- is_featured: Filter by featured products only
|
- 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(
|
logger.debug(
|
||||||
f"[SHOP_API] get_product_catalog for vendor: {vendor.subdomain}",
|
f"[SHOP_API] get_product_catalog for vendor: {vendor.subdomain}",
|
||||||
extra={
|
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(
|
def get_product_details(
|
||||||
request: Request,
|
|
||||||
product_id: int = Path(..., description="Product ID", gt=0),
|
product_id: int = Path(..., description="Product ID", gt=0),
|
||||||
|
vendor: Vendor = Depends(require_vendor_context()),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get detailed product information for customers.
|
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.
|
No authentication required.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- product_id: ID of the product to retrieve
|
- 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(
|
logger.debug(
|
||||||
f"[SHOP_API] get_product_details for product {product_id}",
|
f"[SHOP_API] get_product_details for product {product_id}",
|
||||||
extra={
|
extra={
|
||||||
@@ -131,19 +117,19 @@ def get_product_details(
|
|||||||
return ProductDetailResponse.model_validate(product)
|
return ProductDetailResponse.model_validate(product)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/products/search", response_model=ProductListResponse)
|
@router.get("/products/search", response_model=ProductListResponse) # public
|
||||||
def search_products(
|
def search_products(
|
||||||
request: Request,
|
|
||||||
q: str = Query(..., min_length=1, description="Search query"),
|
q: str = Query(..., min_length=1, description="Search query"),
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
vendor: Vendor = Depends(require_vendor_context()),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Search products in current vendor's catalog.
|
Search products in current vendor's catalog.
|
||||||
|
|
||||||
Searches in product names, descriptions, and SKUs.
|
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.
|
No authentication required.
|
||||||
|
|
||||||
Query Parameters:
|
Query Parameters:
|
||||||
@@ -151,15 +137,6 @@ def search_products(
|
|||||||
- skip: Number of results to skip (pagination)
|
- skip: Number of results to skip (pagination)
|
||||||
- limit: Maximum number of results to return
|
- 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(
|
logger.debug(
|
||||||
f"[SHOP_API] search_products: '{q}'",
|
f"[SHOP_API] search_products: '{q}'",
|
||||||
extra={
|
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
|
# app/api/v1/vendor/analytics.py
|
||||||
"""
|
"""
|
||||||
Vendor analytics and reporting endpoints.
|
Vendor analytics and reporting endpoints.
|
||||||
|
|
||||||
|
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -10,21 +12,27 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import get_current_vendor_api
|
from app.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.exceptions import InvalidTokenException
|
||||||
from app.services.stats_service import stats_service
|
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.user import User
|
||||||
from models.database.vendor import Vendor
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/analytics")
|
router = APIRouter(prefix="/analytics")
|
||||||
logger = logging.getLogger(__name__)
|
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("")
|
@router.get("")
|
||||||
def get_vendor_analytics(
|
def get_vendor_analytics(
|
||||||
period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"),
|
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),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get vendor analytics data for specified time period."""
|
"""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 app.services.auth_service import auth_service
|
||||||
from middleware.vendor_context import get_current_vendor
|
from middleware.vendor_context import get_current_vendor
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
from models.database.vendor import Role, Vendor, VendorUser
|
from models.database.vendor import Vendor
|
||||||
from models.schema.auth import UserLogin
|
from models.schema.auth import LogoutResponse, UserLogin, VendorUserResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth")
|
router = APIRouter(prefix="/auth")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -68,13 +68,7 @@ def vendor_login(
|
|||||||
if not vendor and hasattr(user_credentials, "vendor_code"):
|
if not vendor and hasattr(user_credentials, "vendor_code"):
|
||||||
vendor_code = getattr(user_credentials, "vendor_code", None)
|
vendor_code = getattr(user_credentials, "vendor_code", None)
|
||||||
if vendor_code:
|
if vendor_code:
|
||||||
vendor = (
|
vendor = auth_service.get_vendor_by_code(db, vendor_code)
|
||||||
db.query(Vendor)
|
|
||||||
.filter(
|
|
||||||
Vendor.vendor_code == vendor_code.upper(), Vendor.is_active == True
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Authenticate user
|
# Authenticate user
|
||||||
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
|
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
|
||||||
@@ -91,51 +85,22 @@ def vendor_login(
|
|||||||
vendor_role = "Member"
|
vendor_role = "Member"
|
||||||
|
|
||||||
if vendor:
|
if vendor:
|
||||||
# Check if user is vendor owner (via company ownership)
|
# Check if user has access to this vendor
|
||||||
is_owner = vendor.company and vendor.company.owner_user_id == user.id
|
has_access, role = auth_service.get_user_vendor_role(db, user, vendor)
|
||||||
|
|
||||||
if is_owner:
|
if has_access:
|
||||||
vendor_role = "Owner"
|
vendor_role = role
|
||||||
else:
|
else:
|
||||||
# Check if user is team member
|
logger.warning(
|
||||||
vendor_user = (
|
f"User {user.username} attempted login to vendor {vendor.vendor_code} "
|
||||||
db.query(VendorUser)
|
f"but is not authorized"
|
||||||
.join(Role)
|
)
|
||||||
.filter(
|
raise InvalidCredentialsException(
|
||||||
VendorUser.user_id == user.id,
|
"You do not have access to this vendor"
|
||||||
VendorUser.vendor_id == vendor.id,
|
|
||||||
VendorUser.is_active == True,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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:
|
else:
|
||||||
# No vendor context - find which vendor this user belongs to
|
# No vendor context - find which vendor this user belongs to
|
||||||
# Check owned vendors first (via company ownership)
|
vendor, vendor_role = auth_service.find_user_vendor(user)
|
||||||
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
|
|
||||||
|
|
||||||
if not vendor:
|
if not vendor:
|
||||||
raise InvalidCredentialsException("User is not associated with any 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):
|
def vendor_logout(response: Response):
|
||||||
"""
|
"""
|
||||||
Vendor team member logout.
|
Vendor team member logout.
|
||||||
@@ -212,10 +177,10 @@ def vendor_logout(response: Response):
|
|||||||
|
|
||||||
logger.debug("Deleted vendor_token cookie")
|
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(
|
def get_current_vendor_user(
|
||||||
user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db)
|
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.
|
This endpoint can be called to verify authentication and get user info.
|
||||||
Requires Authorization header (header-only authentication for API endpoints).
|
Requires Authorization header (header-only authentication for API endpoints).
|
||||||
"""
|
"""
|
||||||
return {
|
return VendorUserResponse(
|
||||||
"id": user.id,
|
id=user.id,
|
||||||
"username": user.username,
|
username=user.username,
|
||||||
"email": user.email,
|
email=user.email,
|
||||||
"role": user.role,
|
role=user.role,
|
||||||
"is_active": user.is_active,
|
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
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_vendor_api, get_db
|
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 app.services.content_page_service import content_page_service
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
|
|
||||||
@@ -106,9 +107,7 @@ def list_vendor_pages(
|
|||||||
Returns vendor-specific overrides + platform defaults (vendor overrides take precedence).
|
Returns vendor-specific overrides + platform defaults (vendor overrides take precedence).
|
||||||
"""
|
"""
|
||||||
if not current_user.vendor_id:
|
if not current_user.vendor_id:
|
||||||
raise HTTPException(
|
raise VendorNotAssociatedException()
|
||||||
status_code=403, detail="User is not associated with a vendor"
|
|
||||||
)
|
|
||||||
|
|
||||||
pages = content_page_service.list_pages_for_vendor(
|
pages = content_page_service.list_pages_for_vendor(
|
||||||
db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
|
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.
|
Shows what the vendor has customized.
|
||||||
"""
|
"""
|
||||||
if not current_user.vendor_id:
|
if not current_user.vendor_id:
|
||||||
raise HTTPException(
|
raise VendorNotAssociatedException()
|
||||||
status_code=403, detail="User is not associated with a vendor"
|
|
||||||
)
|
|
||||||
|
|
||||||
pages = content_page_service.list_all_vendor_pages(
|
pages = content_page_service.list_all_vendor_pages(
|
||||||
db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
|
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.
|
Returns vendor override if exists, otherwise platform default.
|
||||||
"""
|
"""
|
||||||
if not current_user.vendor_id:
|
if not current_user.vendor_id:
|
||||||
raise HTTPException(
|
raise VendorNotAssociatedException()
|
||||||
status_code=403, detail="User is not associated with a vendor"
|
|
||||||
)
|
|
||||||
|
|
||||||
page = content_page_service.get_page_for_vendor(
|
page = content_page_service.get_page_for_vendor_or_raise(
|
||||||
db,
|
db,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
vendor_id=current_user.vendor_id,
|
vendor_id=current_user.vendor_id,
|
||||||
include_unpublished=include_unpublished,
|
include_unpublished=include_unpublished,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not page:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Content page not found: {slug}")
|
|
||||||
|
|
||||||
return page.to_dict()
|
return page.to_dict()
|
||||||
|
|
||||||
|
|
||||||
@@ -182,9 +174,7 @@ def create_vendor_page(
|
|||||||
This will be shown instead of the platform default for this vendor.
|
This will be shown instead of the platform default for this vendor.
|
||||||
"""
|
"""
|
||||||
if not current_user.vendor_id:
|
if not current_user.vendor_id:
|
||||||
raise HTTPException(
|
raise VendorNotAssociatedException()
|
||||||
status_code=403, detail="User is not associated with a vendor"
|
|
||||||
)
|
|
||||||
|
|
||||||
page = content_page_service.create_page(
|
page = content_page_service.create_page(
|
||||||
db,
|
db,
|
||||||
@@ -218,24 +208,13 @@ def update_vendor_page(
|
|||||||
Can only update pages owned by this vendor.
|
Can only update pages owned by this vendor.
|
||||||
"""
|
"""
|
||||||
if not current_user.vendor_id:
|
if not current_user.vendor_id:
|
||||||
raise HTTPException(
|
raise VendorNotAssociatedException()
|
||||||
status_code=403, detail="User is not associated with a vendor"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify ownership
|
# Update with ownership check in service layer
|
||||||
existing_page = content_page_service.get_page_by_id(db, page_id)
|
page = content_page_service.update_vendor_page(
|
||||||
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(
|
|
||||||
db,
|
db,
|
||||||
page_id=page_id,
|
page_id=page_id,
|
||||||
|
vendor_id=current_user.vendor_id,
|
||||||
title=page_data.title,
|
title=page_data.title,
|
||||||
content=page_data.content,
|
content=page_data.content,
|
||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
@@ -264,21 +243,7 @@ def delete_vendor_page(
|
|||||||
After deletion, platform default will be shown (if exists).
|
After deletion, platform default will be shown (if exists).
|
||||||
"""
|
"""
|
||||||
if not current_user.vendor_id:
|
if not current_user.vendor_id:
|
||||||
raise HTTPException(
|
raise VendorNotAssociatedException()
|
||||||
status_code=403, detail="User is not associated with a vendor"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify ownership
|
# Delete with ownership check in service layer
|
||||||
existing_page = content_page_service.get_page_by_id(db, page_id)
|
content_page_service.delete_vendor_page(db, page_id, current_user.vendor_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
|
|
||||||
|
|||||||
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
|
# app/api/v1/vendor/customers.py
|
||||||
"""
|
"""
|
||||||
Vendor customer management endpoints.
|
Vendor customer management endpoints.
|
||||||
|
|
||||||
|
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -11,21 +13,27 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import get_current_vendor_api
|
from app.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
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.user import User
|
||||||
from models.database.vendor import Vendor
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/customers")
|
router = APIRouter(prefix="/customers")
|
||||||
logger = logging.getLogger(__name__)
|
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("")
|
@router.get("")
|
||||||
def get_vendor_customers(
|
def get_vendor_customers(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
is_active: bool | None = Query(None),
|
is_active: bool | None = Query(None),
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -38,6 +46,7 @@ def get_vendor_customers(
|
|||||||
- Support filtering by active status
|
- Support filtering by active status
|
||||||
- Return paginated results
|
- Return paginated results
|
||||||
"""
|
"""
|
||||||
|
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||||
return {
|
return {
|
||||||
"customers": [],
|
"customers": [],
|
||||||
"total": 0,
|
"total": 0,
|
||||||
@@ -50,7 +59,6 @@ def get_vendor_customers(
|
|||||||
@router.get("/{customer_id}")
|
@router.get("/{customer_id}")
|
||||||
def get_customer_details(
|
def get_customer_details(
|
||||||
customer_id: int,
|
customer_id: int,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -63,13 +71,13 @@ def get_customer_details(
|
|||||||
- Include order history
|
- Include order history
|
||||||
- Include total spent, etc.
|
- 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"}
|
return {"message": "Customer details coming in Slice 4"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{customer_id}/orders")
|
@router.get("/{customer_id}/orders")
|
||||||
def get_customer_orders(
|
def get_customer_orders(
|
||||||
customer_id: int,
|
customer_id: int,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -81,6 +89,7 @@ def get_customer_orders(
|
|||||||
- Filter by vendor_id
|
- Filter by vendor_id
|
||||||
- Return order details
|
- 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"}
|
return {"orders": [], "message": "Customer orders coming in Slice 5"}
|
||||||
|
|
||||||
|
|
||||||
@@ -88,7 +97,6 @@ def get_customer_orders(
|
|||||||
def update_customer(
|
def update_customer(
|
||||||
customer_id: int,
|
customer_id: int,
|
||||||
customer_data: dict,
|
customer_data: dict,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -100,13 +108,13 @@ def update_customer(
|
|||||||
- Verify customer belongs to vendor
|
- Verify customer belongs to vendor
|
||||||
- Update customer preferences
|
- 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"}
|
return {"message": "Customer update coming in Slice 4"}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{customer_id}/status")
|
@router.put("/{customer_id}/status")
|
||||||
def toggle_customer_status(
|
def toggle_customer_status(
|
||||||
customer_id: int,
|
customer_id: int,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -118,13 +126,13 @@ def toggle_customer_status(
|
|||||||
- Verify customer belongs to vendor
|
- Verify customer belongs to vendor
|
||||||
- Log the change
|
- 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"}
|
return {"message": "Customer status toggle coming in Slice 4"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{customer_id}/stats")
|
@router.get("/{customer_id}/stats")
|
||||||
def get_customer_statistics(
|
def get_customer_statistics(
|
||||||
customer_id: int,
|
customer_id: int,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -137,6 +145,7 @@ def get_customer_statistics(
|
|||||||
- Average order value
|
- Average order value
|
||||||
- Last order date
|
- Last order date
|
||||||
"""
|
"""
|
||||||
|
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||||
return {
|
return {
|
||||||
"total_orders": 0,
|
"total_orders": 0,
|
||||||
"total_spent": 0.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.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.exceptions import InvalidTokenException, VendorNotActiveException
|
||||||
from app.services.stats_service import stats_service
|
from app.services.stats_service import stats_service
|
||||||
|
from app.services.vendor_service import vendor_service
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard")
|
router = APIRouter(prefix="/dashboard")
|
||||||
@@ -35,23 +37,17 @@ def get_vendor_dashboard_stats(
|
|||||||
Vendor is determined from the JWT token (vendor_id claim).
|
Vendor is determined from the JWT token (vendor_id claim).
|
||||||
Requires Authorization header (API endpoint).
|
Requires Authorization header (API endpoint).
|
||||||
"""
|
"""
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
# Get vendor ID from token (set by get_current_vendor_api)
|
# Get vendor ID from token (set by get_current_vendor_api)
|
||||||
if not hasattr(current_user, "token_vendor_id"):
|
if not hasattr(current_user, "token_vendor_id"):
|
||||||
raise HTTPException(
|
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||||
status_code=400,
|
|
||||||
detail="Token missing vendor information. Please login again.",
|
|
||||||
)
|
|
||||||
|
|
||||||
vendor_id = current_user.token_vendor_id
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
# Get vendor object to include in response
|
# Get vendor object (raises VendorNotFoundException if not found)
|
||||||
from models.database.vendor import Vendor
|
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
|
||||||
|
|
||||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
if not vendor.is_active:
|
||||||
if not vendor or not vendor.is_active:
|
raise VendorNotActiveException(vendor.vendor_code)
|
||||||
raise HTTPException(status_code=404, detail="Vendor not found or inactive")
|
|
||||||
|
|
||||||
# Get vendor-scoped statistics
|
# Get vendor-scoped statistics
|
||||||
stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor_id)
|
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
|
# 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
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
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.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.exceptions import InvalidTokenException
|
||||||
from app.services.inventory_service import inventory_service
|
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.user import User
|
||||||
from models.database.vendor import Vendor
|
|
||||||
from models.schema.inventory import (
|
from models.schema.inventory import (
|
||||||
InventoryAdjust,
|
InventoryAdjust,
|
||||||
InventoryCreate,
|
InventoryCreate,
|
||||||
@@ -24,70 +28,77 @@ router = APIRouter()
|
|||||||
logger = logging.getLogger(__name__)
|
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)
|
@router.post("/inventory/set", response_model=InventoryResponse)
|
||||||
def set_inventory(
|
def set_inventory(
|
||||||
inventory: InventoryCreate,
|
inventory: InventoryCreate,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Set exact inventory quantity (replaces existing)."""
|
"""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)
|
@router.post("/inventory/adjust", response_model=InventoryResponse)
|
||||||
def adjust_inventory(
|
def adjust_inventory(
|
||||||
adjustment: InventoryAdjust,
|
adjustment: InventoryAdjust,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Adjust inventory (positive to add, negative to remove)."""
|
"""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)
|
@router.post("/inventory/reserve", response_model=InventoryResponse)
|
||||||
def reserve_inventory(
|
def reserve_inventory(
|
||||||
reservation: InventoryReserve,
|
reservation: InventoryReserve,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Reserve inventory for an order."""
|
"""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)
|
@router.post("/inventory/release", response_model=InventoryResponse)
|
||||||
def release_reservation(
|
def release_reservation(
|
||||||
reservation: InventoryReserve,
|
reservation: InventoryReserve,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Release reserved inventory (cancel order)."""
|
"""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)
|
@router.post("/inventory/fulfill", response_model=InventoryResponse)
|
||||||
def fulfill_reservation(
|
def fulfill_reservation(
|
||||||
reservation: InventoryReserve,
|
reservation: InventoryReserve,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Fulfill reservation (complete order, remove from stock)."""
|
"""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)
|
@router.get("/inventory/product/{product_id}", response_model=ProductInventorySummary)
|
||||||
def get_product_inventory(
|
def get_product_inventory(
|
||||||
product_id: int,
|
product_id: int,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get inventory summary for a product."""
|
"""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)
|
@router.get("/inventory", response_model=InventoryListResponse)
|
||||||
@@ -96,13 +107,13 @@ def get_vendor_inventory(
|
|||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
location: str | None = Query(None),
|
location: str | None = Query(None),
|
||||||
low_stock: int | None = Query(None, ge=0),
|
low_stock: int | None = Query(None, ge=0),
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get all inventory for vendor."""
|
"""Get all inventory for vendor."""
|
||||||
|
vendor_id = _get_vendor_id_from_token(current_user)
|
||||||
inventories = inventory_service.get_vendor_inventory(
|
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
|
# Get total count
|
||||||
@@ -117,23 +128,23 @@ def get_vendor_inventory(
|
|||||||
def update_inventory(
|
def update_inventory(
|
||||||
inventory_id: int,
|
inventory_id: int,
|
||||||
inventory_update: InventoryUpdate,
|
inventory_update: InventoryUpdate,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Update inventory entry."""
|
"""Update inventory entry."""
|
||||||
|
vendor_id = _get_vendor_id_from_token(current_user)
|
||||||
return inventory_service.update_inventory(
|
return inventory_service.update_inventory(
|
||||||
db, vendor.id, inventory_id, inventory_update
|
db, vendor_id, inventory_id, inventory_update
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/inventory/{inventory_id}")
|
@router.delete("/inventory/{inventory_id}")
|
||||||
def delete_inventory(
|
def delete_inventory(
|
||||||
inventory_id: int,
|
inventory_id: int,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Delete inventory entry."""
|
"""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"}
|
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.
|
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
|
import logging
|
||||||
@@ -11,37 +12,45 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import get_current_vendor_api
|
from app.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
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.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 app.tasks.background_tasks import process_marketplace_import
|
||||||
from middleware.decorators import rate_limit
|
from middleware.decorators import rate_limit
|
||||||
from middleware.vendor_context import require_vendor_context # IMPORTANT
|
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
from models.database.vendor import Vendor
|
|
||||||
from models.schema.marketplace_import_job import (
|
from models.schema.marketplace_import_job import (
|
||||||
MarketplaceImportJobRequest,
|
MarketplaceImportJobRequest,
|
||||||
MarketplaceImportJobResponse,
|
MarketplaceImportJobResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter(prefix="/marketplace")
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
@router.post("/import", response_model=MarketplaceImportJobResponse)
|
||||||
@rate_limit(max_requests=10, window_seconds=3600)
|
@rate_limit(max_requests=10, window_seconds=3600)
|
||||||
async def import_products_from_marketplace(
|
async def import_products_from_marketplace(
|
||||||
request: MarketplaceImportJobRequest,
|
request: MarketplaceImportJobRequest,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
vendor: Vendor = Depends(require_vendor_context()), # ADDED: Vendor from middleware
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Import products from marketplace CSV with background processing (Protected)."""
|
"""Import products from marketplace CSV with background processing (Protected)."""
|
||||||
|
vendor = _get_vendor_from_token(current_user, db)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Starting marketplace import: {request.marketplace} for vendor {vendor.vendor_code} "
|
f"Starting marketplace import: {request.marketplace} for vendor {vendor.vendor_code} "
|
||||||
f"by user {current_user.username}"
|
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(
|
import_job = marketplace_import_job_service.create_import_job(
|
||||||
db, request, vendor, current_user
|
db, request, vendor, current_user
|
||||||
)
|
)
|
||||||
@@ -50,9 +59,9 @@ async def import_products_from_marketplace(
|
|||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
process_marketplace_import,
|
process_marketplace_import,
|
||||||
import_job.id,
|
import_job.id,
|
||||||
request.source_url, # FIXED: was request.url
|
request.source_url,
|
||||||
request.marketplace,
|
request.marketplace,
|
||||||
vendor.id, # Pass vendor_id instead of vendor_code
|
vendor.id,
|
||||||
request.batch_size or 1000,
|
request.batch_size or 1000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,7 +71,7 @@ async def import_products_from_marketplace(
|
|||||||
marketplace=request.marketplace,
|
marketplace=request.marketplace,
|
||||||
vendor_id=import_job.vendor_id,
|
vendor_id=import_job.vendor_id,
|
||||||
vendor_code=vendor.vendor_code,
|
vendor_code=vendor.vendor_code,
|
||||||
vendor_name=vendor.name, # FIXED: from vendor object
|
vendor_name=vendor.name,
|
||||||
source_url=request.source_url,
|
source_url=request.source_url,
|
||||||
message=f"Marketplace import started from {request.marketplace}. "
|
message=f"Marketplace import started from {request.marketplace}. "
|
||||||
f"Check status with /import-status/{import_job.id}",
|
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)
|
@router.get("/imports/{job_id}", response_model=MarketplaceImportJobResponse)
|
||||||
def get_marketplace_import_status(
|
def get_marketplace_import_status(
|
||||||
job_id: int,
|
job_id: int,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get status of marketplace import job (Protected)."""
|
"""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)
|
job = marketplace_import_job_service.get_import_job_by_id(db, job_id, current_user)
|
||||||
|
|
||||||
# Verify job belongs to current vendor
|
# Verify job belongs to current vendor
|
||||||
if job.vendor_id != vendor.id:
|
if job.vendor_id != vendor.id:
|
||||||
from app.exceptions import UnauthorizedVendorAccessException
|
|
||||||
|
|
||||||
raise UnauthorizedVendorAccessException(vendor.vendor_code, current_user.id)
|
raise UnauthorizedVendorAccessException(vendor.vendor_code, current_user.id)
|
||||||
|
|
||||||
return marketplace_import_job_service.convert_to_response_model(job)
|
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"),
|
marketplace: str | None = Query(None, description="Filter by marketplace"),
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get marketplace import jobs for current vendor (Protected)."""
|
"""Get marketplace import jobs for current vendor (Protected)."""
|
||||||
|
vendor = _get_vendor_from_token(current_user, db)
|
||||||
|
|
||||||
jobs = marketplace_import_job_service.get_import_jobs(
|
jobs = marketplace_import_job_service.get_import_jobs(
|
||||||
db=db,
|
db=db,
|
||||||
vendor=vendor,
|
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
|
# app/api/v1/vendor/media.py
|
||||||
"""
|
"""
|
||||||
Vendor media and file management endpoints.
|
Vendor media and file management endpoints.
|
||||||
|
|
||||||
|
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -11,21 +13,27 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import get_current_vendor_api
|
from app.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
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.user import User
|
||||||
from models.database.vendor import Vendor
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/media")
|
router = APIRouter(prefix="/media")
|
||||||
logger = logging.getLogger(__name__)
|
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("")
|
@router.get("")
|
||||||
def get_media_library(
|
def get_media_library(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
media_type: str | None = Query(None, description="image, video, document"),
|
media_type: str | None = Query(None, description="image, video, document"),
|
||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -39,6 +47,7 @@ def get_media_library(
|
|||||||
- Support pagination
|
- Support pagination
|
||||||
- Return file URLs, sizes, metadata
|
- Return file URLs, sizes, metadata
|
||||||
"""
|
"""
|
||||||
|
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||||
return {
|
return {
|
||||||
"media": [],
|
"media": [],
|
||||||
"total": 0,
|
"total": 0,
|
||||||
@@ -52,7 +61,6 @@ def get_media_library(
|
|||||||
async def upload_media(
|
async def upload_media(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
folder: str | None = Query(None, description="products, general, etc."),
|
folder: str | None = Query(None, description="products, general, etc."),
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -67,6 +75,7 @@ async def upload_media(
|
|||||||
- Save metadata to database
|
- Save metadata to database
|
||||||
- Return file URL
|
- Return file URL
|
||||||
"""
|
"""
|
||||||
|
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||||
return {
|
return {
|
||||||
"file_url": None,
|
"file_url": None,
|
||||||
"thumbnail_url": None,
|
"thumbnail_url": None,
|
||||||
@@ -78,7 +87,6 @@ async def upload_media(
|
|||||||
async def upload_multiple_media(
|
async def upload_multiple_media(
|
||||||
files: list[UploadFile] = File(...),
|
files: list[UploadFile] = File(...),
|
||||||
folder: str | None = Query(None),
|
folder: str | None = Query(None),
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -91,6 +99,7 @@ async def upload_multiple_media(
|
|||||||
- Return list of uploaded file URLs
|
- Return list of uploaded file URLs
|
||||||
- Handle errors gracefully
|
- Handle errors gracefully
|
||||||
"""
|
"""
|
||||||
|
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||||
return {
|
return {
|
||||||
"uploaded_files": [],
|
"uploaded_files": [],
|
||||||
"failed_files": [],
|
"failed_files": [],
|
||||||
@@ -101,7 +110,6 @@ async def upload_multiple_media(
|
|||||||
@router.get("/{media_id}")
|
@router.get("/{media_id}")
|
||||||
def get_media_details(
|
def get_media_details(
|
||||||
media_id: int,
|
media_id: int,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -113,6 +121,7 @@ def get_media_details(
|
|||||||
- Return file URL
|
- Return file URL
|
||||||
- Return usage information (which products use this file)
|
- 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"}
|
return {"message": "Media details coming in Slice 3"}
|
||||||
|
|
||||||
|
|
||||||
@@ -120,7 +129,6 @@ def get_media_details(
|
|||||||
def update_media_metadata(
|
def update_media_metadata(
|
||||||
media_id: int,
|
media_id: int,
|
||||||
metadata: dict,
|
metadata: dict,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -133,13 +141,13 @@ def update_media_metadata(
|
|||||||
- Update tags/categories
|
- Update tags/categories
|
||||||
- Update description
|
- 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"}
|
return {"message": "Media update coming in Slice 3"}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{media_id}")
|
@router.delete("/{media_id}")
|
||||||
def delete_media(
|
def delete_media(
|
||||||
media_id: int,
|
media_id: int,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -153,13 +161,13 @@ def delete_media(
|
|||||||
- Delete database record
|
- Delete database record
|
||||||
- Return success/error
|
- 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"}
|
return {"message": "Media deletion coming in Slice 3"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{media_id}/usage")
|
@router.get("/{media_id}/usage")
|
||||||
def get_media_usage(
|
def get_media_usage(
|
||||||
media_id: int,
|
media_id: int,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -171,6 +179,7 @@ def get_media_usage(
|
|||||||
- Check other entities using this media
|
- Check other entities using this media
|
||||||
- Return list of usage
|
- Return list of usage
|
||||||
"""
|
"""
|
||||||
|
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||||
return {
|
return {
|
||||||
"products": [],
|
"products": [],
|
||||||
"other_usage": [],
|
"other_usage": [],
|
||||||
@@ -181,7 +190,6 @@ def get_media_usage(
|
|||||||
@router.post("/optimize/{media_id}")
|
@router.post("/optimize/{media_id}")
|
||||||
def optimize_media(
|
def optimize_media(
|
||||||
media_id: int,
|
media_id: int,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -194,4 +202,5 @@ def optimize_media(
|
|||||||
- Keep original
|
- Keep original
|
||||||
- Update database with new versions
|
- 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"}
|
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
|
# app/api/v1/vendor/notifications.py
|
||||||
"""
|
"""
|
||||||
Vendor notification management endpoints.
|
Vendor notification management endpoints.
|
||||||
|
|
||||||
|
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -11,20 +13,26 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import get_current_vendor_api
|
from app.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
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.user import User
|
||||||
from models.database.vendor import Vendor
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/notifications")
|
router = APIRouter(prefix="/notifications")
|
||||||
logger = logging.getLogger(__name__)
|
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("")
|
@router.get("")
|
||||||
def get_notifications(
|
def get_notifications(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
unread_only: bool | None = Query(False),
|
unread_only: bool | None = Query(False),
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -37,6 +45,7 @@ def get_notifications(
|
|||||||
- Support pagination
|
- Support pagination
|
||||||
- Return notification details
|
- Return notification details
|
||||||
"""
|
"""
|
||||||
|
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||||
return {
|
return {
|
||||||
"notifications": [],
|
"notifications": [],
|
||||||
"total": 0,
|
"total": 0,
|
||||||
@@ -47,7 +56,6 @@ def get_notifications(
|
|||||||
|
|
||||||
@router.get("/unread-count")
|
@router.get("/unread-count")
|
||||||
def get_unread_count(
|
def get_unread_count(
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -58,13 +66,13 @@ def get_unread_count(
|
|||||||
- Count unread notifications for vendor
|
- Count unread notifications for vendor
|
||||||
- Used for notification badge
|
- 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"}
|
return {"unread_count": 0, "message": "Unread count coming in Slice 5"}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{notification_id}/read")
|
@router.put("/{notification_id}/read")
|
||||||
def mark_as_read(
|
def mark_as_read(
|
||||||
notification_id: int,
|
notification_id: int,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -75,12 +83,12 @@ def mark_as_read(
|
|||||||
- Mark single notification as read
|
- Mark single notification as read
|
||||||
- Update read timestamp
|
- 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"}
|
return {"message": "Mark as read coming in Slice 5"}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/mark-all-read")
|
@router.put("/mark-all-read")
|
||||||
def mark_all_as_read(
|
def mark_all_as_read(
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -91,13 +99,13 @@ def mark_all_as_read(
|
|||||||
- Mark all vendor notifications as read
|
- Mark all vendor notifications as read
|
||||||
- Update timestamps
|
- 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"}
|
return {"message": "Mark all as read coming in Slice 5"}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{notification_id}")
|
@router.delete("/{notification_id}")
|
||||||
def delete_notification(
|
def delete_notification(
|
||||||
notification_id: int,
|
notification_id: int,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -108,12 +116,12 @@ def delete_notification(
|
|||||||
- Delete single notification
|
- Delete single notification
|
||||||
- Verify notification belongs to vendor
|
- 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"}
|
return {"message": "Notification deletion coming in Slice 5"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings")
|
@router.get("/settings")
|
||||||
def get_notification_settings(
|
def get_notification_settings(
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -125,6 +133,7 @@ def get_notification_settings(
|
|||||||
- Get in-app notification settings
|
- Get in-app notification settings
|
||||||
- Get notification types enabled/disabled
|
- Get notification types enabled/disabled
|
||||||
"""
|
"""
|
||||||
|
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||||
return {
|
return {
|
||||||
"email_notifications": True,
|
"email_notifications": True,
|
||||||
"in_app_notifications": True,
|
"in_app_notifications": True,
|
||||||
@@ -136,7 +145,6 @@ def get_notification_settings(
|
|||||||
@router.put("/settings")
|
@router.put("/settings")
|
||||||
def update_notification_settings(
|
def update_notification_settings(
|
||||||
settings: dict,
|
settings: dict,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -148,12 +156,12 @@ def update_notification_settings(
|
|||||||
- Update in-app notification settings
|
- Update in-app notification settings
|
||||||
- Enable/disable specific notification types
|
- 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"}
|
return {"message": "Notification settings update coming in Slice 5"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/templates")
|
@router.get("/templates")
|
||||||
def get_notification_templates(
|
def get_notification_templates(
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -165,6 +173,7 @@ def get_notification_templates(
|
|||||||
- Include: order confirmation, shipping notification, etc.
|
- Include: order confirmation, shipping notification, etc.
|
||||||
- Return template details
|
- 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"}
|
return {"templates": [], "message": "Notification templates coming in Slice 5"}
|
||||||
|
|
||||||
|
|
||||||
@@ -172,7 +181,6 @@ def get_notification_templates(
|
|||||||
def update_notification_template(
|
def update_notification_template(
|
||||||
template_id: int,
|
template_id: int,
|
||||||
template_data: dict,
|
template_data: dict,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -185,13 +193,13 @@ def update_notification_template(
|
|||||||
- Validate template variables
|
- Validate template variables
|
||||||
- Preview template
|
- 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"}
|
return {"message": "Template update coming in Slice 5"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test")
|
@router.post("/test")
|
||||||
def send_test_notification(
|
def send_test_notification(
|
||||||
notification_data: dict,
|
notification_data: dict,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -203,4 +211,5 @@ def send_test_notification(
|
|||||||
- Use specified template
|
- Use specified template
|
||||||
- Send to current user's email
|
- 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"}
|
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
|
# app/api/v1/vendor/payments.py
|
||||||
"""
|
"""
|
||||||
Vendor payment configuration and processing endpoints.
|
Vendor payment configuration and processing endpoints.
|
||||||
|
|
||||||
|
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -11,17 +13,23 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import get_current_vendor_api
|
from app.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
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.user import User
|
||||||
from models.database.vendor import Vendor
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/payments")
|
router = APIRouter(prefix="/payments")
|
||||||
logger = logging.getLogger(__name__)
|
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")
|
@router.get("/config")
|
||||||
def get_payment_configuration(
|
def get_payment_configuration(
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -34,6 +42,7 @@ def get_payment_configuration(
|
|||||||
- Get currency settings
|
- Get currency settings
|
||||||
- Return masked/secure information only
|
- Return masked/secure information only
|
||||||
"""
|
"""
|
||||||
|
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||||
return {
|
return {
|
||||||
"payment_gateway": None,
|
"payment_gateway": None,
|
||||||
"accepted_methods": [],
|
"accepted_methods": [],
|
||||||
@@ -46,7 +55,6 @@ def get_payment_configuration(
|
|||||||
@router.put("/config")
|
@router.put("/config")
|
||||||
def update_payment_configuration(
|
def update_payment_configuration(
|
||||||
payment_config: dict,
|
payment_config: dict,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -59,13 +67,13 @@ def update_payment_configuration(
|
|||||||
- Update accepted payment methods
|
- Update accepted payment methods
|
||||||
- Validate configuration before saving
|
- 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"}
|
return {"message": "Payment configuration update coming in Slice 5"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stripe/connect")
|
@router.post("/stripe/connect")
|
||||||
def connect_stripe_account(
|
def connect_stripe_account(
|
||||||
stripe_data: dict,
|
stripe_data: dict,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -78,12 +86,12 @@ def connect_stripe_account(
|
|||||||
- Verify Stripe account is active
|
- Verify Stripe account is active
|
||||||
- Enable payment processing
|
- 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"}
|
return {"message": "Stripe connection coming in Slice 5"}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/stripe/disconnect")
|
@router.delete("/stripe/disconnect")
|
||||||
def disconnect_stripe_account(
|
def disconnect_stripe_account(
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -95,12 +103,12 @@ def disconnect_stripe_account(
|
|||||||
- Disable payment processing
|
- Disable payment processing
|
||||||
- Warn about pending payments
|
- 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"}
|
return {"message": "Stripe disconnection coming in Slice 5"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/methods")
|
@router.get("/methods")
|
||||||
def get_payment_methods(
|
def get_payment_methods(
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -111,12 +119,12 @@ def get_payment_methods(
|
|||||||
- Return list of enabled payment methods
|
- Return list of enabled payment methods
|
||||||
- Include: credit card, PayPal, bank transfer, etc.
|
- 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"}
|
return {"methods": [], "message": "Payment methods coming in Slice 5"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/transactions")
|
@router.get("/transactions")
|
||||||
def get_payment_transactions(
|
def get_payment_transactions(
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -129,6 +137,7 @@ def get_payment_transactions(
|
|||||||
- Include payment details
|
- Include payment details
|
||||||
- Support pagination
|
- Support pagination
|
||||||
"""
|
"""
|
||||||
|
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||||
return {
|
return {
|
||||||
"transactions": [],
|
"transactions": [],
|
||||||
"total": 0,
|
"total": 0,
|
||||||
@@ -138,7 +147,6 @@ def get_payment_transactions(
|
|||||||
|
|
||||||
@router.get("/balance")
|
@router.get("/balance")
|
||||||
def get_payment_balance(
|
def get_payment_balance(
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -151,6 +159,7 @@ def get_payment_balance(
|
|||||||
- Get next payout date
|
- Get next payout date
|
||||||
- Get payout history
|
- Get payout history
|
||||||
"""
|
"""
|
||||||
|
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||||
return {
|
return {
|
||||||
"available_balance": 0.0,
|
"available_balance": 0.0,
|
||||||
"pending_balance": 0.0,
|
"pending_balance": 0.0,
|
||||||
@@ -164,7 +173,6 @@ def get_payment_balance(
|
|||||||
def refund_payment(
|
def refund_payment(
|
||||||
payment_id: int,
|
payment_id: int,
|
||||||
refund_data: dict,
|
refund_data: dict,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -177,4 +185,5 @@ def refund_payment(
|
|||||||
- Update order status
|
- Update order status
|
||||||
- Send refund notification to customer
|
- 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"}
|
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.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.exceptions import InvalidTokenException
|
||||||
from app.services.product_service import product_service
|
from app.services.product_service import product_service
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
from models.schema.product import (
|
from models.schema.product import (
|
||||||
ProductCreate,
|
ProductCreate,
|
||||||
|
ProductDeleteResponse,
|
||||||
ProductDetailResponse,
|
ProductDetailResponse,
|
||||||
ProductListResponse,
|
ProductListResponse,
|
||||||
ProductResponse,
|
ProductResponse,
|
||||||
|
ProductToggleResponse,
|
||||||
ProductUpdate,
|
ProductUpdate,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,14 +45,9 @@ def get_vendor_products(
|
|||||||
|
|
||||||
Vendor is determined from JWT token (vendor_id claim).
|
Vendor is determined from JWT token (vendor_id claim).
|
||||||
"""
|
"""
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
# Get vendor ID from token
|
# Get vendor ID from token
|
||||||
if not hasattr(current_user, "token_vendor_id"):
|
if not hasattr(current_user, "token_vendor_id"):
|
||||||
raise HTTPException(
|
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||||
status_code=400,
|
|
||||||
detail="Token missing vendor information. Please login again.",
|
|
||||||
)
|
|
||||||
|
|
||||||
vendor_id = current_user.token_vendor_id
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
@@ -77,14 +75,9 @@ def get_product_details(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get detailed product information including inventory."""
|
"""Get detailed product information including inventory."""
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
# Get vendor ID from token
|
# Get vendor ID from token
|
||||||
if not hasattr(current_user, "token_vendor_id"):
|
if not hasattr(current_user, "token_vendor_id"):
|
||||||
raise HTTPException(
|
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||||
status_code=400,
|
|
||||||
detail="Token missing vendor information. Please login again.",
|
|
||||||
)
|
|
||||||
|
|
||||||
vendor_id = current_user.token_vendor_id
|
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.
|
This publishes a MarketplaceProduct to the vendor's public catalog.
|
||||||
"""
|
"""
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
# Get vendor ID from token
|
# Get vendor ID from token
|
||||||
if not hasattr(current_user, "token_vendor_id"):
|
if not hasattr(current_user, "token_vendor_id"):
|
||||||
raise HTTPException(
|
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||||
status_code=400,
|
|
||||||
detail="Token missing vendor information. Please login again.",
|
|
||||||
)
|
|
||||||
|
|
||||||
vendor_id = current_user.token_vendor_id
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
@@ -137,14 +125,9 @@ def update_product(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Update product in vendor catalog."""
|
"""Update product in vendor catalog."""
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
# Get vendor ID from token
|
# Get vendor ID from token
|
||||||
if not hasattr(current_user, "token_vendor_id"):
|
if not hasattr(current_user, "token_vendor_id"):
|
||||||
raise HTTPException(
|
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||||
status_code=400,
|
|
||||||
detail="Token missing vendor information. Please login again.",
|
|
||||||
)
|
|
||||||
|
|
||||||
vendor_id = current_user.token_vendor_id
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
@@ -160,21 +143,16 @@ def update_product(
|
|||||||
return ProductResponse.model_validate(product)
|
return ProductResponse.model_validate(product)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{product_id}")
|
@router.delete("/{product_id}", response_model=ProductDeleteResponse)
|
||||||
def remove_product_from_catalog(
|
def remove_product_from_catalog(
|
||||||
product_id: int,
|
product_id: int,
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Remove product from vendor catalog."""
|
"""Remove product from vendor catalog."""
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
# Get vendor ID from token
|
# Get vendor ID from token
|
||||||
if not hasattr(current_user, "token_vendor_id"):
|
if not hasattr(current_user, "token_vendor_id"):
|
||||||
raise HTTPException(
|
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||||
status_code=400,
|
|
||||||
detail="Token missing vendor information. Please login again.",
|
|
||||||
)
|
|
||||||
|
|
||||||
vendor_id = current_user.token_vendor_id
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
@@ -185,7 +163,7 @@ def remove_product_from_catalog(
|
|||||||
f"for vendor {current_user.token_vendor_code}"
|
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)
|
@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.
|
Shortcut endpoint for publishing directly from marketplace import.
|
||||||
"""
|
"""
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
# Get vendor ID from token
|
# Get vendor ID from token
|
||||||
if not hasattr(current_user, "token_vendor_id"):
|
if not hasattr(current_user, "token_vendor_id"):
|
||||||
raise HTTPException(
|
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||||
status_code=400,
|
|
||||||
detail="Token missing vendor information. Please login again.",
|
|
||||||
)
|
|
||||||
|
|
||||||
vendor_id = current_user.token_vendor_id
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
@@ -226,21 +199,16 @@ def publish_from_marketplace(
|
|||||||
return ProductResponse.model_validate(product)
|
return ProductResponse.model_validate(product)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{product_id}/toggle-active")
|
@router.put("/{product_id}/toggle-active", response_model=ProductToggleResponse)
|
||||||
def toggle_product_active(
|
def toggle_product_active(
|
||||||
product_id: int,
|
product_id: int,
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Toggle product active status."""
|
"""Toggle product active status."""
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
# Get vendor ID from token
|
# Get vendor ID from token
|
||||||
if not hasattr(current_user, "token_vendor_id"):
|
if not hasattr(current_user, "token_vendor_id"):
|
||||||
raise HTTPException(
|
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||||
status_code=400,
|
|
||||||
detail="Token missing vendor information. Please login again.",
|
|
||||||
)
|
|
||||||
|
|
||||||
vendor_id = current_user.token_vendor_id
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
@@ -253,24 +221,19 @@ def toggle_product_active(
|
|||||||
status = "activated" if product.is_active else "deactivated"
|
status = "activated" if product.is_active else "deactivated"
|
||||||
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
|
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
|
||||||
|
|
||||||
return {"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(
|
def toggle_product_featured(
|
||||||
product_id: int,
|
product_id: int,
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Toggle product featured status."""
|
"""Toggle product featured status."""
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
# Get vendor ID from token
|
# Get vendor ID from token
|
||||||
if not hasattr(current_user, "token_vendor_id"):
|
if not hasattr(current_user, "token_vendor_id"):
|
||||||
raise HTTPException(
|
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||||
status_code=400,
|
|
||||||
detail="Token missing vendor information. Please login again.",
|
|
||||||
)
|
|
||||||
|
|
||||||
vendor_id = current_user.token_vendor_id
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
@@ -283,4 +246,4 @@ def toggle_product_featured(
|
|||||||
status = "featured" if product.is_featured else "unfeatured"
|
status = "featured" if product.is_featured else "unfeatured"
|
||||||
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
|
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
|
||||||
|
|
||||||
return {"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
|
# app/api/v1/vendor/profile.py
|
||||||
"""
|
"""
|
||||||
Vendor profile management endpoints.
|
Vendor profile management endpoints.
|
||||||
|
|
||||||
|
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_vendor_api
|
from app.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
|
||||||
from app.services.vendor_service import vendor_service
|
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.user import User
|
||||||
from models.database.vendor import Vendor
|
|
||||||
from models.schema.vendor import VendorResponse, VendorUpdate
|
from models.schema.vendor import VendorResponse, VendorUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/profile")
|
router = APIRouter(prefix="/profile")
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
@router.get("", response_model=VendorResponse)
|
||||||
def get_vendor_profile(
|
def get_vendor_profile(
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get current vendor profile information."""
|
"""Get current vendor profile information."""
|
||||||
|
vendor = _get_vendor_from_token(current_user, db)
|
||||||
return vendor
|
return vendor
|
||||||
|
|
||||||
|
|
||||||
@router.put("", response_model=VendorResponse)
|
@router.put("", response_model=VendorResponse)
|
||||||
def update_vendor_profile(
|
def update_vendor_profile(
|
||||||
vendor_update: VendorUpdate,
|
vendor_update: VendorUpdate,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Update vendor profile information."""
|
"""Update vendor profile information."""
|
||||||
|
vendor = _get_vendor_from_token(current_user, db)
|
||||||
|
|
||||||
# Verify user has permission to update vendor
|
# Verify user has permission to update vendor
|
||||||
if not vendor_service.can_update_vendor(vendor, current_user):
|
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)
|
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
|
# app/api/v1/vendor/settings.py
|
||||||
"""
|
"""
|
||||||
Vendor settings and configuration endpoints.
|
Vendor settings and configuration endpoints.
|
||||||
|
|
||||||
|
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_vendor_api
|
from app.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
|
||||||
from app.services.vendor_service import vendor_service
|
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.user import User
|
||||||
from models.database.vendor import Vendor
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/settings")
|
router = APIRouter(prefix="/settings")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -21,11 +22,16 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def get_vendor_settings(
|
def get_vendor_settings(
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get vendor settings and configuration."""
|
"""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 {
|
return {
|
||||||
"vendor_code": vendor.vendor_code,
|
"vendor_code": vendor.vendor_code,
|
||||||
"subdomain": vendor.subdomain,
|
"subdomain": vendor.subdomain,
|
||||||
@@ -46,14 +52,21 @@ def get_vendor_settings(
|
|||||||
@router.put("/marketplace")
|
@router.put("/marketplace")
|
||||||
def update_marketplace_settings(
|
def update_marketplace_settings(
|
||||||
marketplace_config: dict,
|
marketplace_config: dict,
|
||||||
vendor: Vendor = Depends(require_vendor_context()),
|
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Update marketplace integration settings."""
|
"""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
|
# Verify permissions
|
||||||
if not vendor_service.can_update_vendor(vendor, current_user):
|
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
|
# Update Letzshop URLs
|
||||||
if "letzshop_csv_url_fr" in marketplace_config:
|
if "letzshop_csv_url_fr" in marketplace_config:
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ from .admin import (
|
|||||||
CannotModifySelfException,
|
CannotModifySelfException,
|
||||||
ConfirmationRequiredException,
|
ConfirmationRequiredException,
|
||||||
InvalidAdminActionException,
|
InvalidAdminActionException,
|
||||||
|
UserCannotBeDeletedException,
|
||||||
UserNotFoundException,
|
UserNotFoundException,
|
||||||
|
UserRoleChangeException,
|
||||||
UserStatusChangeException,
|
UserStatusChangeException,
|
||||||
VendorVerificationException,
|
VendorVerificationException,
|
||||||
)
|
)
|
||||||
@@ -44,6 +46,17 @@ from .base import (
|
|||||||
WizamartException,
|
WizamartException,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Code quality exceptions
|
||||||
|
from .code_quality import (
|
||||||
|
InvalidViolationStatusException,
|
||||||
|
ScanExecutionException,
|
||||||
|
ScanNotFoundException,
|
||||||
|
ScanParseException,
|
||||||
|
ScanTimeoutException,
|
||||||
|
ViolationNotFoundException,
|
||||||
|
ViolationOperationException,
|
||||||
|
)
|
||||||
|
|
||||||
# Cart exceptions
|
# Cart exceptions
|
||||||
from .cart import (
|
from .cart import (
|
||||||
CartItemNotFoundException,
|
CartItemNotFoundException,
|
||||||
@@ -155,13 +168,16 @@ from .team import (
|
|||||||
|
|
||||||
# Vendor exceptions
|
# Vendor exceptions
|
||||||
from .vendor import (
|
from .vendor import (
|
||||||
|
InsufficientVendorPermissionsException,
|
||||||
InvalidVendorDataException,
|
InvalidVendorDataException,
|
||||||
MaxVendorsReachedException,
|
MaxVendorsReachedException,
|
||||||
UnauthorizedVendorAccessException,
|
UnauthorizedVendorAccessException,
|
||||||
|
VendorAccessDeniedException,
|
||||||
VendorAlreadyExistsException,
|
VendorAlreadyExistsException,
|
||||||
VendorNotActiveException,
|
VendorNotActiveException,
|
||||||
VendorNotFoundException,
|
VendorNotFoundException,
|
||||||
VendorNotVerifiedException,
|
VendorNotVerifiedException,
|
||||||
|
VendorOwnerOnlyException,
|
||||||
VendorValidationException,
|
VendorValidationException,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -245,13 +261,16 @@ __all__ = [
|
|||||||
"InvalidQuantityException",
|
"InvalidQuantityException",
|
||||||
"LocationNotFoundException",
|
"LocationNotFoundException",
|
||||||
# Vendor exceptions
|
# Vendor exceptions
|
||||||
"VendorNotFoundException",
|
"InsufficientVendorPermissionsException",
|
||||||
"VendorAlreadyExistsException",
|
|
||||||
"VendorNotActiveException",
|
|
||||||
"VendorNotVerifiedException",
|
|
||||||
"UnauthorizedVendorAccessException",
|
|
||||||
"InvalidVendorDataException",
|
"InvalidVendorDataException",
|
||||||
"MaxVendorsReachedException",
|
"MaxVendorsReachedException",
|
||||||
|
"UnauthorizedVendorAccessException",
|
||||||
|
"VendorAccessDeniedException",
|
||||||
|
"VendorAlreadyExistsException",
|
||||||
|
"VendorNotActiveException",
|
||||||
|
"VendorNotFoundException",
|
||||||
|
"VendorNotVerifiedException",
|
||||||
|
"VendorOwnerOnlyException",
|
||||||
"VendorValidationException",
|
"VendorValidationException",
|
||||||
# Vendor Domain
|
# Vendor Domain
|
||||||
"VendorDomainNotFoundException",
|
"VendorDomainNotFoundException",
|
||||||
@@ -334,4 +353,12 @@ __all__ = [
|
|||||||
"InvalidAdminActionException",
|
"InvalidAdminActionException",
|
||||||
"BulkOperationException",
|
"BulkOperationException",
|
||||||
"ConfirmationRequiredException",
|
"ConfirmationRequiredException",
|
||||||
|
# Code quality exceptions
|
||||||
|
"ViolationNotFoundException",
|
||||||
|
"ScanNotFoundException",
|
||||||
|
"ScanExecutionException",
|
||||||
|
"ScanTimeoutException",
|
||||||
|
"ScanParseException",
|
||||||
|
"ViolationOperationException",
|
||||||
|
"InvalidViolationStatusException",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -236,3 +236,37 @@ class VendorVerificationException(BusinessLogicException):
|
|||||||
error_code="VENDOR_VERIFICATION_FAILED",
|
error_code="VENDOR_VERIFICATION_FAILED",
|
||||||
details=details,
|
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",
|
error_code="MAX_VENDORS_REACHED",
|
||||||
details=details,
|
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 (
|
from app.exceptions import (
|
||||||
AdminOperationException,
|
AdminOperationException,
|
||||||
CannotModifySelfException,
|
CannotModifySelfException,
|
||||||
|
UserCannotBeDeletedException,
|
||||||
UserNotFoundException,
|
UserNotFoundException,
|
||||||
|
UserRoleChangeException,
|
||||||
UserStatusChangeException,
|
UserStatusChangeException,
|
||||||
ValidationException,
|
ValidationException,
|
||||||
VendorAlreadyExistsException,
|
VendorAlreadyExistsException,
|
||||||
VendorNotFoundException,
|
VendorNotFoundException,
|
||||||
VendorVerificationException,
|
VendorVerificationException,
|
||||||
)
|
)
|
||||||
|
from app.exceptions.auth import UserAlreadyExistsException
|
||||||
|
from middleware.auth import AuthManager
|
||||||
from models.database.company import Company
|
from models.database.company import Company
|
||||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
from models.database.marketplace_import_job import MarketplaceImportJob
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
@@ -97,6 +101,244 @@ class AdminService:
|
|||||||
reason="Database update failed",
|
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
|
# VENDOR MANAGEMENT
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from app.exceptions import (
|
|||||||
)
|
)
|
||||||
from middleware.auth import AuthManager
|
from middleware.auth import AuthManager
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
|
from models.database.vendor import Vendor, VendorUser
|
||||||
from models.schema.auth import UserLogin, UserRegister
|
from models.schema.auth import UserLogin, UserRegister
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -214,6 +215,84 @@ class AuthService:
|
|||||||
logger.error(f"Error creating access token with data: {str(e)}")
|
logger.error(f"Error creating access token with data: {str(e)}")
|
||||||
raise ValidationException("Failed to create access token")
|
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
|
# Private helper methods
|
||||||
def _email_exists(self, db: Session, email: str) -> bool:
|
def _email_exists(self, db: Session, email: str) -> bool:
|
||||||
"""Check if email already exists."""
|
"""Check if email already exists."""
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ from datetime import datetime
|
|||||||
from sqlalchemy import desc, func
|
from sqlalchemy import desc, func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.exceptions import (
|
||||||
|
ScanParseException,
|
||||||
|
ScanTimeoutException,
|
||||||
|
ViolationNotFoundException,
|
||||||
|
)
|
||||||
from models.database.architecture_scan import (
|
from models.database.architecture_scan import (
|
||||||
ArchitectureScan,
|
ArchitectureScan,
|
||||||
ArchitectureViolation,
|
ArchitectureViolation,
|
||||||
@@ -54,7 +59,7 @@ class CodeQualityService:
|
|||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
logger.error("Architecture scan timed out after 5 minutes")
|
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()
|
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"Failed to parse validator output: {e}")
|
||||||
logger.error(f"Stdout: {result.stdout}")
|
logger.error(f"Stdout: {result.stdout}")
|
||||||
logger.error(f"Stderr: {result.stderr}")
|
logger.error(f"Stderr: {result.stderr}")
|
||||||
raise Exception(f"Failed to parse scan results: {e}")
|
raise ScanParseException(reason=str(e))
|
||||||
|
|
||||||
# Create scan record
|
# Create scan record
|
||||||
scan = ArchitectureScan(
|
scan = ArchitectureScan(
|
||||||
@@ -285,7 +290,7 @@ class CodeQualityService:
|
|||||||
"""
|
"""
|
||||||
violation = self.get_violation_by_id(db, violation_id)
|
violation = self.get_violation_by_id(db, violation_id)
|
||||||
if not violation:
|
if not violation:
|
||||||
raise ValueError(f"Violation {violation_id} not found")
|
raise ViolationNotFoundException(violation_id)
|
||||||
|
|
||||||
violation.status = "resolved"
|
violation.status = "resolved"
|
||||||
violation.resolved_at = datetime.now()
|
violation.resolved_at = datetime.now()
|
||||||
@@ -313,7 +318,7 @@ class CodeQualityService:
|
|||||||
"""
|
"""
|
||||||
violation = self.get_violation_by_id(db, violation_id)
|
violation = self.get_violation_by_id(db, violation_id)
|
||||||
if not violation:
|
if not violation:
|
||||||
raise ValueError(f"Violation {violation_id} not found")
|
raise ViolationNotFoundException(violation_id)
|
||||||
|
|
||||||
violation.status = "ignored"
|
violation.status = "ignored"
|
||||||
violation.resolved_at = datetime.now()
|
violation.resolved_at = datetime.now()
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ from datetime import UTC, datetime
|
|||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.exceptions.content_page import (
|
||||||
|
ContentPageNotFoundException,
|
||||||
|
UnauthorizedContentPageAccessException,
|
||||||
|
)
|
||||||
from models.database.content_page import ContentPage
|
from models.database.content_page import ContentPage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -319,6 +323,214 @@ class ContentPageService:
|
|||||||
"""Get content page by ID."""
|
"""Get content page by ID."""
|
||||||
return db.query(ContentPage).filter(ContentPage.id == page_id).first()
|
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
|
@staticmethod
|
||||||
def list_all_vendor_pages(
|
def list_all_vendor_pages(
|
||||||
db: Session, vendor_id: int, include_unpublished: bool = False
|
db: Session, vendor_id: int, include_unpublished: bool = False
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{# app/templates/admin/base.html #}
|
{# app/templates/admin/base.html #}
|
||||||
<!DOCTYPE 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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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="/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" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
<!-- Tailwind CSS with CDN fallback -->
|
<!-- Tailwind CSS v4 (built locally via standalone CLI) -->
|
||||||
<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 -->
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
|
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
|
||||||
|
|
||||||
<!-- Alpine Cloak -->
|
<!-- Alpine Cloak -->
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{# app/templates/admin/login.html #}
|
{# app/templates/admin/login.html #}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html :class="{ 'theme-dark': dark }" x-data="adminLogin()" lang="en">
|
<html :class="{ 'dark': dark }" x-data="adminLogin()" lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
|||||||
@@ -1,519 +1,253 @@
|
|||||||
<!DOCTYPE html>
|
{# app/templates/admin/test-auth-flow.html #}
|
||||||
<html lang="en">
|
{% extends 'admin/base.html' %}
|
||||||
<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;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
{% block title %}Auth Flow Testing{% endblock %}
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
{% block content %}
|
||||||
max-width: 1200px;
|
<div x-data="authFlowTest()" x-init="init()">
|
||||||
margin: 0 auto;
|
{# Page Header #}
|
||||||
background: white;
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8">
|
||||||
padding: 30px;
|
<div>
|
||||||
border-radius: 8px;
|
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
Auth Flow Testing
|
||||||
}
|
</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
h1 {
|
Comprehensive testing for Jinja2 migration auth loop fix
|
||||||
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
|
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
{# Log Level Control #}
|
||||||
// Update status display
|
<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">
|
||||||
function updateStatus() {
|
<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 token = localStorage.getItem('admin_token');
|
||||||
const userStr = localStorage.getItem('admin_user');
|
const userStr = localStorage.getItem('admin_user');
|
||||||
let user = null;
|
let user = null;
|
||||||
@@ -524,121 +258,67 @@
|
|||||||
console.error('Failed to parse user data:', e);
|
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');
|
clearAllData() {
|
||||||
hasTokenEl.textContent = token ? 'Yes' : 'No';
|
console.log('Clearing all localStorage data...');
|
||||||
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...');
|
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
console.log('✅ All data cleared');
|
console.log('All data cleared');
|
||||||
alert('✅ All localStorage data cleared!\n\nCheck console for details.');
|
alert('All localStorage data cleared!');
|
||||||
updateStatus();
|
this.updateStatus();
|
||||||
}
|
},
|
||||||
|
|
||||||
function navigateToAdmin() {
|
navigateTo(path) {
|
||||||
console.log('🚀 Navigating to /admin...');
|
console.log(`Navigating to ${path}...`);
|
||||||
window.location.href = '/admin';
|
window.location.href = path;
|
||||||
}
|
},
|
||||||
|
|
||||||
function navigateToLogin() {
|
checkAuthStatus() {
|
||||||
console.log('🚀 Navigating to /admin/login...');
|
this.updateStatus();
|
||||||
window.location.href = '/admin/login';
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateToDashboard() {
|
|
||||||
console.log('🚀 Navigating to /admin/dashboard...');
|
|
||||||
window.location.href = '/admin/dashboard';
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkAuthStatus() {
|
|
||||||
updateStatus();
|
|
||||||
alert('Check console and status panel for auth details.');
|
alert('Check console and status panel for auth details.');
|
||||||
}
|
},
|
||||||
|
|
||||||
function setExpiredToken() {
|
setExpiredToken() {
|
||||||
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwMjJ9.invalidexpiredtoken';
|
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNTE2MjM5MDIyfQ.invalid';
|
||||||
console.log('⚠️ Setting expired/invalid token...');
|
|
||||||
localStorage.setItem('admin_token', expiredToken);
|
localStorage.setItem('admin_token', expiredToken);
|
||||||
localStorage.setItem('admin_user', JSON.stringify({
|
localStorage.setItem('admin_user', JSON.stringify({
|
||||||
id: 1,
|
id: 1,
|
||||||
username: 'test_expired',
|
username: 'test_expired',
|
||||||
role: 'admin'
|
role: 'admin'
|
||||||
}));
|
}));
|
||||||
console.log('✅ Expired token set');
|
alert('Expired token set! Now try navigating to dashboard.');
|
||||||
alert('⚠️ Expired token set!\n\nNow try navigating to dashboard.');
|
this.updateStatus();
|
||||||
updateStatus();
|
},
|
||||||
}
|
|
||||||
|
|
||||||
function setValidToken() {
|
setMockToken() {
|
||||||
// This is a mock token - won't actually work with backend
|
|
||||||
const mockToken = 'mock_valid_token_' + Date.now();
|
const mockToken = 'mock_valid_token_' + Date.now();
|
||||||
console.log('✅ Setting mock valid token...');
|
|
||||||
localStorage.setItem('admin_token', mockToken);
|
localStorage.setItem('admin_token', mockToken);
|
||||||
localStorage.setItem('admin_user', JSON.stringify({
|
localStorage.setItem('admin_user', JSON.stringify({
|
||||||
id: 1,
|
id: 1,
|
||||||
username: 'test_user',
|
username: 'test_user',
|
||||||
role: 'admin'
|
role: 'admin'
|
||||||
}));
|
}));
|
||||||
console.log('✅ Mock token set (will not work with real backend)');
|
alert('Mock token set! Note: This won\'t work with real backend.');
|
||||||
alert('✅ Mock token set!\n\nNote: This is a fake token and won\'t work with the real backend.');
|
this.updateStatus();
|
||||||
updateStatus();
|
},
|
||||||
}
|
|
||||||
|
|
||||||
// Log level control
|
setLogLevel(level) {
|
||||||
function setLogLevel(level) {
|
if (typeof window.LOG_LEVEL !== 'undefined') {
|
||||||
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') {
|
|
||||||
window.LOG_LEVEL = level;
|
window.LOG_LEVEL = level;
|
||||||
document.getElementById('currentLoginLevel').textContent = level;
|
this.currentLoginLevel = level;
|
||||||
console.log('✅ LOGIN log level set to', level);
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ LOG_LEVEL not found (login.js not loaded)');
|
|
||||||
}
|
}
|
||||||
|
if (typeof window.API_LOG_LEVEL !== 'undefined') {
|
||||||
if (typeof API_LOG_LEVEL !== 'undefined') {
|
|
||||||
window.API_LOG_LEVEL = level;
|
window.API_LOG_LEVEL = level;
|
||||||
document.getElementById('currentApiLevel').textContent = level;
|
this.currentApiLevel = level;
|
||||||
console.log('✅ API log level set to', level);
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ API_LOG_LEVEL not found (api-client.js not loaded)');
|
|
||||||
}
|
}
|
||||||
|
alert(`Log level set to ${level}. Reload to apply to all scripts.`);
|
||||||
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.`);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
// Initialize status on load
|
}
|
||||||
updateStatus();
|
</script>
|
||||||
|
{% endblock %}
|
||||||
// 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>
|
|
||||||
|
|||||||
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="/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" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
{# Tailwind CSS with local fallback #}
|
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
<link rel="stylesheet" href="{{ url_for('static', path='platform/css/tailwind.output.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>
|
|
||||||
|
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</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>
|
{# app/templates/shop/account/addresses.html #}
|
||||||
<html lang="en">
|
{% extends "shop/base.html" %}
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
{% block title %}My Addresses{% endblock %}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Address management</title>
|
{% block content %}
|
||||||
</head>
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<body>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">My Addresses</h1>
|
||||||
<-- Address management -->
|
|
||||||
</body>
|
{# TODO: Implement address management #}
|
||||||
</html>
|
<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 #}
|
{# app/templates/shop/account/forgot-password.html #}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html :class="{ 'theme-dark': dark }" x-data="forgotPassword()" lang="en">
|
<html :class="{ 'dark': dark }" x-data="forgotPassword()" lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -37,9 +37,8 @@
|
|||||||
[x-cloak] { display: none !important; }
|
[x-cloak] { display: none !important; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
{# Tailwind CSS with local fallback #}
|
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
<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 #}
|
{# app/templates/shop/account/login.html #}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html :class="{ 'theme-dark': dark }" x-data="customerLogin()" lang="en">
|
<html :class="{ 'dark': dark }" x-data="customerLogin()" lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -37,9 +37,8 @@
|
|||||||
[x-cloak] { display: none !important; }
|
[x-cloak] { display: none !important; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
{# Tailwind CSS with local fallback #}
|
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
<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>
|
{# app/templates/shop/account/orders.html #}
|
||||||
<html lang="en">
|
{% extends "shop/base.html" %}
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
{% block title %}Order History{% endblock %}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Order history</title>
|
{% block content %}
|
||||||
</head>
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<body>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Order History</h1>
|
||||||
<-- Order history -->
|
|
||||||
</body>
|
{# TODO: Implement order history #}
|
||||||
</html>
|
<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>
|
{# app/templates/shop/account/profile.html #}
|
||||||
<html lang="en">
|
{% extends "shop/base.html" %}
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
{% block title %}My Profile{% endblock %}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Customer profile</title>
|
{% block content %}
|
||||||
</head>
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<body>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">My Profile</h1>
|
||||||
<-- Customer profile -->
|
|
||||||
</body>
|
{# TODO: Implement profile management #}
|
||||||
</html>
|
<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 #}
|
{# app/templates/shop/account/register.html #}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html :class="{ 'theme-dark': dark }" x-data="customerRegistration()" lang="en">
|
<html :class="{ 'dark': dark }" x-data="customerRegistration()" lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -37,9 +37,8 @@
|
|||||||
[x-cloak] { display: none !important; }
|
[x-cloak] { display: none !important; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
{# Tailwind CSS with local fallback #}
|
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||||
|
|||||||
@@ -37,9 +37,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
{# Tailwind CSS with local fallback #}
|
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
|
||||||
|
|
||||||
{# Base Shop Styles #}
|
{# Base Shop Styles #}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/shop.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/shop.css') }}">
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<DOCTYPE html>
|
{# app/templates/shop/checkout.html #}
|
||||||
<html lang="en">
|
{% extends "shop/base.html" %}
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
{% block title %}Checkout{% endblock %}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Checkout process</title>
|
{% block content %}
|
||||||
</head>
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<body>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Checkout</h1>
|
||||||
<-- Checkout process -->
|
|
||||||
</body>
|
{# TODO: Implement checkout process #}
|
||||||
</html>
|
<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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" class="h-full">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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>
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
/* Default theme colors (fallback) */
|
|
||||||
--color-primary: {{ theme.colors.primary if theme and theme.colors else '#6366f1' }};
|
--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-secondary: {{ theme.colors.secondary if theme and theme.colors else '#8b5cf6' }};
|
||||||
--color-accent: {{ theme.colors.accent if theme and theme.colors else '#ec4899' }};
|
--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" }};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
.bg-gradient-theme {
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
|
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 {
|
.text-theme-primary {
|
||||||
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;
|
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-name {
|
.bg-theme-primary {
|
||||||
font-size: 1.75rem;
|
background-color: var(--color-primary);
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.border-theme-primary {
|
||||||
font-size: 1.125rem;
|
border-color: var(--color-primary);
|
||||||
color: #6b7280;
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.hover\:bg-theme-primary:hover {
|
||||||
display: flex;
|
background-color: var(--color-primary);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{% block extra_styles %}{% endblock %}
|
{% block extra_styles %}{% endblock %}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
{% if theme and theme.custom_css %}
|
{% if theme and theme.custom_css %}
|
||||||
<style>
|
<style>{{ theme.custom_css | safe }}</style>
|
||||||
{{ theme.custom_css | safe }}
|
|
||||||
</style>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="h-full bg-gradient-theme flex items-center justify-center p-8">
|
||||||
<div class="error-container">
|
<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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="error-icon">{% block icon %}⚠️{% endblock %}</div>
|
{# Error Icon #}
|
||||||
<div class="status-code">{{ status_code }}</div>
|
<div class="text-7xl mb-4">{% block icon %}⚠️{% endblock %}</div>
|
||||||
<div class="status-name">{{ status_name }}</div>
|
|
||||||
<div class="error-message">{{ message }}</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 %}
|
{% block action_buttons %}
|
||||||
<a href="{{ base_url }}shop/" class="btn btn-primary">Continue Shopping</a>
|
<a href="{{ base_url }}shop/"
|
||||||
<a href="{{ base_url }}shop/contact" class="btn btn-secondary">Contact Us</a>
|
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 %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% block extra_content %}{% endblock %}
|
{% 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 %}
|
{% 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 %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Vendor Info #}
|
||||||
{% if vendor %}
|
{% if vendor %}
|
||||||
<div class="vendor-info">
|
<div class="mt-8 text-sm text-gray-400">
|
||||||
{{ vendor.name }}
|
{{ vendor.name }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<DOCTYPE html>
|
{# app/templates/shop/search.html #}
|
||||||
<html lang="en">
|
{% extends "shop/base.html" %}
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
{% block title %}Search Results{% endblock %}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Search results page</title>
|
{% block content %}
|
||||||
</head>
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<body>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Search Results</h1>
|
||||||
<-- Search results page -->
|
|
||||||
</body>
|
{# TODO: Implement search results #}
|
||||||
</html>
|
<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 #}
|
{# app/templates/vendor/base.html #}
|
||||||
<!DOCTYPE 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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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="/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" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
<!-- Tailwind CSS with CDN fallback -->
|
<!-- Tailwind CSS v4 (built locally via standalone CLI) -->
|
||||||
<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 -->
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='vendor/css/tailwind.output.css') }}" />
|
<link rel="stylesheet" href="{{ url_for('static', path='vendor/css/tailwind.output.css') }}" />
|
||||||
|
|
||||||
<!-- Alpine Cloak -->
|
<!-- 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 #}
|
{# app/templates/vendor/login.html #}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html :class="{ 'theme-dark': dark }" x-data="vendorLogin()" lang="en">
|
<html :class="{ 'dark': dark }" x-data="vendorLogin()" lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
@@ -355,18 +355,40 @@ def update_product(
|
|||||||
return ProductResponse.model_validate(product)
|
return ProductResponse.model_validate(product)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Files to Migrate
|
## Migration Status
|
||||||
|
|
||||||
Current files still using `require_vendor_context()`:
|
**COMPLETED** - All vendor API endpoints have been migrated to use the token-based vendor context pattern.
|
||||||
- `app/api/v1/vendor/customers.py`
|
|
||||||
- `app/api/v1/vendor/notifications.py`
|
### Migrated Files
|
||||||
- `app/api/v1/vendor/media.py`
|
All vendor API files now use `current_user.token_vendor_id`:
|
||||||
- `app/api/v1/vendor/marketplace.py`
|
- `app/api/v1/vendor/customers.py` ✅
|
||||||
- `app/api/v1/vendor/inventory.py`
|
- `app/api/v1/vendor/notifications.py` ✅
|
||||||
- `app/api/v1/vendor/settings.py`
|
- `app/api/v1/vendor/media.py` ✅
|
||||||
- `app/api/v1/vendor/analytics.py`
|
- `app/api/v1/vendor/marketplace.py` ✅
|
||||||
- `app/api/v1/vendor/payments.py`
|
- `app/api/v1/vendor/inventory.py` ✅
|
||||||
- `app/api/v1/vendor/profile.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
|
## 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
|
## Related Documentation
|
||||||
|
|
||||||
- [Vendor RBAC System](./vendor-rbac.md) - Role-based access control for vendors
|
- [Vendor RBAC System](./vendor-rbac.md) - Role-based access control for vendors
|
||||||
- [Vendor Authentication](./vendor-authentication.md) - Complete authentication guide
|
- [Authentication & RBAC](../architecture/auth-rbac.md) - Complete authentication guide
|
||||||
- [Architecture Rules](../architecture/rules/) - All architecture rules
|
- [Architecture Patterns](../architecture/architecture-patterns.md) - All architecture patterns
|
||||||
- [API Design Guidelines](../architecture/api-design.md) - RESTful API patterns
|
- [Middleware Reference](./middleware-reference.md) - Middleware patterns
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
|
|||||||
@@ -136,32 +136,36 @@ async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
|||||||
- `db.commit()`
|
- `db.commit()`
|
||||||
- `db.query(`
|
- `db.query(`
|
||||||
|
|
||||||
#### API-003: Catch Service Exceptions
|
#### API-003: No HTTPException in Endpoints
|
||||||
**Severity:** Error
|
**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
|
```python
|
||||||
# ✅ Good
|
# ✅ Good - Let domain exceptions bubble up
|
||||||
@router.post("/vendors")
|
@router.post("/vendors")
|
||||||
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||||
try:
|
# Service raises VendorAlreadyExistsException if duplicate
|
||||||
result = vendor_service.create_vendor(db, vendor)
|
# Global handler converts to 409 Conflict
|
||||||
return result
|
return vendor_service.create_vendor(db, vendor)
|
||||||
except VendorAlreadyExistsError as e:
|
|
||||||
raise HTTPException(status_code=409, detail=str(e))
|
# ❌ Bad - Don't raise HTTPException directly
|
||||||
except Exception as e:
|
@router.post("/vendors")
|
||||||
logger.error(f"Unexpected error: {e}")
|
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
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
|
#### API-004: Proper Authentication
|
||||||
**Severity:** Warning
|
**Severity:** Warning
|
||||||
|
|
||||||
Protected endpoints must use Depends() for authentication.
|
Protected endpoints must use Depends() for authentication.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ✅ Good
|
# ✅ Good - Protected endpoint with authentication
|
||||||
@router.post("/vendors")
|
@router.post("/vendors")
|
||||||
async def create_vendor(
|
async def create_vendor(
|
||||||
vendor: VendorCreate,
|
vendor: VendorCreate,
|
||||||
@@ -171,6 +175,31 @@ async def create_vendor(
|
|||||||
pass
|
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
|
#### API-005: Multi-Tenant Scoping
|
||||||
**Severity:** Error
|
**Severity:** Error
|
||||||
|
|
||||||
@@ -542,12 +571,41 @@ async loadData() {
|
|||||||
#### TPL-001: Admin Templates Extend admin/base.html
|
#### TPL-001: Admin Templates Extend admin/base.html
|
||||||
**Severity:** Error
|
**Severity:** Error
|
||||||
|
|
||||||
|
All admin templates must extend the base template for consistent layout (sidebar, navigation, etc.).
|
||||||
|
|
||||||
```jinja
|
```jinja
|
||||||
✅ {% extends "admin/base.html" %}
|
{# ✅ Good - Extends base template #}
|
||||||
❌ No extends directive
|
{% 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
|
#### TPL-002: Vendor Templates Extend vendor/base.html
|
||||||
**Severity:** Error
|
**Severity:** Error
|
||||||
@@ -789,12 +847,13 @@ Before committing code:
|
|||||||
| Violation | Quick Fix |
|
| Violation | Quick Fix |
|
||||||
|-----------|-----------|
|
|-----------|-----------|
|
||||||
| HTTPException in service | Create custom exception in `app/exceptions/` |
|
| 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 |
|
| 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()` |
|
| console.log in JS | Use `window.LogConfig.createLogger()` |
|
||||||
| Missing ...data() | Add spread operator in component return |
|
| Missing ...data() | Add spread operator in component return |
|
||||||
| Bare except clause | Specify exception type |
|
| Bare except clause | Specify exception type |
|
||||||
| Raw dict return | Create Pydantic response model |
|
| 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
|
**Last Updated:** 2025-12-04
|
||||||
**Version:** 2.0
|
**Version:** 2.2
|
||||||
|
|||||||
@@ -324,5 +324,5 @@ Migration failures will halt deployment to prevent data corruption.
|
|||||||
## Further Reading
|
## Further Reading
|
||||||
|
|
||||||
- [Alembic Official Documentation](https://alembic.sqlalchemy.org/)
|
- [Alembic Official Documentation](https://alembic.sqlalchemy.org/)
|
||||||
- [Database Setup Guide](../getting-started/database-setup-guide.md)
|
- [Database Seeder Documentation](../database-seeder/database-seeder-documentation.md)
|
||||||
- [Deployment Guide](../deployment/production.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
|
// marketplace.js
|
||||||
return {
|
return {
|
||||||
...data(),
|
...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
|
## 🎨 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
|
**File Location:** `app/templates/admin/partials/sidebar.html`
|
||||||
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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📦 Files Created
|
## Sidebar Structure
|
||||||
|
|
||||||
### JavaScript Files (Alpine.js Components)
|
The sidebar is organized into the following sections:
|
||||||
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
|
|
||||||
|
|
||||||
### HTML Templates
|
| Section | Collapsible | Pages |
|
||||||
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
|
| Dashboard | No | Dashboard |
|
||||||
3. **[icons.html](computer:///mnt/user-data/outputs/icons.html)** - Icons browser page
|
| Platform Administration | Yes | Companies, Vendors, Users, Customers, Marketplace |
|
||||||
|
| Content Management | Yes | Platform Homepage, Content Pages, Vendor Themes |
|
||||||
### Sidebar Update
|
| Developer Tools | Yes | Components, Icons, Testing Hub, Code Quality |
|
||||||
1. **[sidebar-fixed.html](computer:///mnt/user-data/outputs/sidebar-fixed.html)** - Fixed sidebar with active indicators
|
| Platform Monitoring | Yes | Import Jobs, Application Logs |
|
||||||
|
| Settings | No | Settings |
|
||||||
### Documentation
|
|
||||||
1. **[ARCHITECTURE_CONFIRMATION.md](computer:///mnt/user-data/outputs/ARCHITECTURE_CONFIRMATION.md)** - Architecture confirmation
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 Installation Steps
|
## Collapsible Sections
|
||||||
|
|
||||||
### Step 1: Install JavaScript Files
|
### How It Works
|
||||||
|
|
||||||
```bash
|
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.
|
||||||
# Copy to your static directory
|
|
||||||
cp outputs/testing-hub.js static/admin/js/testing-hub.js
|
### State Management
|
||||||
cp outputs/components.js static/admin/js/components.js
|
|
||||||
cp outputs/icons-page.js static/admin/js/icons-page.js
|
**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
|
| Method | Description |
|
||||||
# Copy to your templates directory
|
|--------|-------------|
|
||||||
cp outputs/testing-hub.html app/templates/admin/testing-hub.html
|
| `toggleSection(section)` | Toggle a section open/closed |
|
||||||
cp outputs/components.html app/templates/admin/components.html
|
| `expandSectionForCurrentPage()` | Auto-expand section containing current page |
|
||||||
cp outputs/icons.html app/templates/admin/icons.html
|
| `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
|
Sections animate smoothly using CSS transitions (no plugins required):
|
||||||
# Replace your current sidebar
|
|
||||||
cp outputs/sidebar-fixed.html app/templates/partials/sidebar.html
|
```html
|
||||||
```
|
<ul
|
||||||
|
x-show="openSections.platformAdmin"
|
||||||
### Step 4: Add Icons Route
|
x-transition:enter="transition-all duration-200 ease-out"
|
||||||
|
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||||
Update `app/api/v1/admin/pages.py` - add this route:
|
x-transition:enter-end="opacity-100 translate-y-0"
|
||||||
|
x-transition:leave="transition-all duration-150 ease-in"
|
||||||
```python
|
x-transition:leave-start="opacity-100 translate-y-0"
|
||||||
@router.get("/icons", response_class=HTMLResponse, include_in_schema=False)
|
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||||
async def admin_icons_page(
|
class="mt-1 overflow-hidden"
|
||||||
request: Request,
|
>
|
||||||
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
```
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
### Chevron Icon Rotation
|
||||||
"""
|
|
||||||
Render icons browser page.
|
The chevron icon rotates 180 degrees when a section is expanded:
|
||||||
Browse and search all available icons.
|
|
||||||
"""
|
```html
|
||||||
return templates.TemplateResponse(
|
<span
|
||||||
"admin/icons.html",
|
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
|
||||||
{
|
:class="{ 'rotate-180': openSections.platformAdmin }"
|
||||||
"request": request,
|
></span>
|
||||||
"user": current_user,
|
```
|
||||||
}
|
|
||||||
)
|
---
|
||||||
```
|
|
||||||
|
## Page-to-Section Mapping
|
||||||
### Step 5: Verify Icons Are Updated
|
|
||||||
|
Pages are mapped to their parent sections for auto-expansion:
|
||||||
Make sure you're using the updated `icons-updated.js` from earlier:
|
|
||||||
|
```javascript
|
||||||
```bash
|
const pageSectionMap = {
|
||||||
cp outputs/icons-updated.js static/shared/js/icons.js
|
// Platform Administration
|
||||||
```
|
companies: 'platformAdmin',
|
||||||
|
vendors: 'platformAdmin',
|
||||||
### Step 6: Restart Server
|
users: 'platformAdmin',
|
||||||
|
customers: 'platformAdmin',
|
||||||
```bash
|
marketplace: 'platformAdmin',
|
||||||
# Stop current server (Ctrl+C)
|
// Content Management
|
||||||
# Start again
|
'platform-homepage': 'contentMgmt',
|
||||||
uvicorn app.main:app --reload
|
'content-pages': 'contentMgmt',
|
||||||
```
|
'vendor-theme': 'contentMgmt',
|
||||||
|
// Developer Tools
|
||||||
---
|
components: 'devTools',
|
||||||
|
icons: 'devTools',
|
||||||
## 🐛 Sidebar Active Indicator Fix
|
testing: 'devTools',
|
||||||
|
'code-quality': 'devTools',
|
||||||
### The Problem
|
// Platform Monitoring
|
||||||
|
imports: 'monitoring',
|
||||||
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.
|
logs: 'monitoring'
|
||||||
|
};
|
||||||
### 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):**
|
## Complete Page Mapping
|
||||||
```html
|
|
||||||
<!-- Only dashboard had the x-show condition -->
|
| Page | `currentPage` Value | Section | URL |
|
||||||
<span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"></span>
|
|------|---------------------|---------|-----|
|
||||||
```
|
| Dashboard | `'dashboard'` | (always visible) | `/admin/dashboard` |
|
||||||
|
| Companies | `'companies'` | Platform Administration | `/admin/companies` |
|
||||||
### The Fix
|
| Vendors | `'vendors'` | Platform Administration | `/admin/vendors` |
|
||||||
|
| Users | `'users'` | Platform Administration | `/admin/users` |
|
||||||
**1. Sidebar HTML** - Add the indicator `<span>` to EVERY menu item:
|
| 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
|
```html
|
||||||
<!-- Vendors -->
|
|
||||||
<li class="relative px-6 py-3">
|
<li class="relative px-6 py-3">
|
||||||
<!-- ✅ Add this span for the purple bar -->
|
<!-- 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"></span>
|
<span x-show="currentPage === 'vendors'"
|
||||||
<a :class="currentPage === 'vendors' ? 'text-gray-800 dark:text-gray-100' : ''"
|
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">
|
href="/admin/vendors">
|
||||||
<span x-html="$icon('shopping-bag')"></span>
|
<span x-html="$icon('shopping-bag')"></span>
|
||||||
<span class="ml-4">Vendors</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>
|
</li>
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. JavaScript Files** - Each component must set `currentPage`:
|
### Setting currentPage in Components
|
||||||
|
|
||||||
|
Each page component must set `currentPage` to match the sidebar:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// vendors.js
|
|
||||||
function adminVendors() {
|
function adminVendors() {
|
||||||
return {
|
return {
|
||||||
...data(),
|
...data(), // Inherit base (includes openSections)
|
||||||
currentPage: 'vendors', // ✅ Must match sidebar check
|
currentPage: 'vendors', // Must match sidebar check
|
||||||
// ... rest of component
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// users.js
|
|
||||||
function adminUsers() {
|
|
||||||
return {
|
|
||||||
...data(),
|
|
||||||
currentPage: 'users', // ✅ Must match sidebar check
|
|
||||||
// ... rest of component
|
// ... 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
|
The sidebar uses Jinja2 macros for DRY code:
|
||||||
- **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
|
|
||||||
|
|
||||||
### Components Library
|
### section_header
|
||||||
- **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
|
|
||||||
|
|
||||||
### Icons Browser
|
Creates a collapsible section header with chevron:
|
||||||
- **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
|
|
||||||
|
|
||||||
---
|
```jinja2
|
||||||
|
{% macro section_header(title, section_key) %}
|
||||||
## 🎯 How Each Feature Works
|
<button
|
||||||
|
@click="toggleSection('{{ section_key }}')"
|
||||||
### Components Library Navigation
|
class="flex items-center justify-between w-full px-6 py-2 ..."
|
||||||
|
>
|
||||||
1. **Click a section** in the left sidebar
|
<span>{{ title }}</span>
|
||||||
2. **Page scrolls** to that section smoothly
|
<span x-html="$icon('chevron-down', '...')"
|
||||||
3. **URL updates** with hash (#forms)
|
:class="{ 'rotate-180': openSections.{{ section_key }} }"></span>
|
||||||
4. **Active section** is highlighted in purple
|
</button>
|
||||||
5. **Bookmarkable**: Share URL with #section
|
{% endmacro %}
|
||||||
|
|
||||||
```javascript
|
|
||||||
// How it works
|
|
||||||
goToSection(sectionId) {
|
|
||||||
this.activeSection = sectionId;
|
|
||||||
window.location.hash = sectionId;
|
|
||||||
// Smooth scroll
|
|
||||||
document.getElementById(sectionId).scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Icons Browser Search
|
### section_content
|
||||||
|
|
||||||
1. **Type in search box** - filters as you type
|
Wraps section items with collapse animation:
|
||||||
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
|
|
||||||
|
|
||||||
```javascript
|
```jinja2
|
||||||
// How it works
|
{% macro section_content(section_key) %}
|
||||||
filterIcons() {
|
<ul x-show="openSections.{{ section_key }}" x-transition:...>
|
||||||
let icons = this.allIcons;
|
{{ caller() }}
|
||||||
|
</ul>
|
||||||
// Filter by category
|
{% endmacro %}
|
||||||
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;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing Hub Navigation
|
### menu_item
|
||||||
|
|
||||||
1. **View stats** at the top
|
Creates a menu item with active indicator:
|
||||||
2. **Read test suite cards** with features
|
|
||||||
3. **Click "Run Tests"** to go to test page
|
|
||||||
4. **Read best practices** before testing
|
|
||||||
|
|
||||||
---
|
```jinja2
|
||||||
|
{% macro menu_item(page_id, url, icon, label) %}
|
||||||
## 🧪 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
|
|
||||||
<li class="relative px-6 py-3">
|
<li class="relative px-6 py-3">
|
||||||
<!-- Active indicator -->
|
<span x-show="currentPage === '{{ page_id }}'" class="..."></span>
|
||||||
<span x-show="currentPage === 'page-name'"
|
<a href="{{ url }}">
|
||||||
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg">
|
<span x-html="$icon('{{ icon }}')"></span>
|
||||||
</span>
|
<span class="ml-4">{{ label }}</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>
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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
|
### Step 1: Add Route
|
||||||
**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
|
|
||||||
|
|
||||||
**Total Files:** 7 (3 JS + 3 HTML + 1 Sidebar)
|
Add the route in `app/routes/admin_pages.py`:
|
||||||
**Lines of Code:** ~2000+
|
|
||||||
**Features Added:** 20+
|
|
||||||
|
|
||||||
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
|
## 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
|
**Live Component Library:** Visit `/admin/components` in the admin panel to see all components with copy-paste ready code.
|
||||||
**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.
|
|
||||||
|
|
||||||
### 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
|
## Page Layout Structure
|
||||||
**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
|
|
||||||
|
|
||||||
### 4. JavaScript for Detail Page
|
All admin list pages follow a consistent structure:
|
||||||
**File:** `static/admin/js/vendor-detail.js`
|
|
||||||
- Loads vendor data
|
|
||||||
- Handles delete action with double confirmation
|
|
||||||
- Logging for debugging
|
|
||||||
- Error handling
|
|
||||||
|
|
||||||
## Implementation Steps
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
### Step 1: Add Components Menu to Sidebar
|
│ Page Header (Title + Action Button) │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
Update your `app/templates/admin/sidebar.html` (or wherever your sidebar is defined):
|
│ Stats Cards (4 columns on desktop) │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
```html
|
│ Search & Filters Bar │
|
||||||
<!-- Add this menu item after "Settings" or wherever appropriate -->
|
├─────────────────────────────────────────────────────┤
|
||||||
<li class="relative px-6 py-3">
|
│ Data Table │
|
||||||
<a
|
│ ├── Table Header │
|
||||||
class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
│ ├── Table Rows │
|
||||||
:class="{ 'text-gray-800 dark:text-gray-100': currentPage === 'components' }"
|
│ └── Pagination │
|
||||||
href="/admin/components"
|
└─────────────────────────────────────────────────────┘
|
||||||
>
|
|
||||||
<span x-html="$icon('collection', 'w-5 h-5')"></span>
|
|
||||||
<span class="ml-4">Components</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Add Components Page Route
|
---
|
||||||
|
|
||||||
Update your `app/api/v1/admin/pages.py`:
|
## Form Components
|
||||||
|
|
||||||
```python
|
### Basic Input
|
||||||
@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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
```html
|
||||||
<label class="block mb-4 text-sm">
|
<label class="block mb-4 text-sm">
|
||||||
<span class="text-gray-700 dark:text-gray-400">Field Label</span>
|
<span class="text-gray-700 dark:text-gray-400">Field Label</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
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"
|
x-model="formData.field"
|
||||||
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.fieldName }"
|
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>
|
</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
|
```html
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
x-model="data.field"
|
||||||
disabled
|
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"
|
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
|
```html
|
||||||
<textarea
|
<textarea
|
||||||
x-model="formData.description"
|
x-model="formData.description"
|
||||||
@@ -147,26 +89,90 @@ async def admin_components_page(
|
|||||||
></textarea>
|
></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
|
```html
|
||||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
<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>
|
<span x-html="$icon('user-group', 'w-5 h-5')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||||
Total Users
|
Total Users
|
||||||
</p>
|
</p>
|
||||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">
|
||||||
1,234
|
0
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
```html
|
||||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
<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">
|
<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>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Button Components
|
---
|
||||||
|
|
||||||
|
## Status Badges
|
||||||
|
|
||||||
|
### Success Badge
|
||||||
|
|
||||||
#### Primary Button
|
|
||||||
```html
|
```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">
|
<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">
|
||||||
Button Text
|
<span x-html="$icon('check-circle', 'w-3 h-3 mr-1')"></span>
|
||||||
</button>
|
Active
|
||||||
|
</span>
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Button with Icon
|
### Warning Badge
|
||||||
|
|
||||||
```html
|
```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 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('plus', 'w-4 h-4 mr-2')"></span>
|
<span x-html="$icon('clock', 'w-3 h-3 mr-1')"></span>
|
||||||
Add Item
|
Pending
|
||||||
</button>
|
</span>
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Secondary Button
|
### Danger Badge
|
||||||
|
|
||||||
```html
|
```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">
|
<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">
|
||||||
Cancel
|
<span x-html="$icon('x-circle', 'w-3 h-3 mr-1')"></span>
|
||||||
</button>
|
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**
|
## Data Tables
|
||||||
- Clear sections with headers
|
|
||||||
- Two-column layout on desktop
|
|
||||||
- Helper text for all fields
|
|
||||||
- Proper validation states
|
|
||||||
|
|
||||||
3. **Visual Consistency**
|
### Basic Table Structure
|
||||||
- Uses standard form components
|
|
||||||
- Consistent spacing and sizing
|
|
||||||
- Dark mode support
|
|
||||||
|
|
||||||
4. **User Experience**
|
```html
|
||||||
- Disabled states for read-only fields
|
<div class="w-full overflow-hidden rounded-lg shadow-xs">
|
||||||
- Clear indication of required fields
|
<div class="w-full overflow-x-auto">
|
||||||
- Loading states
|
<table class="w-full whitespace-no-wrap">
|
||||||
- Error messages inline with fields
|
<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**
|
```html
|
||||||
- 4 stats cards at top showing key metrics
|
<div class="flex items-center space-x-2 text-sm">
|
||||||
- Visual status indicators (colors, icons)
|
<!-- 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**
|
<!-- Edit -->
|
||||||
- Basic info card
|
<a :href="'/admin/resource/' + item.id + '/edit'"
|
||||||
- Contact info card
|
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"
|
||||||
- Business details section
|
title="Edit">
|
||||||
- Owner information section
|
<span x-html="$icon('edit', 'w-5 h-5')"></span>
|
||||||
- Marketplace URLs (if available)
|
</a>
|
||||||
|
|
||||||
3. **Actions**
|
<!-- Delete -->
|
||||||
- Edit button (goes to edit page)
|
<button @click="deleteItem(item)"
|
||||||
- Delete button (with double confirmation)
|
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"
|
||||||
- Back to list button
|
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
|
### Loading State
|
||||||
- **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)
|
|
||||||
|
|
||||||
## 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
|
### Error Alert
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
## 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`)
|
```html
|
||||||
- **Success**: Green (`bg-green-600`, `text-green-600`)
|
<div class="flex flex-col items-center py-12">
|
||||||
- **Warning**: Orange (`bg-orange-600`, `text-orange-600`)
|
<span x-html="$icon('inbox', 'w-12 h-12 text-gray-400 mb-4')"></span>
|
||||||
- **Danger**: Red (`bg-red-600`, `text-red-600`)
|
<p class="text-lg font-medium text-gray-600 dark:text-gray-400">No items found</p>
|
||||||
- **Info**: Blue (`bg-blue-600`, `text-blue-600`)
|
<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
|
## Modal Components
|
||||||
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.)
|
|
||||||
|
|
||||||
## Tips
|
### Confirmation Modal
|
||||||
|
|
||||||
- Always reference `/admin/components` when building new pages
|
```html
|
||||||
- Copy component HTML directly from the components page
|
<div
|
||||||
- Maintain consistent spacing and styling
|
x-show="showModal"
|
||||||
- Use Alpine.js x-model for form bindings
|
x-cloak
|
||||||
- Use your icon system with `x-html="$icon('icon-name', 'w-5 h-5')"`
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||||
- Test in both light and dark modes
|
@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
|
# Tailwind CSS Build Guide
|
||||||
|
|
||||||
**Version:** 1.0
|
**Version:** 2.0
|
||||||
**Last Updated:** December 2024
|
**Last Updated:** December 2024
|
||||||
**Audience:** Frontend Developers
|
**Audience:** Frontend Developers
|
||||||
|
|
||||||
@@ -8,17 +8,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The platform uses [Tailwind CSS](https://tailwindcss.com/) for styling with a **dual-layer architecture**:
|
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.
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,180 +17,230 @@ This layered approach allows:
|
|||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
```
|
```
|
||||||
Browser loads:
|
Tailwind Standalone CLI (single binary, no npm)
|
||||||
1. CDN Tailwind 2.2.19 (base utilities)
|
│
|
||||||
└── Fallback: static/shared/css/tailwind.min.css
|
├── static/admin/css/tailwind.css → tailwind.output.css (Admin)
|
||||||
|
├── static/vendor/css/tailwind.css → tailwind.output.css (Vendor)
|
||||||
2. Custom tailwind.output.css (overrides/extensions)
|
├── static/shop/css/tailwind.css → tailwind.output.css (Shop)
|
||||||
└── Built from: static/admin/css/tailwind.css
|
└── static/platform/css/tailwind.css → tailwind.output.css (Platform)
|
||||||
└── Contains: Windmill Dashboard theme, custom colors, dark mode
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Files
|
### Key Files
|
||||||
|
|
||||||
| File | Version | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|---------|
|
|------|---------|
|
||||||
| CDN `tailwindcss@2.2.19` | 2.2.19 | Base Tailwind utilities |
|
| `~/.local/bin/tailwindcss` | Standalone CLI binary (v4.x) |
|
||||||
| `static/shared/css/tailwind.min.css` | 2.2.19 | Local fallback for CDN |
|
| `static/*/css/tailwind.css` | CSS-first source configuration |
|
||||||
| `tailwind.config.js` | 1.4.6 | Custom build configuration |
|
| `static/*/css/tailwind.output.css` | Compiled output (do not edit) |
|
||||||
| `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 |
|
|
||||||
|
|
||||||
### Template Loading Order
|
### Template Loading
|
||||||
|
|
||||||
|
Each frontend loads its own CSS:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- 1. Base Tailwind from CDN (with fallback) -->
|
<!-- Admin -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
|
||||||
onerror="this.onerror=null; this.href='/static/shared/css/tailwind.min.css';">
|
|
||||||
|
|
||||||
<!-- 2. Custom overrides (built via npm) -->
|
<!-- Vendor -->
|
||||||
<link rel="stylesheet" href="/static/admin/css/tailwind.output.css" />
|
<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
|
/* Import Tailwind */
|
||||||
purge: {
|
@import "tailwindcss";
|
||||||
content: [
|
|
||||||
'public/**/*.html',
|
/* Content sources for tree-shaking */
|
||||||
'app/templates/**/*.html', // Jinja2 templates
|
@source "../../../app/templates/admin/**/*.html";
|
||||||
'static/**/*.js', // Alpine.js components
|
@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: [...],
|
## Custom Color Palette
|
||||||
safelist: [
|
|
||||||
'bg-orange-600',
|
All frontends share the same Windmill Dashboard color palette:
|
||||||
'bg-green-600',
|
|
||||||
'bg-red-600',
|
```css
|
||||||
'hover:bg-orange-700',
|
@theme {
|
||||||
'hover:bg-green-700',
|
/* Gray (custom darker palette) */
|
||||||
'hover:bg-red-700',
|
--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
|
## Building Tailwind CSS
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
Install Node.js dependencies (one-time setup):
|
Install the standalone CLI (one-time setup):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
make tailwind-install
|
||||||
```
|
```
|
||||||
|
|
||||||
Or using Make:
|
Or manually:
|
||||||
|
|
||||||
```bash
|
```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
|
### Development Build
|
||||||
|
|
||||||
For development, build without purging (includes all classes, larger file):
|
Build all frontends (includes all classes, larger files):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build admin CSS
|
|
||||||
npm run tailwind:admin
|
|
||||||
|
|
||||||
# Build vendor CSS
|
|
||||||
npm run tailwind:vendor
|
|
||||||
|
|
||||||
# Or using Make
|
|
||||||
make tailwind-dev
|
make tailwind-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Production Build
|
### Production Build
|
||||||
|
|
||||||
For production, build with purging and minification (smaller file):
|
Build with minification (smaller files):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Or using Make
|
|
||||||
make tailwind-build
|
make tailwind-build
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### Watch Mode
|
||||||
|
|
||||||
## Available npm Scripts
|
Watch for changes during development:
|
||||||
|
|
||||||
| Script | Command | Description |
|
```bash
|
||||||
|--------|---------|-------------|
|
make tailwind-watch
|
||||||
| `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',
|
|
||||||
// ...
|
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
```javascript
|
||||||
theme: {
|
document.documentElement.classList.toggle('dark');
|
||||||
extend: {
|
localStorage.setItem('darkMode', dark);
|
||||||
colors: {
|
```
|
||||||
'brand': '#123456',
|
|
||||||
},
|
---
|
||||||
spacing: {
|
|
||||||
'128': '32rem',
|
## 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
|
Add reusable component classes:
|
||||||
make tailwind-dev
|
|
||||||
|
```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
|
### Classes not appearing
|
||||||
|
|
||||||
1. **Check purge paths** - Ensure your file is in a scanned directory
|
1. **Check @source paths** - Ensure your templates are in a scanned directory
|
||||||
2. **Check safelist** - Dynamic classes need to be safelisted
|
2. **Check class exists** - Tailwind v4 may have renamed some classes
|
||||||
3. **Rebuild CSS** - Run `make tailwind-dev` after config changes
|
3. **Rebuild CSS** - Run `make tailwind-dev` after config changes
|
||||||
|
|
||||||
### Dynamic classes in Alpine.js
|
### 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
|
```html
|
||||||
<!-- This class might be purged -->
|
<!-- This works - full class names visible -->
|
||||||
<div :class="isActive ? 'bg-green-600' : 'bg-red-600'">
|
<div :class="isActive ? 'bg-green-600' : 'bg-red-600'">
|
||||||
```
|
```
|
||||||
|
|
||||||
**Solution:** Add to safelist:
|
### Class name changes (v2 → v4)
|
||||||
|
|
||||||
```javascript
|
| v2.x | v4.x |
|
||||||
safelist: ['bg-green-600', 'bg-red-600']
|
|------|------|
|
||||||
```
|
| `overflow-ellipsis` | `text-ellipsis` |
|
||||||
|
| `flex-grow-0` | `grow-0` |
|
||||||
### Large CSS file in development
|
| `flex-shrink-0` | `shrink-0` |
|
||||||
|
|
||||||
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 { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
1. **Always rebuild after config changes**
|
1. **Always rebuild after CSS changes**
|
||||||
```bash
|
```bash
|
||||||
make tailwind-dev
|
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
|
## Related Documentation
|
||||||
|
|
||||||
- [Frontend Overview](overview.md)
|
|
||||||
- [UI Components](shared/ui-components.md)
|
- [UI Components](shared/ui-components.md)
|
||||||
- [Tailwind CSS Official Docs](https://tailwindcss.com/docs)
|
- [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):
|
def dependency(request: Request):
|
||||||
vendor = get_current_vendor(request)
|
vendor = get_current_vendor(request)
|
||||||
if not vendor:
|
if not vendor:
|
||||||
from fastapi import HTTPException
|
from app.exceptions import VendorNotFoundException
|
||||||
|
|
||||||
raise HTTPException(
|
raise VendorNotFoundException("unknown", identifier_type="context")
|
||||||
status_code=404, detail="Vendor not found or not active"
|
|
||||||
)
|
|
||||||
return vendor
|
return vendor
|
||||||
|
|
||||||
return dependency
|
return dependency
|
||||||
|
|||||||
10
mkdocs.yml
10
mkdocs.yml
@@ -40,6 +40,7 @@ nav:
|
|||||||
- API Consolidation:
|
- API Consolidation:
|
||||||
- Proposal: architecture/api-consolidation-proposal.md
|
- Proposal: architecture/api-consolidation-proposal.md
|
||||||
- Migration Status: architecture/api-migration-status.md
|
- Migration Status: architecture/api-migration-status.md
|
||||||
|
- Architecture Violations Status: architecture/architecture-violations-status.md
|
||||||
- Diagrams:
|
- Diagrams:
|
||||||
- Multi-Tenant Diagrams: architecture/diagrams/multitenant-diagrams.md
|
- Multi-Tenant Diagrams: architecture/diagrams/multitenant-diagrams.md
|
||||||
- Vendor Domain Diagrams: architecture/diagrams/vendor-domain-diagrams.md
|
- Vendor Domain Diagrams: architecture/diagrams/vendor-domain-diagrams.md
|
||||||
@@ -84,7 +85,6 @@ nav:
|
|||||||
- Overview: frontend/overview.md
|
- Overview: frontend/overview.md
|
||||||
- CDN Fallback Strategy: frontend/cdn-fallback-strategy.md
|
- CDN Fallback Strategy: frontend/cdn-fallback-strategy.md
|
||||||
- Tailwind CSS Build: frontend/tailwind-css.md
|
- Tailwind CSS Build: frontend/tailwind-css.md
|
||||||
- Tailwind v3 Migration Plan: frontend/tailwind-migration-plan.md
|
|
||||||
- Shared Components:
|
- Shared Components:
|
||||||
- UI Components: frontend/shared/ui-components.md
|
- UI Components: frontend/shared/ui-components.md
|
||||||
- UI Components Quick Reference: frontend/shared/ui-components-quick-reference.md
|
- UI Components Quick Reference: frontend/shared/ui-components-quick-reference.md
|
||||||
@@ -118,7 +118,13 @@ nav:
|
|||||||
- Customer Authentication:
|
- Customer Authentication:
|
||||||
- Implementation Guide: development/customer-authentication-implementation.md
|
- Implementation Guide: development/customer-authentication-implementation.md
|
||||||
- Quick Summary: development/customer-auth-summary.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:
|
- Database Seeder:
|
||||||
- Documentation: development/database-seeder/database-seeder-documentation.md
|
- Documentation: development/database-seeder/database-seeder-documentation.md
|
||||||
- Makefile Guide: development/database-seeder/makefile-database-seeder.md
|
- Makefile Guide: development/database-seeder/makefile-database-seeder.md
|
||||||
|
|||||||
@@ -513,3 +513,38 @@ class FileLogResponse(BaseModel):
|
|||||||
last_modified: datetime
|
last_modified: datetime
|
||||||
lines: list[str]
|
lines: list[str]
|
||||||
total_lines: int
|
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
|
page: int
|
||||||
per_page: int
|
per_page: int
|
||||||
pages: 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
|
total: int
|
||||||
skip: int
|
skip: int
|
||||||
limit: 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_*"]
|
python_functions = ["test_*"]
|
||||||
addopts = [
|
addopts = [
|
||||||
"-v",
|
"-v",
|
||||||
|
"--tb=short",
|
||||||
"--strict-markers",
|
"--strict-markers",
|
||||||
"--strict-config",
|
"--strict-config",
|
||||||
"--color=yes",
|
"--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 = [
|
markers = [
|
||||||
"unit: Unit tests",
|
"unit: marks tests as unit tests - fast, isolated components",
|
||||||
"integration: Integration tests",
|
"integration: marks tests as integration tests - multiple components working together",
|
||||||
"slow: Slow running tests",
|
"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 = [
|
filterwarnings = [
|
||||||
|
"ignore::UserWarning",
|
||||||
"ignore::DeprecationWarning",
|
"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
|
# 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")
|
print("⏭️ Not an admin template, skipping extends check")
|
||||||
return
|
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
|
# TPL-001: Check for extends
|
||||||
has_extends = any(
|
has_extends = any(
|
||||||
"{% extends" in line and "admin/base.html" in line for line in lines
|
"{% extends" in line and "admin/base.html" in line for line in lines
|
||||||
@@ -395,7 +414,7 @@ class ArchitectureValidator:
|
|||||||
line_number=1,
|
line_number=1,
|
||||||
message="Admin template does not extend admin/base.html",
|
message="Admin template does not extend admin/base.html",
|
||||||
context=file_path.name,
|
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):
|
def _validate_api_endpoints(self, target_path: Path):
|
||||||
@@ -535,26 +554,44 @@ class ArchitectureValidator:
|
|||||||
def _check_endpoint_authentication(
|
def _check_endpoint_authentication(
|
||||||
self, file_path: Path, content: str, lines: list[str]
|
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")
|
rule = self._get_rule("API-004")
|
||||||
if not rule:
|
if not rule:
|
||||||
return
|
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
|
# This is a warning-level check
|
||||||
# Look for endpoints without Depends(get_current_*)
|
# Look for endpoints without Depends(get_current_*)
|
||||||
for i, line in enumerate(lines, 1):
|
for i, line in enumerate(lines, 1):
|
||||||
if "@router." in line and (
|
if "@router." in line and (
|
||||||
"post" in line or "put" in line or "delete" in line
|
"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
|
has_auth = False
|
||||||
for j in range(i, min(i + 5, len(lines))):
|
is_public = False
|
||||||
if "Depends(get_current_" in lines[j]:
|
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
|
has_auth = True
|
||||||
break
|
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(
|
if not has_auth and not is_public and "include_in_schema=False" not in " ".join(
|
||||||
lines[i : i + 5]
|
lines[i : i + 15]
|
||||||
):
|
):
|
||||||
self._add_violation(
|
self._add_violation(
|
||||||
rule_id="API-004",
|
rule_id="API-004",
|
||||||
@@ -564,7 +601,7 @@ class ArchitectureValidator:
|
|||||||
line_number=i,
|
line_number=i,
|
||||||
message="Endpoint may be missing authentication",
|
message="Endpoint may be missing authentication",
|
||||||
context=line.strip(),
|
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):
|
def _validate_service_layer(self, target_path: Path):
|
||||||
@@ -816,14 +853,37 @@ class ArchitectureValidator:
|
|||||||
template_files = list(target_path.glob("app/templates/admin/**/*.html"))
|
template_files = list(target_path.glob("app/templates/admin/**/*.html"))
|
||||||
self.result.files_checked += len(template_files)
|
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:
|
for file_path in template_files:
|
||||||
# Skip base template and partials
|
# Skip base template and partials
|
||||||
if "base.html" in file_path.name or "partials" in str(file_path):
|
if "base.html" in file_path.name or "partials" in str(file_path):
|
||||||
continue
|
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()
|
content = file_path.read_text()
|
||||||
lines = content.split("\n")
|
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
|
# TPL-001: Check for extends
|
||||||
has_extends = any(
|
has_extends = any(
|
||||||
"{% extends" in line and "admin/base.html" in line for line in lines
|
"{% extends" in line and "admin/base.html" in line for line in lines
|
||||||
@@ -838,7 +898,7 @@ class ArchitectureValidator:
|
|||||||
line_number=1,
|
line_number=1,
|
||||||
message="Admin template does not extend admin/base.html",
|
message="Admin template does not extend admin/base.html",
|
||||||
context=file_path.name,
|
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]:
|
def _get_rule(self, rule_id: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -1,4 +1,254 @@
|
|||||||
/* Tailwind CSS source file for admin panel */
|
/* Tailwind CSS v4 - Admin Panel Styles */
|
||||||
@tailwind base;
|
/* Configuration is CSS-first in v4 */
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@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