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:
2025-12-04 22:24:45 +01:00
parent 76f8a59954
commit 8a367077e1
85 changed files with 21787 additions and 134978 deletions

View File

@@ -42,10 +42,14 @@ from app.core.database import get_db
from app.exceptions import (
AdminRequiredException,
InsufficientPermissionsException,
InsufficientVendorPermissionsException,
InvalidTokenException,
UnauthorizedVendorAccessException,
VendorAccessDeniedException,
VendorNotFoundException,
VendorOwnerOnlyException,
)
from app.services.vendor_service import vendor_service
from middleware.auth import AuthManager
from middleware.rate_limiter import RateLimiter
from models.database.user import User
@@ -545,12 +549,16 @@ def require_vendor_permission(permission: str):
"""
Dependency factory to require a specific vendor permission.
Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The vendor object is loaded and stored in request.state.vendor for endpoint use.
Usage:
@router.get("/products")
def list_products(
vendor: Vendor = Depends(get_vendor_from_code),
request: Request,
user: User = Depends(require_vendor_permission(VendorPermissions.PRODUCTS_VIEW.value))
):
vendor = request.state.vendor # Vendor is set by this dependency
...
"""
@@ -559,10 +567,17 @@ def require_vendor_permission(permission: str):
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
) -> User:
# Get vendor from request state (set by middleware)
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorAccessDeniedException("No vendor context")
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
# Load vendor from database (raises VendorNotFoundException if not found)
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
# Store vendor in request state for endpoint use
request.state.vendor = vendor
# Check if user has permission
if not current_user.has_vendor_permission(vendor.id, permission):
@@ -584,16 +599,29 @@ def require_vendor_owner(
"""
Dependency to require vendor owner role.
Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The vendor object is loaded and stored in request.state.vendor for endpoint use.
Usage:
@router.delete("/team/{user_id}")
def remove_team_member(
request: Request,
user: User = Depends(require_vendor_owner)
):
vendor = request.state.vendor # Vendor is set by this dependency
...
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorAccessDeniedException("No vendor context")
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
# Load vendor from database (raises VendorNotFoundException if not found)
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
# Store vendor in request state for endpoint use
request.state.vendor = vendor
if not current_user.is_owner_of(vendor.id):
raise VendorOwnerOnlyException(
@@ -608,14 +636,19 @@ def require_any_vendor_permission(*permissions: str):
"""
Dependency factory to require ANY of the specified permissions.
Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The vendor object is loaded and stored in request.state.vendor for endpoint use.
Usage:
@router.get("/dashboard")
def dashboard(
request: Request,
user: User = Depends(require_any_vendor_permission(
VendorPermissions.DASHBOARD_VIEW.value,
VendorPermissions.REPORTS_VIEW.value
))
):
vendor = request.state.vendor # Vendor is set by this dependency
...
"""
@@ -624,9 +657,17 @@ def require_any_vendor_permission(*permissions: str):
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
) -> User:
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorAccessDeniedException("No vendor context")
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
# Load vendor from database (raises VendorNotFoundException if not found)
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
# Store vendor in request state for endpoint use
request.state.vendor = vendor
# Check if user has ANY of the required permissions
has_permission = any(
@@ -648,14 +689,19 @@ def require_all_vendor_permissions(*permissions: str):
"""
Dependency factory to require ALL of the specified permissions.
Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The vendor object is loaded and stored in request.state.vendor for endpoint use.
Usage:
@router.post("/products/bulk-delete")
def bulk_delete_products(
request: Request,
user: User = Depends(require_all_vendor_permissions(
VendorPermissions.PRODUCTS_VIEW.value,
VendorPermissions.PRODUCTS_DELETE.value
))
):
vendor = request.state.vendor # Vendor is set by this dependency
...
"""
@@ -664,9 +710,17 @@ def require_all_vendor_permissions(*permissions: str):
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
) -> User:
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorAccessDeniedException("No vendor context")
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
# Load vendor from database (raises VendorNotFoundException if not found)
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
# Store vendor in request state for endpoint use
request.state.vendor = vendor
# Check if user has ALL required permissions
missing_permissions = [
@@ -688,17 +742,29 @@ def require_all_vendor_permissions(*permissions: str):
def get_user_permissions(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
) -> list:
"""
Get all permissions for current user in current vendor.
Returns empty list if no vendor context.
Uses token_vendor_id from JWT token (authenticated vendor API pattern).
Also sets request.state.vendor for endpoint use.
Returns empty list if no vendor context in token.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
return []
vendor_id = current_user.token_vendor_id
# Load vendor from database
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
# Store vendor in request state for endpoint use
request.state.vendor = vendor
# If owner, return all permissions
if current_user.is_owner_of(vendor.id):
from app.core.permissions import VendorPermissions

View File

@@ -20,7 +20,7 @@ from app.core.environment import should_use_secure_cookies
from app.exceptions import InvalidCredentialsException
from app.services.auth_service import auth_service
from models.database.user import User
from models.schema.auth import LoginResponse, UserLogin, UserResponse
from models.schema.auth import LoginResponse, LogoutResponse, UserLogin, UserResponse
router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__)
@@ -97,7 +97,7 @@ def get_current_admin(current_user: User = Depends(get_current_admin_api)):
return current_user
@router.post("/logout")
@router.post("/logout", response_model=LogoutResponse)
def admin_logout(response: Response):
"""
Admin logout endpoint.
@@ -115,4 +115,4 @@ def admin_logout(response: Response):
logger.debug("Deleted admin_token cookie")
return {"message": "Logged out successfully"}
return LogoutResponse(message="Logged out successfully")

View File

@@ -5,12 +5,13 @@ RESTful API for architecture validation and violation management
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.exceptions import ViolationNotFoundException
from app.services.code_quality_service import code_quality_service
from models.database.user import User
@@ -136,25 +137,23 @@ async def trigger_scan(
Trigger a new architecture scan
Requires authentication. Runs the validator script and stores results.
Domain exceptions (ScanTimeoutException, ScanParseException) bubble up to global handler.
"""
try:
scan = code_quality_service.run_scan(
db, triggered_by=f"manual:{current_user.username}"
)
scan = code_quality_service.run_scan(
db, triggered_by=f"manual:{current_user.username}"
)
return ScanResponse(
id=scan.id,
timestamp=scan.timestamp.isoformat(),
total_files=scan.total_files,
total_violations=scan.total_violations,
errors=scan.errors,
warnings=scan.warnings,
duration_seconds=scan.duration_seconds,
triggered_by=scan.triggered_by,
git_commit_hash=scan.git_commit_hash,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Scan failed: {str(e)}")
return ScanResponse(
id=scan.id,
timestamp=scan.timestamp.isoformat(),
total_files=scan.total_files,
total_violations=scan.total_violations,
errors=scan.errors,
warnings=scan.warnings,
duration_seconds=scan.duration_seconds,
triggered_by=scan.triggered_by,
git_commit_hash=scan.git_commit_hash,
)
@router.get("/scans", response_model=list[ScanResponse])
@@ -269,7 +268,7 @@ async def get_violation(
violation = code_quality_service.get_violation_by_id(db, violation_id)
if not violation:
raise HTTPException(status_code=404, detail="Violation not found")
raise ViolationNotFoundException(violation_id)
# Format assignments
assignments = [
@@ -331,29 +330,26 @@ async def assign_violation(
Updates violation status to 'assigned'.
"""
try:
assignment = code_quality_service.assign_violation(
db,
violation_id=violation_id,
user_id=request.user_id,
assigned_by=current_user.id,
due_date=request.due_date,
priority=request.priority,
)
assignment = code_quality_service.assign_violation(
db,
violation_id=violation_id,
user_id=request.user_id,
assigned_by=current_user.id,
due_date=request.due_date,
priority=request.priority,
)
return {
"id": assignment.id,
"violation_id": assignment.violation_id,
"user_id": assignment.user_id,
"assigned_at": assignment.assigned_at.isoformat(),
"assigned_by": assignment.assigned_by,
"due_date": (
assignment.due_date.isoformat() if assignment.due_date else None
),
"priority": assignment.priority,
}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return {
"id": assignment.id,
"violation_id": assignment.violation_id,
"user_id": assignment.user_id,
"assigned_at": assignment.assigned_at.isoformat(),
"assigned_by": assignment.assigned_by,
"due_date": (
assignment.due_date.isoformat() if assignment.due_date else None
),
"priority": assignment.priority,
}
@router.post("/violations/{violation_id}/resolve")
@@ -367,28 +363,24 @@ async def resolve_violation(
Mark violation as resolved
Records resolution timestamp and user.
ViolationNotFoundException bubbles up if violation doesn't exist.
"""
try:
violation = code_quality_service.resolve_violation(
db,
violation_id=violation_id,
resolved_by=current_user.id,
resolution_note=request.resolution_note,
)
violation = code_quality_service.resolve_violation(
db,
violation_id=violation_id,
resolved_by=current_user.id,
resolution_note=request.resolution_note,
)
return {
"id": violation.id,
"status": violation.status,
"resolved_at": (
violation.resolved_at.isoformat() if violation.resolved_at else None
),
"resolved_by": violation.resolved_by,
"resolution_note": violation.resolution_note,
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return {
"id": violation.id,
"status": violation.status,
"resolved_at": (
violation.resolved_at.isoformat() if violation.resolved_at else None
),
"resolved_by": violation.resolved_by,
"resolution_note": violation.resolution_note,
}
@router.post("/violations/{violation_id}/ignore")
@@ -402,28 +394,24 @@ async def ignore_violation(
Mark violation as ignored (won't fix)
Records reason for ignoring.
ViolationNotFoundException bubbles up if violation doesn't exist.
"""
try:
violation = code_quality_service.ignore_violation(
db,
violation_id=violation_id,
ignored_by=current_user.id,
reason=request.reason,
)
violation = code_quality_service.ignore_violation(
db,
violation_id=violation_id,
ignored_by=current_user.id,
reason=request.reason,
)
return {
"id": violation.id,
"status": violation.status,
"resolved_at": (
violation.resolved_at.isoformat() if violation.resolved_at else None
),
"resolved_by": violation.resolved_by,
"resolution_note": violation.resolution_note,
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return {
"id": violation.id,
"status": violation.status,
"resolved_at": (
violation.resolved_at.isoformat() if violation.resolved_at else None
),
"resolved_by": violation.resolved_by,
"resolution_note": violation.resolution_note,
}
@router.post("/violations/{violation_id}/comments")
@@ -438,23 +426,20 @@ async def add_comment(
For team collaboration and discussion.
"""
try:
comment = code_quality_service.add_comment(
db,
violation_id=violation_id,
user_id=current_user.id,
comment=request.comment,
)
comment = code_quality_service.add_comment(
db,
violation_id=violation_id,
user_id=current_user.id,
comment=request.comment,
)
return {
"id": comment.id,
"violation_id": comment.violation_id,
"user_id": comment.user_id,
"comment": comment.comment,
"created_at": comment.created_at.isoformat(),
}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return {
"id": comment.id,
"violation_id": comment.violation_id,
"user_id": comment.user_id,
"comment": comment.comment,
"created_at": comment.created_at.isoformat(),
}
@router.get("/stats", response_model=DashboardStatsResponse)

View File

@@ -10,7 +10,7 @@ Platform administrators can:
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -170,28 +170,9 @@ def list_all_pages(
Filter by vendor_id to see specific vendor pages.
"""
if vendor_id:
pages = content_page_service.list_all_vendor_pages(
db, vendor_id=vendor_id, include_unpublished=include_unpublished
)
else:
# Get all pages (both platform and vendor)
from sqlalchemy import and_
from models.database.content_page import ContentPage
filters = []
if not include_unpublished:
filters.append(ContentPage.is_published == True)
pages = (
db.query(ContentPage)
.filter(and_(*filters) if filters else True)
.order_by(
ContentPage.vendor_id, ContentPage.display_order, ContentPage.title
)
.all()
)
pages = content_page_service.list_all_pages(
db, vendor_id=vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@@ -203,11 +184,7 @@ def get_page(
db: Session = Depends(get_db),
):
"""Get a specific content page by ID."""
page = content_page_service.get_page_by_id(db, page_id)
if not page:
raise HTTPException(status_code=404, detail="Content page not found")
page = content_page_service.get_page_by_id_or_raise(db, page_id)
return page.to_dict()
@@ -219,7 +196,7 @@ def update_page(
db: Session = Depends(get_db),
):
"""Update a content page (platform or vendor)."""
page = content_page_service.update_page(
page = content_page_service.update_page_or_raise(
db,
page_id=page_id,
title=page_data.title,
@@ -234,10 +211,6 @@ def update_page(
display_order=page_data.display_order,
updated_by=current_user.id,
)
if not page:
raise HTTPException(status_code=404, detail="Content page not found")
return page.to_dict()
@@ -248,9 +221,4 @@ def delete_page(
db: Session = Depends(get_db),
):
"""Delete a content page."""
success = content_page_service.delete_page(db, page_id)
if not success:
raise HTTPException(status_code=404, detail="Content page not found")
return
content_page_service.delete_page_or_raise(db, page_id)

View File

@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.core.logging import reload_log_level
from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException
from app.services.admin_audit_service import admin_audit_service
from app.services.admin_settings_service import admin_settings_service
from app.services.log_service import log_service
@@ -26,8 +27,12 @@ from models.schema.admin import (
ApplicationLogFilters,
ApplicationLogListResponse,
FileLogResponse,
LogCleanupResponse,
LogDeleteResponse,
LogFileListResponse,
LogSettingsResponse,
LogSettingsUpdate,
LogSettingsUpdateResponse,
LogStatistics,
)
@@ -87,7 +92,7 @@ def get_log_statistics(
return log_service.get_log_statistics(db, days)
@router.delete("/database/cleanup")
@router.delete("/database/cleanup", response_model=LogCleanupResponse)
def cleanup_old_logs(
retention_days: int = Query(30, ge=1, le=365),
confirm: bool = Query(False, description="Must be true to confirm cleanup"),
@@ -99,13 +104,8 @@ def cleanup_old_logs(
Requires confirmation parameter.
"""
from fastapi import HTTPException
if not confirm:
raise HTTPException(
status_code=400,
detail="Cleanup requires confirmation parameter: confirm=true",
)
raise ConfirmationRequiredException(operation="cleanup_logs")
deleted_count = log_service.cleanup_old_logs(db, retention_days)
@@ -119,13 +119,13 @@ def cleanup_old_logs(
details={"retention_days": retention_days, "deleted_count": deleted_count},
)
return {
"message": f"Deleted {deleted_count} log entries older than {retention_days} days",
"deleted_count": deleted_count,
}
return LogCleanupResponse(
message=f"Deleted {deleted_count} log entries older than {retention_days} days",
deleted_count=deleted_count,
)
@router.delete("/database/{log_id}")
@router.delete("/database/{log_id}", response_model=LogDeleteResponse)
def delete_log(
log_id: int,
db: Session = Depends(get_db),
@@ -144,7 +144,7 @@ def delete_log(
details={},
)
return {"message": message}
return LogDeleteResponse(message=message)
# ============================================================================
@@ -152,7 +152,7 @@ def delete_log(
# ============================================================================
@router.get("/files")
@router.get("/files", response_model=LogFileListResponse)
def list_log_files(
current_admin: User = Depends(get_current_admin_api),
):
@@ -161,7 +161,7 @@ def list_log_files(
Returns list of log files with size and modification date.
"""
return {"files": log_service.list_log_files()}
return LogFileListResponse(files=log_service.list_log_files())
@router.get("/files/{filename}", response_model=FileLogResponse)
@@ -191,7 +191,6 @@ def download_log_file(
from pathlib import Path
from app.core.config import settings
from fastapi import HTTPException
from fastapi.responses import FileResponse
# Determine log file path
@@ -202,7 +201,7 @@ def download_log_file(
log_file = Path("logs") / filename
if not log_file.exists():
raise HTTPException(status_code=404, detail=f"Log file '{filename}' not found")
raise ResourceNotFoundException(resource_type="LogFile", identifier=filename)
# Log action
from app.core.database import get_db
@@ -267,7 +266,7 @@ def get_log_settings(
)
@router.put("/settings")
@router.put("/settings", response_model=LogSettingsUpdateResponse)
def update_log_settings(
settings_update: LogSettingsUpdate,
db: Session = Depends(get_db),
@@ -335,8 +334,8 @@ def update_log_settings(
details={"updated_fields": updated},
)
return {
"message": "Log settings updated successfully",
"updated_fields": updated,
"note": "Log level changes are applied immediately. File rotation settings require restart.",
}
return LogSettingsUpdateResponse(
message="Log settings updated successfully",
updated_fields=updated,
note="Log level changes are applied immediately. File rotation settings require restart.",
)

View File

@@ -15,6 +15,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException
from app.services.admin_audit_service import admin_audit_service
from app.services.admin_settings_service import admin_settings_service
from models.database.user import User
@@ -78,9 +79,9 @@ def get_setting(
setting = admin_settings_service.get_setting_by_key(db, key)
if not setting:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
raise ResourceNotFoundException(
resource_type="Setting", identifier=key
)
return AdminSettingResponse.model_validate(setting)
@@ -184,12 +185,10 @@ def delete_setting(
Requires confirmation parameter.
WARNING: Deleting settings may affect platform functionality.
"""
from fastapi import HTTPException
if not confirm:
raise HTTPException(
status_code=400,
detail="Deletion requires confirmation parameter: confirm=true",
raise ConfirmationRequiredException(
operation="delete_setting",
message="Deletion requires confirmation parameter: confirm=true",
)
message = admin_settings_service.delete_setting(

View File

@@ -1,25 +1,30 @@
# app/api/v1/admin/users.py
"""
User management endpoints for admin.
All endpoints use the admin_service for business logic.
Domain exceptions are raised by the service and converted to HTTP responses
by the global exception handler.
"""
import logging
import math
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy.orm import Session, joinedload
from fastapi import APIRouter, Body, Depends, Path, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.admin_service import admin_service
from middleware.auth import AuthManager
from app.services.stats_service import stats_service
from models.database.user import User
from models.schema.auth import (
UserCreate,
UserDeleteResponse,
UserDetailResponse,
UserListResponse,
UserResponse,
UserSearchResponse,
UserStatusToggleResponse,
UserUpdate,
)
@@ -38,31 +43,19 @@ def get_all_users(
current_admin: User = Depends(get_current_admin_api),
):
"""Get paginated list of all users (Admin only)."""
query = db.query(User)
# Apply filters
if search:
search_term = f"%{search.lower()}%"
query = query.filter(
(User.username.ilike(search_term))
| (User.email.ilike(search_term))
| (User.first_name.ilike(search_term))
| (User.last_name.ilike(search_term))
)
if role:
query = query.filter(User.role == role)
# Convert string params to proper types
is_active_bool = None
if is_active:
query = query.filter(User.is_active == (is_active.lower() == "true"))
is_active_bool = is_active.lower() == "true"
# Get total count
total = query.count()
pages = math.ceil(total / per_page) if total > 0 else 1
# Apply pagination
skip = (page - 1) * per_page
users = query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all()
users, total, pages = admin_service.list_users(
db=db,
page=page,
per_page=per_page,
search=search if search else None,
role=role if role else None,
is_active=is_active_bool,
)
return UserListResponse(
items=[UserResponse.model_validate(user) for user in users],
@@ -80,30 +73,16 @@ def create_user(
current_admin: User = Depends(get_current_admin_api),
):
"""Create a new user (Admin only)."""
# Check if email exists
if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
# Check if username exists
if db.query(User).filter(User.username == user_data.username).first():
raise HTTPException(status_code=400, detail="Username already taken")
# Create user
auth_manager = AuthManager()
user = User(
user = admin_service.create_user(
db=db,
email=user_data.email,
username=user_data.username,
hashed_password=auth_manager.hash_password(user_data.password),
password=user_data.password,
first_name=user_data.first_name,
last_name=user_data.last_name,
role=user_data.role,
is_active=True,
current_admin_id=current_admin.id,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info(f"Admin {current_admin.username} created user {user.username}")
return UserDetailResponse(
id=user.id,
@@ -118,8 +97,8 @@ def create_user(
last_name=user.last_name,
full_name=user.full_name,
is_email_verified=user.is_email_verified,
owned_companies_count=len(user.owned_companies),
vendor_memberships_count=len(user.vendor_memberships),
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
vendor_memberships_count=len(user.vendor_memberships) if user.vendor_memberships else 0,
)
@@ -132,7 +111,7 @@ def get_user_statistics(
return stats_service.get_user_statistics(db)
@router.get("/search")
@router.get("/search", response_model=UserSearchResponse)
def search_users(
q: str = Query(..., min_length=2, description="Search query (username or email)"),
limit: int = Query(10, ge=1, le=50),
@@ -144,25 +123,8 @@ def search_users(
Used for autocomplete in ownership transfer.
"""
search_term = f"%{q.lower()}%"
users = (
db.query(User)
.filter((User.username.ilike(search_term)) | (User.email.ilike(search_term)))
.limit(limit)
.all()
)
return {
"users": [
{
"id": user.id,
"username": user.username,
"email": user.email,
"is_active": user.is_active,
}
for user in users
]
}
users = admin_service.search_users(db=db, query=q, limit=limit)
return UserSearchResponse(users=users)
@router.get("/{user_id}", response_model=UserDetailResponse)
@@ -172,15 +134,7 @@ def get_user_details(
current_admin: User = Depends(get_current_admin_api),
):
"""Get detailed user information (Admin only)."""
user = (
db.query(User)
.options(joinedload(User.owned_companies), joinedload(User.vendor_memberships))
.filter(User.id == user_id)
.first()
)
if not user:
raise HTTPException(status_code=404, detail="User not found")
user = admin_service.get_user_details(db=db, user_id=user_id)
return UserDetailResponse(
id=user.id,
@@ -195,8 +149,8 @@ def get_user_details(
last_name=user.last_name,
full_name=user.full_name,
is_email_verified=user.is_email_verified,
owned_companies_count=len(user.owned_companies),
vendor_memberships_count=len(user.vendor_memberships),
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
vendor_memberships_count=len(user.vendor_memberships) if user.vendor_memberships else 0,
)
@@ -208,36 +162,19 @@ def update_user(
current_admin: User = Depends(get_current_admin_api),
):
"""Update user information (Admin only)."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent changing own admin status
if user.id == current_admin.id and user_update.role and user_update.role != "admin":
raise HTTPException(
status_code=400, detail="Cannot change your own admin role"
)
# Check email uniqueness if changing
if user_update.email and user_update.email != user.email:
if db.query(User).filter(User.email == user_update.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
# Check username uniqueness if changing
if user_update.username and user_update.username != user.username:
if db.query(User).filter(User.username == user_update.username).first():
raise HTTPException(status_code=400, detail="Username already taken")
# Update fields
update_data = user_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
db.commit()
db.refresh(user)
logger.info(f"Admin {current_admin.username} updated user {user.username}")
user = admin_service.update_user(
db=db,
user_id=user_id,
current_admin_id=current_admin.id,
email=update_data.get("email"),
username=update_data.get("username"),
first_name=update_data.get("first_name"),
last_name=update_data.get("last_name"),
role=update_data.get("role"),
is_active=update_data.get("is_active"),
)
return UserDetailResponse(
id=user.id,
@@ -252,68 +189,38 @@ def update_user(
last_name=user.last_name,
full_name=user.full_name,
is_email_verified=user.is_email_verified,
owned_companies_count=len(user.owned_companies),
vendor_memberships_count=len(user.vendor_memberships),
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
vendor_memberships_count=len(user.vendor_memberships) if user.vendor_memberships else 0,
)
@router.put("/{user_id}/status")
@router.put("/{user_id}/status", response_model=UserStatusToggleResponse)
def toggle_user_status(
user_id: int = Path(..., description="User ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Toggle user active status (Admin only)."""
user = db.query(User).filter(User.id == user_id).first()
user, message = admin_service.toggle_user_status(
db=db,
user_id=user_id,
current_admin_id=current_admin.id,
)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent deactivating yourself
if user.id == current_admin.id:
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
user.is_active = not user.is_active
db.commit()
action = "activated" if user.is_active else "deactivated"
logger.info(f"Admin {current_admin.username} {action} user {user.username}")
return {"message": f"User {action} successfully", "is_active": user.is_active}
return UserStatusToggleResponse(message=message, is_active=user.is_active)
@router.delete("/{user_id}")
@router.delete("/{user_id}", response_model=UserDeleteResponse)
def delete_user(
user_id: int = Path(..., description="User ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Delete a user (Admin only)."""
user = (
db.query(User)
.options(joinedload(User.owned_companies))
.filter(User.id == user_id)
.first()
message = admin_service.delete_user(
db=db,
user_id=user_id,
current_admin_id=current_admin.id,
)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent deleting yourself
if user.id == current_admin.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
# Prevent deleting users who own companies
if user.owned_companies:
raise HTTPException(
status_code=400,
detail=f"Cannot delete user who owns {len(user.owned_companies)} company(ies). Transfer ownership first.",
)
username = user.username
db.delete(user)
db.commit()
logger.info(f"Admin {current_admin.username} deleted user {username}")
return {"message": "User deleted successfully"}
return UserDeleteResponse(message=message)

View File

@@ -16,14 +16,20 @@ This prevents:
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi import APIRouter, Depends, Request, Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.environment import should_use_secure_cookies
from app.exceptions import VendorNotFoundException
from app.services.customer_service import customer_service
from models.schema.auth import UserLogin
from models.schema.auth import (
LogoutResponse,
PasswordResetRequestResponse,
PasswordResetResponse,
UserLogin,
)
from models.schema.customer import CustomerRegister, CustomerResponse
router = APIRouter()
@@ -62,10 +68,7 @@ def register_customer(
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] register_customer for vendor {vendor.subdomain}",
@@ -122,10 +125,7 @@ def customer_login(
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] customer_login for vendor {vendor.subdomain}",
@@ -199,7 +199,7 @@ def customer_login(
)
@router.post("/auth/logout")
@router.post("/auth/logout", response_model=LogoutResponse)
def customer_logout(request: Request, response: Response):
"""
Customer logout for current vendor.
@@ -245,10 +245,10 @@ def customer_logout(request: Request, response: Response):
logger.debug(f"Deleted customer_token cookie (path={cookie_path})")
return {"message": "Logged out successfully"}
return LogoutResponse(message="Logged out successfully")
@router.post("/auth/forgot-password")
@router.post("/auth/forgot-password", response_model=PasswordResetRequestResponse)
def forgot_password(request: Request, email: str, db: Session = Depends(get_db)):
"""
Request password reset for customer.
@@ -263,10 +263,7 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] forgot_password for vendor {vendor.subdomain}",
@@ -285,12 +282,12 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
logger.info(f"Password reset requested for {email} (vendor: {vendor.subdomain})")
return {
"message": "If an account exists with this email, a password reset link has been sent."
}
return PasswordResetRequestResponse(
message="If an account exists with this email, a password reset link has been sent."
)
@router.post("/auth/reset-password")
@router.post("/auth/reset-password", response_model=PasswordResetResponse)
def reset_password(
request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)
):
@@ -307,10 +304,7 @@ def reset_password(
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] reset_password for vendor {vendor.subdomain}",
@@ -329,6 +323,6 @@ def reset_password(
logger.info(f"Password reset completed (vendor: {vendor.subdomain})")
return {
"message": "Password reset successfully. You can now log in with your new password."
}
return PasswordResetResponse(
message="Password reset successfully. You can now log in with your new password."
)

View File

@@ -3,17 +3,21 @@
Shop Shopping Cart API (Public)
Public endpoints for managing shopping cart in shop frontend.
Uses vendor from request.state (injected by VendorContextMiddleware).
Uses vendor from middleware context (VendorContextMiddleware).
No authentication required - uses session ID for cart tracking.
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
"""
import logging
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request
from fastapi import APIRouter, Body, Depends, Path
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.cart_service import cart_service
from middleware.vendor_context import require_vendor_context
from models.database.vendor import Vendor
from models.schema.cart import (
AddToCartRequest,
CartOperationResponse,
@@ -31,30 +35,21 @@ logger = logging.getLogger(__name__)
# ============================================================================
@router.get("/cart/{session_id}", response_model=CartResponse)
@router.get("/cart/{session_id}", response_model=CartResponse) # public
def get_cart(
request: Request,
session_id: str = Path(..., description="Shopping session ID"),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> CartResponse:
"""
Get shopping cart contents for current vendor.
Vendor is automatically determined from request context.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID for cart tracking.
Path Parameters:
- session_id: Unique session identifier for the cart
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.info(
f"[SHOP_API] get_cart for session {session_id}, vendor {vendor.id}",
extra={
@@ -79,17 +74,17 @@ def get_cart(
return CartResponse.from_service_dict(cart)
@router.post("/cart/{session_id}/items", response_model=CartOperationResponse)
@router.post("/cart/{session_id}/items", response_model=CartOperationResponse) # public
def add_to_cart(
request: Request,
session_id: str = Path(..., description="Shopping session ID"),
cart_data: AddToCartRequest = Body(...),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> CartOperationResponse:
"""
Add product to cart for current vendor.
Vendor is automatically determined from request context.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID.
Path Parameters:
@@ -99,15 +94,6 @@ def add_to_cart(
- product_id: ID of product to add
- quantity: Quantity to add (default: 1)
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.info(
f"[SHOP_API] add_to_cart: product {cart_data.product_id}, qty {cart_data.quantity}, session {session_id}",
extra={
@@ -140,18 +126,18 @@ def add_to_cart(
@router.put(
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
)
) # public
def update_cart_item(
request: Request,
session_id: str = Path(..., description="Shopping session ID"),
product_id: int = Path(..., description="Product ID", gt=0),
cart_data: UpdateCartItemRequest = Body(...),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> CartOperationResponse:
"""
Update cart item quantity for current vendor.
Vendor is automatically determined from request context.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID.
Path Parameters:
@@ -161,15 +147,6 @@ def update_cart_item(
Request Body:
- quantity: New quantity (must be >= 1)
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug(
f"[SHOP_API] update_cart_item: product {product_id}, qty {cart_data.quantity}",
extra={
@@ -194,32 +171,23 @@ def update_cart_item(
@router.delete(
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
)
) # public
def remove_from_cart(
request: Request,
session_id: str = Path(..., description="Shopping session ID"),
product_id: int = Path(..., description="Product ID", gt=0),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> CartOperationResponse:
"""
Remove item from cart for current vendor.
Vendor is automatically determined from request context.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID.
Path Parameters:
- session_id: Unique session identifier for the cart
- product_id: ID of product to remove
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug(
f"[SHOP_API] remove_from_cart: product {product_id}",
extra={
@@ -237,30 +205,21 @@ def remove_from_cart(
return CartOperationResponse(**result)
@router.delete("/cart/{session_id}", response_model=ClearCartResponse)
@router.delete("/cart/{session_id}", response_model=ClearCartResponse) # public
def clear_cart(
request: Request,
session_id: str = Path(..., description="Shopping session ID"),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> ClearCartResponse:
"""
Clear all items from cart for current vendor.
Vendor is automatically determined from request context.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID.
Path Parameters:
- session_id: Unique session identifier for the cart
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug(
f"[SHOP_API] clear_cart for session {session_id}",
extra={

View File

@@ -8,7 +8,7 @@ No authentication required.
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -90,16 +90,13 @@ def get_content_page(slug: str, request: Request, db: Session = Depends(get_db))
vendor = getattr(request.state, "vendor", None)
vendor_id = vendor.id if vendor else None
page = content_page_service.get_page_for_vendor(
page = content_page_service.get_page_for_vendor_or_raise(
db,
slug=slug,
vendor_id=vendor_id,
include_unpublished=False, # Only show published pages
)
if not page:
raise HTTPException(status_code=404, detail=f"Content page not found: {slug}")
return {
"slug": page.slug,
"title": page.title,

View File

@@ -3,17 +3,21 @@
Shop Product Catalog API (Public)
Public endpoints for browsing product catalog in shop frontend.
Uses vendor from request.state (injected by VendorContextMiddleware).
Uses vendor from middleware context (VendorContextMiddleware).
No authentication required.
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.product_service import product_service
from middleware.vendor_context import require_vendor_context
from models.database.vendor import Vendor
from models.schema.product import (
ProductDetailResponse,
ProductListResponse,
@@ -24,19 +28,19 @@ router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/products", response_model=ProductListResponse)
@router.get("/products", response_model=ProductListResponse) # public
def get_product_catalog(
request: Request,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
search: str | None = Query(None, description="Search products by name"),
is_featured: bool | None = Query(None, description="Filter by featured products"),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
):
"""
Get product catalog for current vendor.
Vendor is automatically determined from request context (domain/subdomain/path).
Vendor is automatically determined from request context (URL/subdomain/domain).
Only returns active products visible to customers.
No authentication required.
@@ -46,15 +50,6 @@ def get_product_catalog(
- search: Search query for product name/description
- is_featured: Filter by featured products only
"""
# Get vendor from middleware (injected by VendorContextMiddleware)
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug(
f"[SHOP_API] get_product_catalog for vendor: {vendor.subdomain}",
extra={
@@ -85,30 +80,21 @@ def get_product_catalog(
)
@router.get("/products/{product_id}", response_model=ProductDetailResponse)
@router.get("/products/{product_id}", response_model=ProductDetailResponse) # public
def get_product_details(
request: Request,
product_id: int = Path(..., description="Product ID", gt=0),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
):
"""
Get detailed product information for customers.
Vendor is automatically determined from request context.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required.
Path Parameters:
- product_id: ID of the product to retrieve
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug(
f"[SHOP_API] get_product_details for product {product_id}",
extra={
@@ -131,19 +117,19 @@ def get_product_details(
return ProductDetailResponse.model_validate(product)
@router.get("/products/search", response_model=ProductListResponse)
@router.get("/products/search", response_model=ProductListResponse) # public
def search_products(
request: Request,
q: str = Query(..., min_length=1, description="Search query"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
):
"""
Search products in current vendor's catalog.
Searches in product names, descriptions, and SKUs.
Vendor is automatically determined from request context.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required.
Query Parameters:
@@ -151,15 +137,6 @@ def search_products(
- skip: Number of results to skip (pagination)
- limit: Maximum number of results to return
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug(
f"[SHOP_API] search_products: '{q}'",
extra={

View File

@@ -1,6 +1,8 @@
# app/api/v1/vendor/analytics.py
"""
Vendor analytics and reporting endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
@@ -10,21 +12,27 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.stats_service import stats_service
from middleware.vendor_context import require_vendor_context
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/analytics")
logger = logging.getLogger(__name__)
def _get_vendor_id_from_token(current_user: User) -> int:
"""Helper to get vendor_id from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return current_user.token_vendor_id
@router.get("")
def get_vendor_analytics(
period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get vendor analytics data for specified time period."""
return stats_service.get_vendor_analytics(db, vendor.id, period)
vendor_id = _get_vendor_id_from_token(current_user)
return stats_service.get_vendor_analytics(db, vendor_id, period)

View File

@@ -25,8 +25,8 @@ from app.exceptions import InvalidCredentialsException
from app.services.auth_service import auth_service
from middleware.vendor_context import get_current_vendor
from models.database.user import User
from models.database.vendor import Role, Vendor, VendorUser
from models.schema.auth import UserLogin
from models.database.vendor import Vendor
from models.schema.auth import LogoutResponse, UserLogin, VendorUserResponse
router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__)
@@ -68,13 +68,7 @@ def vendor_login(
if not vendor and hasattr(user_credentials, "vendor_code"):
vendor_code = getattr(user_credentials, "vendor_code", None)
if vendor_code:
vendor = (
db.query(Vendor)
.filter(
Vendor.vendor_code == vendor_code.upper(), Vendor.is_active == True
)
.first()
)
vendor = auth_service.get_vendor_by_code(db, vendor_code)
# Authenticate user
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
@@ -91,51 +85,22 @@ def vendor_login(
vendor_role = "Member"
if vendor:
# Check if user is vendor owner (via company ownership)
is_owner = vendor.company and vendor.company.owner_user_id == user.id
# Check if user has access to this vendor
has_access, role = auth_service.get_user_vendor_role(db, user, vendor)
if is_owner:
vendor_role = "Owner"
if has_access:
vendor_role = role
else:
# Check if user is team member
vendor_user = (
db.query(VendorUser)
.join(Role)
.filter(
VendorUser.user_id == user.id,
VendorUser.vendor_id == vendor.id,
VendorUser.is_active == True,
)
.first()
logger.warning(
f"User {user.username} attempted login to vendor {vendor.vendor_code} "
f"but is not authorized"
)
raise InvalidCredentialsException(
"You do not have access to this vendor"
)
if vendor_user:
vendor_role = vendor_user.role.name
else:
logger.warning(
f"User {user.username} attempted login to vendor {vendor.vendor_code} "
f"but is not authorized"
)
raise InvalidCredentialsException(
"You do not have access to this vendor"
)
else:
# No vendor context - find which vendor this user belongs to
# Check owned vendors first (via company ownership)
for company in user.owned_companies:
if company.vendors:
vendor = company.vendors[0]
vendor_role = "Owner"
break
# Check vendor memberships if no owned vendor found
if not vendor and user.vendor_memberships:
active_membership = next(
(vm for vm in user.vendor_memberships if vm.is_active), None
)
if active_membership:
vendor = active_membership.vendor
vendor_role = active_membership.role.name
vendor, vendor_role = auth_service.find_user_vendor(user)
if not vendor:
raise InvalidCredentialsException("User is not associated with any vendor")
@@ -194,7 +159,7 @@ def vendor_login(
)
@router.post("/logout")
@router.post("/logout", response_model=LogoutResponse)
def vendor_logout(response: Response):
"""
Vendor team member logout.
@@ -212,10 +177,10 @@ def vendor_logout(response: Response):
logger.debug("Deleted vendor_token cookie")
return {"message": "Logged out successfully"}
return LogoutResponse(message="Logged out successfully")
@router.get("/me")
@router.get("/me", response_model=VendorUserResponse)
def get_current_vendor_user(
user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db)
):
@@ -225,10 +190,10 @@ def get_current_vendor_user(
This endpoint can be called to verify authentication and get user info.
Requires Authorization header (header-only authentication for API endpoints).
"""
return {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"is_active": user.is_active,
}
return VendorUserResponse(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active,
)

View File

@@ -10,11 +10,12 @@ Vendors can:
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, get_db
from app.exceptions.content_page import VendorNotAssociatedException
from app.services.content_page_service import content_page_service
from models.database.user import User
@@ -106,9 +107,7 @@ def list_vendor_pages(
Returns vendor-specific overrides + platform defaults (vendor overrides take precedence).
"""
if not current_user.vendor_id:
raise HTTPException(
status_code=403, detail="User is not associated with a vendor"
)
raise VendorNotAssociatedException()
pages = content_page_service.list_pages_for_vendor(
db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
@@ -129,9 +128,7 @@ def list_vendor_overrides(
Shows what the vendor has customized.
"""
if not current_user.vendor_id:
raise HTTPException(
status_code=403, detail="User is not associated with a vendor"
)
raise VendorNotAssociatedException()
pages = content_page_service.list_all_vendor_pages(
db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
@@ -153,20 +150,15 @@ def get_page(
Returns vendor override if exists, otherwise platform default.
"""
if not current_user.vendor_id:
raise HTTPException(
status_code=403, detail="User is not associated with a vendor"
)
raise VendorNotAssociatedException()
page = content_page_service.get_page_for_vendor(
page = content_page_service.get_page_for_vendor_or_raise(
db,
slug=slug,
vendor_id=current_user.vendor_id,
include_unpublished=include_unpublished,
)
if not page:
raise HTTPException(status_code=404, detail=f"Content page not found: {slug}")
return page.to_dict()
@@ -182,9 +174,7 @@ def create_vendor_page(
This will be shown instead of the platform default for this vendor.
"""
if not current_user.vendor_id:
raise HTTPException(
status_code=403, detail="User is not associated with a vendor"
)
raise VendorNotAssociatedException()
page = content_page_service.create_page(
db,
@@ -218,24 +208,13 @@ def update_vendor_page(
Can only update pages owned by this vendor.
"""
if not current_user.vendor_id:
raise HTTPException(
status_code=403, detail="User is not associated with a vendor"
)
raise VendorNotAssociatedException()
# Verify ownership
existing_page = content_page_service.get_page_by_id(db, page_id)
if not existing_page:
raise HTTPException(status_code=404, detail="Content page not found")
if existing_page.vendor_id != current_user.vendor_id:
raise HTTPException(
status_code=403, detail="Cannot edit pages from other vendors"
)
# Update
page = content_page_service.update_page(
# Update with ownership check in service layer
page = content_page_service.update_vendor_page(
db,
page_id=page_id,
vendor_id=current_user.vendor_id,
title=page_data.title,
content=page_data.content,
content_format=page_data.content_format,
@@ -264,21 +243,7 @@ def delete_vendor_page(
After deletion, platform default will be shown (if exists).
"""
if not current_user.vendor_id:
raise HTTPException(
status_code=403, detail="User is not associated with a vendor"
)
raise VendorNotAssociatedException()
# Verify ownership
existing_page = content_page_service.get_page_by_id(db, page_id)
if not existing_page:
raise HTTPException(status_code=404, detail="Content page not found")
if existing_page.vendor_id != current_user.vendor_id:
raise HTTPException(
status_code=403, detail="Cannot delete pages from other vendors"
)
# Delete
content_page_service.delete_page(db, page_id)
return
# Delete with ownership check in service layer
content_page_service.delete_vendor_page(db, page_id, current_user.vendor_id)

View File

@@ -2,6 +2,8 @@
# app/api/v1/vendor/customers.py
"""
Vendor customer management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
@@ -11,21 +13,27 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from middleware.vendor_context import require_vendor_context
from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/customers")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("")
def get_vendor_customers(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
search: str | None = Query(None),
is_active: bool | None = Query(None),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -38,6 +46,7 @@ def get_vendor_customers(
- Support filtering by active status
- Return paginated results
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"customers": [],
"total": 0,
@@ -50,7 +59,6 @@ def get_vendor_customers(
@router.get("/{customer_id}")
def get_customer_details(
customer_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -63,13 +71,13 @@ def get_customer_details(
- Include order history
- Include total spent, etc.
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Customer details coming in Slice 4"}
@router.get("/{customer_id}/orders")
def get_customer_orders(
customer_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -81,6 +89,7 @@ def get_customer_orders(
- Filter by vendor_id
- Return order details
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"orders": [], "message": "Customer orders coming in Slice 5"}
@@ -88,7 +97,6 @@ def get_customer_orders(
def update_customer(
customer_id: int,
customer_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -100,13 +108,13 @@ def update_customer(
- Verify customer belongs to vendor
- Update customer preferences
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Customer update coming in Slice 4"}
@router.put("/{customer_id}/status")
def toggle_customer_status(
customer_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -118,13 +126,13 @@ def toggle_customer_status(
- Verify customer belongs to vendor
- Log the change
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Customer status toggle coming in Slice 4"}
@router.get("/{customer_id}/stats")
def get_customer_statistics(
customer_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -137,6 +145,7 @@ def get_customer_statistics(
- Average order value
- Last order date
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"total_orders": 0,
"total_spent": 0.0,

View File

@@ -10,7 +10,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException, VendorNotActiveException
from app.services.stats_service import stats_service
from app.services.vendor_service import vendor_service
from models.database.user import User
router = APIRouter(prefix="/dashboard")
@@ -35,23 +37,17 @@ def get_vendor_dashboard_stats(
Vendor is determined from the JWT token (vendor_id claim).
Requires Authorization header (API endpoint).
"""
from fastapi import HTTPException
# Get vendor ID from token (set by get_current_vendor_api)
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
# Get vendor object to include in response
from models.database.vendor import Vendor
# Get vendor object (raises VendorNotFoundException if not found)
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor or not vendor.is_active:
raise HTTPException(status_code=404, detail="Vendor not found or inactive")
if not vendor.is_active:
raise VendorNotActiveException(vendor.vendor_code)
# Get vendor-scoped statistics
stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor_id)

View File

@@ -1,4 +1,9 @@
# app/api/v1/vendor/inventory.py
"""
Vendor inventory management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
from fastapi import APIRouter, Depends, Query
@@ -6,10 +11,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.inventory_service import inventory_service
from middleware.vendor_context import require_vendor_context
from models.database.user import User
from models.database.vendor import Vendor
from models.schema.inventory import (
InventoryAdjust,
InventoryCreate,
@@ -24,70 +28,77 @@ router = APIRouter()
logger = logging.getLogger(__name__)
def _get_vendor_id_from_token(current_user: User) -> int:
"""Helper to get vendor_id from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return current_user.token_vendor_id
@router.post("/inventory/set", response_model=InventoryResponse)
def set_inventory(
inventory: InventoryCreate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Set exact inventory quantity (replaces existing)."""
return inventory_service.set_inventory(db, vendor.id, inventory)
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.set_inventory(db, vendor_id, inventory)
@router.post("/inventory/adjust", response_model=InventoryResponse)
def adjust_inventory(
adjustment: InventoryAdjust,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Adjust inventory (positive to add, negative to remove)."""
return inventory_service.adjust_inventory(db, vendor.id, adjustment)
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.adjust_inventory(db, vendor_id, adjustment)
@router.post("/inventory/reserve", response_model=InventoryResponse)
def reserve_inventory(
reservation: InventoryReserve,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Reserve inventory for an order."""
return inventory_service.reserve_inventory(db, vendor.id, reservation)
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.reserve_inventory(db, vendor_id, reservation)
@router.post("/inventory/release", response_model=InventoryResponse)
def release_reservation(
reservation: InventoryReserve,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Release reserved inventory (cancel order)."""
return inventory_service.release_reservation(db, vendor.id, reservation)
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.release_reservation(db, vendor_id, reservation)
@router.post("/inventory/fulfill", response_model=InventoryResponse)
def fulfill_reservation(
reservation: InventoryReserve,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Fulfill reservation (complete order, remove from stock)."""
return inventory_service.fulfill_reservation(db, vendor.id, reservation)
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.fulfill_reservation(db, vendor_id, reservation)
@router.get("/inventory/product/{product_id}", response_model=ProductInventorySummary)
def get_product_inventory(
product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get inventory summary for a product."""
return inventory_service.get_product_inventory(db, vendor.id, product_id)
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.get_product_inventory(db, vendor_id, product_id)
@router.get("/inventory", response_model=InventoryListResponse)
@@ -96,13 +107,13 @@ def get_vendor_inventory(
limit: int = Query(100, ge=1, le=1000),
location: str | None = Query(None),
low_stock: int | None = Query(None, ge=0),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get all inventory for vendor."""
vendor_id = _get_vendor_id_from_token(current_user)
inventories = inventory_service.get_vendor_inventory(
db, vendor.id, skip, limit, location, low_stock
db, vendor_id, skip, limit, location, low_stock
)
# Get total count
@@ -117,23 +128,23 @@ def get_vendor_inventory(
def update_inventory(
inventory_id: int,
inventory_update: InventoryUpdate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update inventory entry."""
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.update_inventory(
db, vendor.id, inventory_id, inventory_update
db, vendor_id, inventory_id, inventory_update
)
@router.delete("/inventory/{inventory_id}")
def delete_inventory(
inventory_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Delete inventory entry."""
inventory_service.delete_inventory(db, vendor.id, inventory_id)
vendor_id = _get_vendor_id_from_token(current_user)
inventory_service.delete_inventory(db, vendor_id, inventory_id)
return {"message": "Inventory deleted successfully"}

View File

@@ -1,7 +1,8 @@
# app/api/v1/vendor/marketplace.py # Note: Should be under /vendor/ route
# app/api/v1/vendor/marketplace.py
"""
Marketplace import endpoints for vendors.
Vendor context is automatically injected by middleware.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
@@ -11,37 +12,45 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException, UnauthorizedVendorAccessException
from app.services.marketplace_import_job_service import marketplace_import_job_service
from app.services.vendor_service import vendor_service
from app.tasks.background_tasks import process_marketplace_import
from middleware.decorators import rate_limit
from middleware.vendor_context import require_vendor_context # IMPORTANT
from models.database.user import User
from models.database.vendor import Vendor
from models.schema.marketplace_import_job import (
MarketplaceImportJobRequest,
MarketplaceImportJobResponse,
)
router = APIRouter()
router = APIRouter(prefix="/marketplace")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.post("/import", response_model=MarketplaceImportJobResponse)
@rate_limit(max_requests=10, window_seconds=3600)
async def import_products_from_marketplace(
request: MarketplaceImportJobRequest,
background_tasks: BackgroundTasks,
vendor: Vendor = Depends(require_vendor_context()), # ADDED: Vendor from middleware
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Import products from marketplace CSV with background processing (Protected)."""
vendor = _get_vendor_from_token(current_user, db)
logger.info(
f"Starting marketplace import: {request.marketplace} for vendor {vendor.vendor_code} "
f"by user {current_user.username}"
)
# Create import job (vendor comes from middleware)
# Create import job (vendor comes from token)
import_job = marketplace_import_job_service.create_import_job(
db, request, vendor, current_user
)
@@ -50,9 +59,9 @@ async def import_products_from_marketplace(
background_tasks.add_task(
process_marketplace_import,
import_job.id,
request.source_url, # FIXED: was request.url
request.source_url,
request.marketplace,
vendor.id, # Pass vendor_id instead of vendor_code
vendor.id,
request.batch_size or 1000,
)
@@ -62,7 +71,7 @@ async def import_products_from_marketplace(
marketplace=request.marketplace,
vendor_id=import_job.vendor_id,
vendor_code=vendor.vendor_code,
vendor_name=vendor.name, # FIXED: from vendor object
vendor_name=vendor.name,
source_url=request.source_url,
message=f"Marketplace import started from {request.marketplace}. "
f"Check status with /import-status/{import_job.id}",
@@ -77,17 +86,16 @@ async def import_products_from_marketplace(
@router.get("/imports/{job_id}", response_model=MarketplaceImportJobResponse)
def get_marketplace_import_status(
job_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get status of marketplace import job (Protected)."""
vendor = _get_vendor_from_token(current_user, db)
job = marketplace_import_job_service.get_import_job_by_id(db, job_id, current_user)
# Verify job belongs to current vendor
if job.vendor_id != vendor.id:
from app.exceptions import UnauthorizedVendorAccessException
raise UnauthorizedVendorAccessException(vendor.vendor_code, current_user.id)
return marketplace_import_job_service.convert_to_response_model(job)
@@ -98,11 +106,12 @@ def get_marketplace_import_jobs(
marketplace: str | None = Query(None, description="Filter by marketplace"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get marketplace import jobs for current vendor (Protected)."""
vendor = _get_vendor_from_token(current_user, db)
jobs = marketplace_import_job_service.get_import_jobs(
db=db,
vendor=vendor,

View File

@@ -2,6 +2,8 @@
# app/api/v1/vendor/media.py
"""
Vendor media and file management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
@@ -11,21 +13,27 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from middleware.vendor_context import require_vendor_context
from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/media")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("")
def get_media_library(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
media_type: str | None = Query(None, description="image, video, document"),
search: str | None = Query(None),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -39,6 +47,7 @@ def get_media_library(
- Support pagination
- Return file URLs, sizes, metadata
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"media": [],
"total": 0,
@@ -52,7 +61,6 @@ def get_media_library(
async def upload_media(
file: UploadFile = File(...),
folder: str | None = Query(None, description="products, general, etc."),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -67,6 +75,7 @@ async def upload_media(
- Save metadata to database
- Return file URL
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"file_url": None,
"thumbnail_url": None,
@@ -78,7 +87,6 @@ async def upload_media(
async def upload_multiple_media(
files: list[UploadFile] = File(...),
folder: str | None = Query(None),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -91,6 +99,7 @@ async def upload_multiple_media(
- Return list of uploaded file URLs
- Handle errors gracefully
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"uploaded_files": [],
"failed_files": [],
@@ -101,7 +110,6 @@ async def upload_multiple_media(
@router.get("/{media_id}")
def get_media_details(
media_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -113,6 +121,7 @@ def get_media_details(
- Return file URL
- Return usage information (which products use this file)
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media details coming in Slice 3"}
@@ -120,7 +129,6 @@ def get_media_details(
def update_media_metadata(
media_id: int,
metadata: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -133,13 +141,13 @@ def update_media_metadata(
- Update tags/categories
- Update description
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media update coming in Slice 3"}
@router.delete("/{media_id}")
def delete_media(
media_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -153,13 +161,13 @@ def delete_media(
- Delete database record
- Return success/error
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media deletion coming in Slice 3"}
@router.get("/{media_id}/usage")
def get_media_usage(
media_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -171,6 +179,7 @@ def get_media_usage(
- Check other entities using this media
- Return list of usage
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"products": [],
"other_usage": [],
@@ -181,7 +190,6 @@ def get_media_usage(
@router.post("/optimize/{media_id}")
def optimize_media(
media_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -194,4 +202,5 @@ def optimize_media(
- Keep original
- Update database with new versions
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media optimization coming in Slice 3"}

View File

@@ -2,6 +2,8 @@
# app/api/v1/vendor/notifications.py
"""
Vendor notification management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
@@ -11,20 +13,26 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from middleware.vendor_context import require_vendor_context
from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/notifications")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("")
def get_notifications(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
unread_only: bool | None = Query(False),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -37,6 +45,7 @@ def get_notifications(
- Support pagination
- Return notification details
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"notifications": [],
"total": 0,
@@ -47,7 +56,6 @@ def get_notifications(
@router.get("/unread-count")
def get_unread_count(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -58,13 +66,13 @@ def get_unread_count(
- Count unread notifications for vendor
- Used for notification badge
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"unread_count": 0, "message": "Unread count coming in Slice 5"}
@router.put("/{notification_id}/read")
def mark_as_read(
notification_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -75,12 +83,12 @@ def mark_as_read(
- Mark single notification as read
- Update read timestamp
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Mark as read coming in Slice 5"}
@router.put("/mark-all-read")
def mark_all_as_read(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -91,13 +99,13 @@ def mark_all_as_read(
- Mark all vendor notifications as read
- Update timestamps
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Mark all as read coming in Slice 5"}
@router.delete("/{notification_id}")
def delete_notification(
notification_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -108,12 +116,12 @@ def delete_notification(
- Delete single notification
- Verify notification belongs to vendor
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Notification deletion coming in Slice 5"}
@router.get("/settings")
def get_notification_settings(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -125,6 +133,7 @@ def get_notification_settings(
- Get in-app notification settings
- Get notification types enabled/disabled
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"email_notifications": True,
"in_app_notifications": True,
@@ -136,7 +145,6 @@ def get_notification_settings(
@router.put("/settings")
def update_notification_settings(
settings: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -148,12 +156,12 @@ def update_notification_settings(
- Update in-app notification settings
- Enable/disable specific notification types
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Notification settings update coming in Slice 5"}
@router.get("/templates")
def get_notification_templates(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -165,6 +173,7 @@ def get_notification_templates(
- Include: order confirmation, shipping notification, etc.
- Return template details
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"templates": [], "message": "Notification templates coming in Slice 5"}
@@ -172,7 +181,6 @@ def get_notification_templates(
def update_notification_template(
template_id: int,
template_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -185,13 +193,13 @@ def update_notification_template(
- Validate template variables
- Preview template
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Template update coming in Slice 5"}
@router.post("/test")
def send_test_notification(
notification_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -203,4 +211,5 @@ def send_test_notification(
- Use specified template
- Send to current user's email
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Test notification coming in Slice 5"}

View File

@@ -2,6 +2,8 @@
# app/api/v1/vendor/payments.py
"""
Vendor payment configuration and processing endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
@@ -11,17 +13,23 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from middleware.vendor_context import require_vendor_context
from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/payments")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("/config")
def get_payment_configuration(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -34,6 +42,7 @@ def get_payment_configuration(
- Get currency settings
- Return masked/secure information only
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"payment_gateway": None,
"accepted_methods": [],
@@ -46,7 +55,6 @@ def get_payment_configuration(
@router.put("/config")
def update_payment_configuration(
payment_config: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -59,13 +67,13 @@ def update_payment_configuration(
- Update accepted payment methods
- Validate configuration before saving
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Payment configuration update coming in Slice 5"}
@router.post("/stripe/connect")
def connect_stripe_account(
stripe_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -78,12 +86,12 @@ def connect_stripe_account(
- Verify Stripe account is active
- Enable payment processing
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Stripe connection coming in Slice 5"}
@router.delete("/stripe/disconnect")
def disconnect_stripe_account(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -95,12 +103,12 @@ def disconnect_stripe_account(
- Disable payment processing
- Warn about pending payments
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Stripe disconnection coming in Slice 5"}
@router.get("/methods")
def get_payment_methods(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -111,12 +119,12 @@ def get_payment_methods(
- Return list of enabled payment methods
- Include: credit card, PayPal, bank transfer, etc.
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"methods": [], "message": "Payment methods coming in Slice 5"}
@router.get("/transactions")
def get_payment_transactions(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -129,6 +137,7 @@ def get_payment_transactions(
- Include payment details
- Support pagination
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"transactions": [],
"total": 0,
@@ -138,7 +147,6 @@ def get_payment_transactions(
@router.get("/balance")
def get_payment_balance(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -151,6 +159,7 @@ def get_payment_balance(
- Get next payout date
- Get payout history
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"available_balance": 0.0,
"pending_balance": 0.0,
@@ -164,7 +173,6 @@ def get_payment_balance(
def refund_payment(
payment_id: int,
refund_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -177,4 +185,5 @@ def refund_payment(
- Update order status
- Send refund notification to customer
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Payment refund coming in Slice 5"}

View File

@@ -10,13 +10,16 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.product_service import product_service
from models.database.user import User
from models.schema.product import (
ProductCreate,
ProductDeleteResponse,
ProductDetailResponse,
ProductListResponse,
ProductResponse,
ProductToggleResponse,
ProductUpdate,
)
@@ -42,14 +45,9 @@ def get_vendor_products(
Vendor is determined from JWT token (vendor_id claim).
"""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -77,14 +75,9 @@ def get_product_details(
db: Session = Depends(get_db),
):
"""Get detailed product information including inventory."""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -106,14 +99,9 @@ def add_product_to_catalog(
This publishes a MarketplaceProduct to the vendor's public catalog.
"""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -137,14 +125,9 @@ def update_product(
db: Session = Depends(get_db),
):
"""Update product in vendor catalog."""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -160,21 +143,16 @@ def update_product(
return ProductResponse.model_validate(product)
@router.delete("/{product_id}")
@router.delete("/{product_id}", response_model=ProductDeleteResponse)
def remove_product_from_catalog(
product_id: int,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Remove product from vendor catalog."""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -185,7 +163,7 @@ def remove_product_from_catalog(
f"for vendor {current_user.token_vendor_code}"
)
return {"message": f"Product {product_id} removed from catalog"}
return ProductDeleteResponse(message=f"Product {product_id} removed from catalog")
@router.post("/from-import/{marketplace_product_id}", response_model=ProductResponse)
@@ -199,14 +177,9 @@ def publish_from_marketplace(
Shortcut endpoint for publishing directly from marketplace import.
"""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -226,21 +199,16 @@ def publish_from_marketplace(
return ProductResponse.model_validate(product)
@router.put("/{product_id}/toggle-active")
@router.put("/{product_id}/toggle-active", response_model=ProductToggleResponse)
def toggle_product_active(
product_id: int,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Toggle product active status."""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -253,24 +221,19 @@ def toggle_product_active(
status = "activated" if product.is_active else "deactivated"
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
return {"message": f"Product {status}", "is_active": product.is_active}
return ProductToggleResponse(message=f"Product {status}", is_active=product.is_active)
@router.put("/{product_id}/toggle-featured")
@router.put("/{product_id}/toggle-featured", response_model=ProductToggleResponse)
def toggle_product_featured(
product_id: int,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Toggle product featured status."""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
@@ -283,4 +246,4 @@ def toggle_product_featured(
status = "featured" if product.is_featured else "unfeatured"
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
return {"message": f"Product {status}", "is_featured": product.is_featured}
return ProductToggleResponse(message=f"Product {status}", is_featured=product.is_featured)

View File

@@ -1,45 +1,54 @@
# app/api/v1/vendor/profile.py
"""
Vendor profile management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
from app.services.vendor_service import vendor_service
from middleware.vendor_context import require_vendor_context
from models.database.user import User
from models.database.vendor import Vendor
from models.schema.vendor import VendorResponse, VendorUpdate
router = APIRouter(prefix="/profile")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("", response_model=VendorResponse)
def get_vendor_profile(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get current vendor profile information."""
vendor = _get_vendor_from_token(current_user, db)
return vendor
@router.put("", response_model=VendorResponse)
def update_vendor_profile(
vendor_update: VendorUpdate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update vendor profile information."""
vendor = _get_vendor_from_token(current_user, db)
# Verify user has permission to update vendor
if not vendor_service.can_update_vendor(vendor, current_user):
raise HTTPException(status_code=403, detail="Insufficient permissions")
raise InsufficientPermissionsException(required_permission="vendor:profile:update")
return vendor_service.update_vendor(db, vendor.id, vendor_update)

View File

@@ -1,19 +1,20 @@
# app/api/v1/vendor/settings.py
"""
Vendor settings and configuration endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
from app.services.vendor_service import vendor_service
from middleware.vendor_context import require_vendor_context
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/settings")
logger = logging.getLogger(__name__)
@@ -21,11 +22,16 @@ logger = logging.getLogger(__name__)
@router.get("")
def get_vendor_settings(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get vendor settings and configuration."""
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
return {
"vendor_code": vendor.vendor_code,
"subdomain": vendor.subdomain,
@@ -46,14 +52,21 @@ def get_vendor_settings(
@router.put("/marketplace")
def update_marketplace_settings(
marketplace_config: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update marketplace integration settings."""
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
# Verify permissions
if not vendor_service.can_update_vendor(vendor, current_user):
raise HTTPException(status_code=403, detail="Insufficient permissions")
raise InsufficientPermissionsException(
required_permission="vendor:settings:update"
)
# Update Letzshop URLs
if "letzshop_csv_url_fr" in marketplace_config:

View File

@@ -14,7 +14,9 @@ from .admin import (
CannotModifySelfException,
ConfirmationRequiredException,
InvalidAdminActionException,
UserCannotBeDeletedException,
UserNotFoundException,
UserRoleChangeException,
UserStatusChangeException,
VendorVerificationException,
)
@@ -44,6 +46,17 @@ from .base import (
WizamartException,
)
# Code quality exceptions
from .code_quality import (
InvalidViolationStatusException,
ScanExecutionException,
ScanNotFoundException,
ScanParseException,
ScanTimeoutException,
ViolationNotFoundException,
ViolationOperationException,
)
# Cart exceptions
from .cart import (
CartItemNotFoundException,
@@ -155,13 +168,16 @@ from .team import (
# Vendor exceptions
from .vendor import (
InsufficientVendorPermissionsException,
InvalidVendorDataException,
MaxVendorsReachedException,
UnauthorizedVendorAccessException,
VendorAccessDeniedException,
VendorAlreadyExistsException,
VendorNotActiveException,
VendorNotFoundException,
VendorNotVerifiedException,
VendorOwnerOnlyException,
VendorValidationException,
)
@@ -245,13 +261,16 @@ __all__ = [
"InvalidQuantityException",
"LocationNotFoundException",
# Vendor exceptions
"VendorNotFoundException",
"VendorAlreadyExistsException",
"VendorNotActiveException",
"VendorNotVerifiedException",
"UnauthorizedVendorAccessException",
"InsufficientVendorPermissionsException",
"InvalidVendorDataException",
"MaxVendorsReachedException",
"UnauthorizedVendorAccessException",
"VendorAccessDeniedException",
"VendorAlreadyExistsException",
"VendorNotActiveException",
"VendorNotFoundException",
"VendorNotVerifiedException",
"VendorOwnerOnlyException",
"VendorValidationException",
# Vendor Domain
"VendorDomainNotFoundException",
@@ -334,4 +353,12 @@ __all__ = [
"InvalidAdminActionException",
"BulkOperationException",
"ConfirmationRequiredException",
# Code quality exceptions
"ViolationNotFoundException",
"ScanNotFoundException",
"ScanExecutionException",
"ScanTimeoutException",
"ScanParseException",
"ViolationOperationException",
"InvalidViolationStatusException",
]

View File

@@ -236,3 +236,37 @@ class VendorVerificationException(BusinessLogicException):
error_code="VENDOR_VERIFICATION_FAILED",
details=details,
)
class UserCannotBeDeletedException(BusinessLogicException):
"""Raised when a user cannot be deleted due to ownership constraints."""
def __init__(self, user_id: int, reason: str, owned_count: int = 0):
details = {
"user_id": user_id,
"reason": reason,
}
if owned_count > 0:
details["owned_companies_count"] = owned_count
super().__init__(
message=f"Cannot delete user {user_id}: {reason}",
error_code="USER_CANNOT_BE_DELETED",
details=details,
)
class UserRoleChangeException(BusinessLogicException):
"""Raised when user role cannot be changed."""
def __init__(self, user_id: int, current_role: str, target_role: str, reason: str):
super().__init__(
message=f"Cannot change user {user_id} role from {current_role} to {target_role}: {reason}",
error_code="USER_ROLE_CHANGE_FAILED",
details={
"user_id": user_id,
"current_role": current_role,
"target_role": target_role,
"reason": reason,
},
)

View 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"

View 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)

View File

@@ -148,3 +148,43 @@ class MaxVendorsReachedException(BusinessLogicException):
error_code="MAX_VENDORS_REACHED",
details=details,
)
class VendorAccessDeniedException(AuthorizationException):
"""Raised when no vendor context is available for an authenticated endpoint."""
def __init__(self, message: str = "No vendor context available"):
super().__init__(
message=message,
error_code="VENDOR_ACCESS_DENIED",
)
class VendorOwnerOnlyException(AuthorizationException):
"""Raised when operation requires vendor owner role."""
def __init__(self, operation: str, vendor_code: str | None = None):
details = {"operation": operation}
if vendor_code:
details["vendor_code"] = vendor_code
super().__init__(
message=f"Operation '{operation}' requires vendor owner role",
error_code="VENDOR_OWNER_ONLY",
details=details,
)
class InsufficientVendorPermissionsException(AuthorizationException):
"""Raised when user lacks required vendor permission."""
def __init__(self, required_permission: str, vendor_code: str | None = None):
details = {"required_permission": required_permission}
if vendor_code:
details["vendor_code"] = vendor_code
super().__init__(
message=f"Permission required: {required_permission}",
error_code="INSUFFICIENT_VENDOR_PERMISSIONS",
details=details,
)

View File

@@ -21,13 +21,17 @@ from sqlalchemy.orm import Session, joinedload
from app.exceptions import (
AdminOperationException,
CannotModifySelfException,
UserCannotBeDeletedException,
UserNotFoundException,
UserRoleChangeException,
UserStatusChangeException,
ValidationException,
VendorAlreadyExistsException,
VendorNotFoundException,
VendorVerificationException,
)
from app.exceptions.auth import UserAlreadyExistsException
from middleware.auth import AuthManager
from models.database.company import Company
from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.user import User
@@ -97,6 +101,244 @@ class AdminService:
reason="Database update failed",
)
def list_users(
self,
db: Session,
page: int = 1,
per_page: int = 10,
search: str | None = None,
role: str | None = None,
is_active: bool | None = None,
) -> tuple[list[User], int, int]:
"""
Get paginated list of users with filtering.
Returns:
Tuple of (users, total_count, total_pages)
"""
import math
query = db.query(User)
# Apply filters
if search:
search_term = f"%{search.lower()}%"
query = query.filter(
or_(
User.username.ilike(search_term),
User.email.ilike(search_term),
User.first_name.ilike(search_term),
User.last_name.ilike(search_term),
)
)
if role:
query = query.filter(User.role == role)
if is_active is not None:
query = query.filter(User.is_active == is_active)
# Get total count
total = query.count()
pages = math.ceil(total / per_page) if total > 0 else 1
# Apply pagination
skip = (page - 1) * per_page
users = query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all()
return users, total, pages
def create_user(
self,
db: Session,
email: str,
username: str,
password: str,
first_name: str | None = None,
last_name: str | None = None,
role: str = "customer",
current_admin_id: int | None = None,
) -> User:
"""
Create a new user.
Raises:
UserAlreadyExistsException: If email or username already exists
"""
# Check if email exists
if db.query(User).filter(User.email == email).first():
raise UserAlreadyExistsException("Email already registered", field="email")
# Check if username exists
if db.query(User).filter(User.username == username).first():
raise UserAlreadyExistsException("Username already taken", field="username")
# Create user
auth_manager = AuthManager()
user = User(
email=email,
username=username,
hashed_password=auth_manager.hash_password(password),
first_name=first_name,
last_name=last_name,
role=role,
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info(f"Admin {current_admin_id} created user {user.username}")
return user
def get_user_details(self, db: Session, user_id: int) -> User:
"""
Get user with relationships loaded.
Raises:
UserNotFoundException: If user not found
"""
user = (
db.query(User)
.options(joinedload(User.owned_companies), joinedload(User.vendor_memberships))
.filter(User.id == user_id)
.first()
)
if not user:
raise UserNotFoundException(str(user_id))
return user
def update_user(
self,
db: Session,
user_id: int,
current_admin_id: int,
email: str | None = None,
username: str | None = None,
first_name: str | None = None,
last_name: str | None = None,
role: str | None = None,
is_active: bool | None = None,
) -> User:
"""
Update user information.
Raises:
UserNotFoundException: If user not found
UserAlreadyExistsException: If email/username already taken
UserRoleChangeException: If trying to change own admin role
"""
user = self._get_user_by_id_or_raise(db, user_id)
# Prevent changing own admin status
if user.id == current_admin_id and role and role != "admin":
raise UserRoleChangeException(
user_id=user_id,
current_role=user.role,
target_role=role,
reason="Cannot change your own admin role",
)
# Check email uniqueness if changing
if email and email != user.email:
if db.query(User).filter(User.email == email).first():
raise UserAlreadyExistsException("Email already registered", field="email")
# Check username uniqueness if changing
if username and username != user.username:
if db.query(User).filter(User.username == username).first():
raise UserAlreadyExistsException("Username already taken", field="username")
# Update fields
if email is not None:
user.email = email
if username is not None:
user.username = username
if first_name is not None:
user.first_name = first_name
if last_name is not None:
user.last_name = last_name
if role is not None:
user.role = role
if is_active is not None:
user.is_active = is_active
user.updated_at = datetime.now(UTC)
db.commit()
db.refresh(user)
logger.info(f"Admin {current_admin_id} updated user {user.username}")
return user
def delete_user(self, db: Session, user_id: int, current_admin_id: int) -> str:
"""
Delete a user.
Raises:
UserNotFoundException: If user not found
CannotModifySelfException: If trying to delete yourself
UserCannotBeDeletedException: If user owns companies
"""
user = (
db.query(User)
.options(joinedload(User.owned_companies))
.filter(User.id == user_id)
.first()
)
if not user:
raise UserNotFoundException(str(user_id))
# Prevent deleting yourself
if user.id == current_admin_id:
raise CannotModifySelfException(user_id, "delete account")
# Prevent deleting users who own companies
if user.owned_companies:
raise UserCannotBeDeletedException(
user_id=user_id,
reason=f"User owns {len(user.owned_companies)} company(ies). Transfer ownership first.",
owned_count=len(user.owned_companies),
)
username = user.username
db.delete(user)
db.commit()
logger.info(f"Admin {current_admin_id} deleted user {username}")
return f"User {username} deleted successfully"
def search_users(
self,
db: Session,
query: str,
limit: int = 10,
) -> list[dict]:
"""
Search users by username or email.
Used for autocomplete in ownership transfer.
"""
search_term = f"%{query.lower()}%"
users = (
db.query(User)
.filter(or_(User.username.ilike(search_term), User.email.ilike(search_term)))
.limit(limit)
.all()
)
return [
{
"id": user.id,
"username": user.username,
"email": user.email,
"is_active": user.is_active,
}
for user in users
]
# ============================================================================
# VENDOR MANAGEMENT
# ============================================================================

View File

@@ -22,6 +22,7 @@ from app.exceptions import (
)
from middleware.auth import AuthManager
from models.database.user import User
from models.database.vendor import Vendor, VendorUser
from models.schema.auth import UserLogin, UserRegister
logger = logging.getLogger(__name__)
@@ -214,6 +215,84 @@ class AuthService:
logger.error(f"Error creating access token with data: {str(e)}")
raise ValidationException("Failed to create access token")
def get_vendor_by_code(self, db: Session, vendor_code: str) -> Vendor | None:
"""
Get active vendor by vendor code.
Args:
db: Database session
vendor_code: Vendor code to look up
Returns:
Vendor if found and active, None otherwise
"""
return (
db.query(Vendor)
.filter(Vendor.vendor_code == vendor_code.upper(), Vendor.is_active == True)
.first()
)
def get_user_vendor_role(
self, db: Session, user: User, vendor: Vendor
) -> tuple[bool, str | None]:
"""
Check if user has access to vendor and return their role.
Args:
db: Database session
user: User to check
vendor: Vendor to check access for
Returns:
Tuple of (has_access: bool, role_name: str | None)
"""
# Check if user is vendor owner (via company ownership)
if vendor.company and vendor.company.owner_user_id == user.id:
return True, "Owner"
# Check if user is team member
vendor_user = (
db.query(VendorUser)
.filter(
VendorUser.user_id == user.id,
VendorUser.vendor_id == vendor.id,
VendorUser.is_active == True,
)
.first()
)
if vendor_user:
return True, vendor_user.role.name
return False, None
def find_user_vendor(self, user: User) -> tuple[Vendor | None, str | None]:
"""
Find which vendor a user belongs to when no vendor context is provided.
Checks owned companies first, then vendor memberships.
Args:
user: User to find vendor for
Returns:
Tuple of (vendor: Vendor | None, role: str | None)
"""
# Check owned vendors first (via company ownership)
for company in user.owned_companies:
if company.vendors:
return company.vendors[0], "Owner"
# Check vendor memberships
if user.vendor_memberships:
active_membership = next(
(vm for vm in user.vendor_memberships if vm.is_active), None
)
if active_membership:
return active_membership.vendor, active_membership.role.name
return None, None
# Private helper methods
def _email_exists(self, db: Session, email: str) -> bool:
"""Check if email already exists."""

View File

@@ -11,6 +11,11 @@ from datetime import datetime
from sqlalchemy import desc, func
from sqlalchemy.orm import Session
from app.exceptions import (
ScanParseException,
ScanTimeoutException,
ViolationNotFoundException,
)
from models.database.architecture_scan import (
ArchitectureScan,
ArchitectureViolation,
@@ -54,7 +59,7 @@ class CodeQualityService:
)
except subprocess.TimeoutExpired:
logger.error("Architecture scan timed out after 5 minutes")
raise Exception("Scan timed out")
raise ScanTimeoutException(timeout_seconds=300)
duration = (datetime.now() - start_time).total_seconds()
@@ -77,7 +82,7 @@ class CodeQualityService:
logger.error(f"Failed to parse validator output: {e}")
logger.error(f"Stdout: {result.stdout}")
logger.error(f"Stderr: {result.stderr}")
raise Exception(f"Failed to parse scan results: {e}")
raise ScanParseException(reason=str(e))
# Create scan record
scan = ArchitectureScan(
@@ -285,7 +290,7 @@ class CodeQualityService:
"""
violation = self.get_violation_by_id(db, violation_id)
if not violation:
raise ValueError(f"Violation {violation_id} not found")
raise ViolationNotFoundException(violation_id)
violation.status = "resolved"
violation.resolved_at = datetime.now()
@@ -313,7 +318,7 @@ class CodeQualityService:
"""
violation = self.get_violation_by_id(db, violation_id)
if not violation:
raise ValueError(f"Violation {violation_id} not found")
raise ViolationNotFoundException(violation_id)
violation.status = "ignored"
violation.resolved_at = datetime.now()

View File

@@ -22,6 +22,10 @@ from datetime import UTC, datetime
from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.exceptions.content_page import (
ContentPageNotFoundException,
UnauthorizedContentPageAccessException,
)
from models.database.content_page import ContentPage
logger = logging.getLogger(__name__)
@@ -319,6 +323,214 @@ class ContentPageService:
"""Get content page by ID."""
return db.query(ContentPage).filter(ContentPage.id == page_id).first()
@staticmethod
def get_page_by_id_or_raise(db: Session, page_id: int) -> ContentPage:
"""
Get content page by ID or raise ContentPageNotFoundException.
Args:
db: Database session
page_id: Page ID
Returns:
ContentPage
Raises:
ContentPageNotFoundException: If page not found
"""
page = db.query(ContentPage).filter(ContentPage.id == page_id).first()
if not page:
raise ContentPageNotFoundException(identifier=page_id)
return page
@staticmethod
def get_page_for_vendor_or_raise(
db: Session,
slug: str,
vendor_id: int | None = None,
include_unpublished: bool = False,
) -> ContentPage:
"""
Get content page for a vendor with fallback to platform default.
Raises ContentPageNotFoundException if not found.
Args:
db: Database session
slug: Page slug
vendor_id: Vendor ID
include_unpublished: Include draft pages
Returns:
ContentPage
Raises:
ContentPageNotFoundException: If page not found
"""
page = ContentPageService.get_page_for_vendor(
db, slug=slug, vendor_id=vendor_id, include_unpublished=include_unpublished
)
if not page:
raise ContentPageNotFoundException(identifier=slug)
return page
@staticmethod
def update_page_or_raise(
db: Session,
page_id: int,
title: str | None = None,
content: str | None = None,
content_format: str | None = None,
template: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
display_order: int | None = None,
updated_by: int | None = None,
) -> ContentPage:
"""
Update an existing content page or raise exception.
Raises:
ContentPageNotFoundException: If page not found
"""
page = ContentPageService.update_page(
db,
page_id=page_id,
title=title,
content=content,
content_format=content_format,
template=template,
meta_description=meta_description,
meta_keywords=meta_keywords,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,
display_order=display_order,
updated_by=updated_by,
)
if not page:
raise ContentPageNotFoundException(identifier=page_id)
return page
@staticmethod
def delete_page_or_raise(db: Session, page_id: int) -> None:
"""
Delete a content page or raise exception.
Raises:
ContentPageNotFoundException: If page not found
"""
success = ContentPageService.delete_page(db, page_id)
if not success:
raise ContentPageNotFoundException(identifier=page_id)
@staticmethod
def update_vendor_page(
db: Session,
page_id: int,
vendor_id: int,
title: str | None = None,
content: str | None = None,
content_format: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
display_order: int | None = None,
updated_by: int | None = None,
) -> ContentPage:
"""
Update a vendor-specific content page with ownership check.
Args:
db: Database session
page_id: Page ID
vendor_id: Vendor ID (for ownership verification)
... other fields
Returns:
Updated ContentPage
Raises:
ContentPageNotFoundException: If page not found
UnauthorizedContentPageAccessException: If page doesn't belong to vendor
"""
page = ContentPageService.get_page_by_id_or_raise(db, page_id)
if page.vendor_id != vendor_id:
raise UnauthorizedContentPageAccessException(action="edit")
return ContentPageService.update_page_or_raise(
db,
page_id=page_id,
title=title,
content=content,
content_format=content_format,
meta_description=meta_description,
meta_keywords=meta_keywords,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,
display_order=display_order,
updated_by=updated_by,
)
@staticmethod
def delete_vendor_page(db: Session, page_id: int, vendor_id: int) -> None:
"""
Delete a vendor-specific content page with ownership check.
Args:
db: Database session
page_id: Page ID
vendor_id: Vendor ID (for ownership verification)
Raises:
ContentPageNotFoundException: If page not found
UnauthorizedContentPageAccessException: If page doesn't belong to vendor
"""
page = ContentPageService.get_page_by_id_or_raise(db, page_id)
if page.vendor_id != vendor_id:
raise UnauthorizedContentPageAccessException(action="delete")
ContentPageService.delete_page_or_raise(db, page_id)
@staticmethod
def list_all_pages(
db: Session,
vendor_id: int | None = None,
include_unpublished: bool = False,
) -> list[ContentPage]:
"""
List all content pages (platform defaults and vendor overrides).
Args:
db: Database session
vendor_id: Optional filter by vendor ID
include_unpublished: Include draft pages
Returns:
List of ContentPage objects
"""
filters = []
if vendor_id:
filters.append(ContentPage.vendor_id == vendor_id)
if not include_unpublished:
filters.append(ContentPage.is_published == True)
return (
db.query(ContentPage)
.filter(and_(*filters) if filters else True)
.order_by(ContentPage.vendor_id, ContentPage.display_order, ContentPage.title)
.all()
)
@staticmethod
def list_all_vendor_pages(
db: Session, vendor_id: int, include_unpublished: bool = False

View File

@@ -1,6 +1,6 @@
{# app/templates/admin/base.html #}
<!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
<html :class="{ 'dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -10,11 +10,7 @@
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Tailwind CSS with CDN fallback -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
<!-- Admin-specific Tailwind customizations -->
<!-- Tailwind CSS v4 (built locally via standalone CLI) -->
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
<!-- Alpine Cloak -->

View File

@@ -1,6 +1,6 @@
{# app/templates/admin/login.html #}
<!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="adminLogin()" lang="en">
<html :class="{ 'dark': dark }" x-data="adminLogin()" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

View File

@@ -1,4 +1,5 @@
<DOCTYPE html>
{# standalone - Minimal monitoring page without admin chrome #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">

View File

@@ -1,519 +1,253 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auth Flow Testing - Admin Panel</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
{# app/templates/admin/test-auth-flow.html #}
{% extends 'admin/base.html' %}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
padding: 20px;
background: #f5f5f5;
line-height: 1.6;
}
{% block title %}Auth Flow Testing{% endblock %}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.test-section {
margin-bottom: 30px;
padding: 20px;
background: #f9f9f9;
border-radius: 6px;
border-left: 4px solid #3b82f6;
}
.test-section h2 {
color: #333;
margin-bottom: 15px;
font-size: 20px;
}
.test-description {
color: #666;
margin-bottom: 15px;
font-size: 14px;
}
.test-steps {
background: white;
padding: 15px;
border-radius: 4px;
margin-bottom: 15px;
}
.test-steps ol {
margin-left: 20px;
}
.test-steps li {
margin-bottom: 8px;
color: #444;
}
.expected-result {
background: #e8f5e9;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #4caf50;
margin-bottom: 15px;
}
.expected-result strong {
color: #2e7d32;
display: block;
margin-bottom: 5px;
}
.expected-result ul {
margin-left: 20px;
color: #555;
}
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
button:active {
transform: translateY(0);
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-warning {
background: #f59e0b;
color: white;
}
.btn-warning:hover {
background: #d97706;
}
.btn-success {
background: #10b981;
color: white;
}
.btn-success:hover {
background: #059669;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.status-panel {
background: #1e293b;
color: #e2e8f0;
padding: 20px;
border-radius: 6px;
margin-top: 30px;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.status-panel h3 {
color: #38bdf8;
margin-bottom: 15px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.status-item {
margin-bottom: 8px;
display: flex;
justify-content: space-between;
}
.status-label {
color: #94a3b8;
}
.status-value {
color: #34d399;
font-weight: 500;
}
.status-value.false {
color: #f87171;
}
.log-level-control {
background: #fef3c7;
padding: 15px;
border-radius: 6px;
margin-bottom: 30px;
border-left: 4px solid #f59e0b;
}
.log-level-control h3 {
color: #92400e;
margin-bottom: 10px;
font-size: 16px;
}
.log-level-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.log-level-buttons button {
padding: 8px 16px;
font-size: 12px;
}
.warning-box {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
padding: 15px;
margin-top: 30px;
}
.warning-box h3 {
color: #991b1b;
margin-bottom: 10px;
font-size: 16px;
}
.warning-box ul {
margin-left: 20px;
color: #7f1d1d;
}
.warning-box li {
margin-bottom: 5px;
}
</style>
</head>
<body>
<div class="container">
<h1>🧪 Auth Flow Testing</h1>
<p class="subtitle">Comprehensive testing for the Jinja2 migration auth loop fix</p>
<!-- Log Level Control -->
<div class="log-level-control">
<h3>📊 Log Level Control</h3>
<p style="color: #78350f; font-size: 13px; margin-bottom: 10px;">
Change logging verbosity for login.js and api-client.js
{% block content %}
<div x-data="authFlowTest()" x-init="init()">
{# Page Header #}
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Auth Flow Testing
</h2>
<p class="text-gray-600 dark:text-gray-400 mt-1">
Comprehensive testing for Jinja2 migration auth loop fix
</p>
<div class="log-level-buttons">
<button onclick="setLogLevel(0)" class="btn-secondary">0 - None</button>
<button onclick="setLogLevel(1)" class="btn-danger">1 - Errors Only</button>
<button onclick="setLogLevel(2)" class="btn-warning">2 - Warnings</button>
<button onclick="setLogLevel(3)" class="btn-success">3 - Info (Production)</button>
<button onclick="setLogLevel(4)" class="btn-primary">4 - Debug (Full)</button>
</div>
<p style="color: #78350f; font-size: 12px; margin-top: 10px; font-style: italic;">
Current levels: LOGIN = <span id="currentLoginLevel">4</span>, API = <span id="currentApiLevel">3</span>
</p>
</div>
<!-- Test 1: Clean Slate -->
<div class="test-section">
<h2>Test 1: Clean Slate - Fresh Login Flow</h2>
<p class="test-description">
Tests the complete login flow from scratch with no existing tokens.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Click "Clear All Data" below</li>
<li>Click "Navigate to /admin"</li>
<li>Observe browser behavior and console logs</li>
<li>You should land on login page</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Single redirect: /admin → /admin/login</li>
<li>Login page loads with NO API calls to /admin/auth/me</li>
<li>No loops, no errors in console</li>
<li>Form is ready for input</li>
</ul>
</div>
<div class="button-group">
<button onclick="clearAllData()" class="btn-danger">Clear All Data</button>
<button onclick="navigateToAdmin()" class="btn-primary">Navigate to /admin</button>
<button onclick="navigateToLogin()" class="btn-secondary">Go to Login</button>
</div>
</div>
<!-- Test 2: Login Success -->
<div class="test-section">
<h2>Test 2: Successful Login</h2>
<p class="test-description">
Tests that login works correctly and redirects to dashboard.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Ensure you're on /admin/login</li>
<li>Enter valid admin credentials</li>
<li>Click "Login"</li>
<li>Observe redirect and dashboard load</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Login API call succeeds (check Network tab)</li>
<li>Token stored in localStorage</li>
<li>Success message shows briefly</li>
<li>Redirect to /admin/dashboard after 500ms</li>
<li>Dashboard loads with stats and recent vendors</li>
</ul>
</div>
<div class="button-group">
<button onclick="navigateToLogin()" class="btn-primary">Go to Login Page</button>
<button onclick="checkAuthStatus()" class="btn-secondary">Check Auth Status</button>
</div>
</div>
<!-- Test 3: Dashboard Refresh -->
<div class="test-section">
<h2>Test 3: Dashboard Refresh (Authenticated)</h2>
<p class="test-description">
Tests that refreshing the dashboard works without redirect loops.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Complete Test 2 (login successfully)</li>
<li>On dashboard, press F5 or click "Refresh Page"</li>
<li>Observe page reload behavior</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Dashboard reloads normally</li>
<li>No redirects to login</li>
<li>Stats and vendors load correctly</li>
<li>No console errors</li>
</ul>
</div>
<div class="button-group">
<button onclick="navigateToDashboard()" class="btn-primary">Go to Dashboard</button>
<button onclick="window.location.reload()" class="btn-secondary">Refresh Page</button>
</div>
</div>
<!-- Test 4: Expired Token -->
<div class="test-section">
<h2>Test 4: Expired Token Handling</h2>
<p class="test-description">
Tests that expired tokens are handled gracefully with redirect to login.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Click "Set Expired Token"</li>
<li>Click "Navigate to Dashboard"</li>
<li>Observe authentication failure and redirect</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Server detects expired token</li>
<li>Returns 401 Unauthorized</li>
<li>Browser redirects to /admin/login</li>
<li>Token is cleared from localStorage</li>
<li>No infinite loops</li>
</ul>
</div>
<div class="button-group">
<button onclick="setExpiredToken()" class="btn-warning">Set Expired Token</button>
<button onclick="navigateToDashboard()" class="btn-primary">Navigate to Dashboard</button>
</div>
</div>
<!-- Test 5: Direct Dashboard Access (No Token) -->
<div class="test-section">
<h2>Test 5: Direct Dashboard Access (Unauthenticated)</h2>
<p class="test-description">
Tests that accessing dashboard without token redirects to login.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Click "Clear All Data"</li>
<li>Click "Navigate to Dashboard"</li>
<li>Observe immediate redirect to login</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Redirect from /admin/dashboard to /admin/login</li>
<li>No API calls attempted</li>
<li>Login page loads correctly</li>
</ul>
</div>
<div class="button-group">
<button onclick="clearAllData()" class="btn-danger">Clear All Data</button>
<button onclick="navigateToDashboard()" class="btn-primary">Navigate to Dashboard</button>
</div>
</div>
<!-- Test 6: Login Page with Valid Token -->
<div class="test-section">
<h2>Test 6: Login Page with Valid Token</h2>
<p class="test-description">
Tests what happens when user visits login page while already authenticated.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Login successfully (Test 2)</li>
<li>Click "Go to Login Page" below</li>
<li>Observe behavior</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Login page loads</li>
<li>Existing token is cleared (init() clears it)</li>
<li>Form is displayed normally</li>
<li>NO redirect loops</li>
<li>NO API calls to validate token</li>
</ul>
</div>
<div class="button-group">
<button onclick="setValidToken()" class="btn-success">Set Valid Token (Mock)</button>
<button onclick="navigateToLogin()" class="btn-primary">Go to Login Page</button>
</div>
</div>
<!-- Status Panel -->
<div class="status-panel">
<h3>🔍 Current Auth Status</h3>
<div id="statusDisplay">
<div class="status-item">
<span class="status-label">Current URL:</span>
<span class="status-value" id="currentUrl">-</span>
</div>
<div class="status-item">
<span class="status-label">Has admin_token:</span>
<span class="status-value" id="hasToken">-</span>
</div>
<div class="status-item">
<span class="status-label">Has admin_user:</span>
<span class="status-value" id="hasUser">-</span>
</div>
<div class="status-item">
<span class="status-label">Token Preview:</span>
<span class="status-value" id="tokenPreview">-</span>
</div>
<div class="status-item">
<span class="status-label">Username:</span>
<span class="status-value" id="username">-</span>
</div>
</div>
<button onclick="updateStatus()" style="margin-top: 15px; background: #38bdf8; color: #0f172a; padding: 8px 16px; border-radius: 4px; font-size: 12px; cursor: pointer; border: none;">
🔄 Refresh Status
</button>
</div>
<!-- Warning Box -->
<div class="warning-box">
<h3>⚠️ Important Notes</h3>
<ul>
<li>Always check browser console for detailed logs</li>
<li>Use Network tab to see actual HTTP requests and redirects</li>
<li>Clear browser cache if you see unexpected behavior</li>
<li>Make sure FastAPI server is running on localhost:8000</li>
<li>Valid admin credentials required for login tests</li>
</ul>
</div>
</div>
<script>
// Update status display
function updateStatus() {
{# Log Level Control #}
<div class="px-4 py-3 mb-6 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg shadow-md border-l-4 border-yellow-500">
<h4 class="mb-2 text-lg font-semibold text-yellow-800 dark:text-yellow-200">Log Level Control</h4>
<p class="text-sm text-yellow-700 dark:text-yellow-300 mb-3">
Change logging verbosity for login.js and api-client.js
</p>
<div class="flex flex-wrap gap-2">
<button @click="setLogLevel(0)" class="px-3 py-1 text-xs font-medium text-white bg-gray-600 rounded hover:bg-gray-700">0 - None</button>
<button @click="setLogLevel(1)" class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">1 - Errors</button>
<button @click="setLogLevel(2)" class="px-3 py-1 text-xs font-medium text-white bg-yellow-600 rounded hover:bg-yellow-700">2 - Warnings</button>
<button @click="setLogLevel(3)" class="px-3 py-1 text-xs font-medium text-white bg-green-600 rounded hover:bg-green-700">3 - Info</button>
<button @click="setLogLevel(4)" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">4 - Debug</button>
</div>
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-2 italic">
Current: LOGIN = <span x-text="currentLoginLevel">4</span>, API = <span x-text="currentApiLevel">3</span>
</p>
</div>
{# Test Sections Grid #}
<div class="grid gap-6 mb-8 md:grid-cols-2">
{# Test 1: Clean Slate #}
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-blue-500">
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
Test 1: Clean Slate - Fresh Login
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Tests complete login flow from scratch with no existing tokens.
</p>
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
<li>Clear All Data</li>
<li>Navigate to /admin</li>
<li>Should land on login page</li>
</ol>
</div>
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
<p class="text-xs text-green-700 dark:text-green-400">Expected: Single redirect /admin -> /admin/login, no loops</p>
</div>
<div class="flex flex-wrap gap-2">
<button @click="clearAllData()" class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">Clear All Data</button>
<button @click="navigateTo('/admin')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to /admin</button>
</div>
</div>
{# Test 2: Successful Login #}
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-green-500">
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
Test 2: Successful Login
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Tests that login works correctly and redirects to dashboard.
</p>
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
<li>Go to /admin/login</li>
<li>Enter valid admin credentials</li>
<li>Click Login</li>
</ol>
</div>
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
<p class="text-xs text-green-700 dark:text-green-400">Expected: Token stored, redirect to /admin/dashboard</p>
</div>
<div class="flex flex-wrap gap-2">
<button @click="navigateTo('/admin/login')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Login</button>
<button @click="checkAuthStatus()" class="px-3 py-1 text-xs font-medium text-white bg-gray-600 rounded hover:bg-gray-700">Check Status</button>
</div>
</div>
{# Test 3: Dashboard Refresh #}
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-purple-500">
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
Test 3: Dashboard Refresh
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Tests that refreshing dashboard works without redirect loops.
</p>
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
<li>Complete Test 2 (login)</li>
<li>Press F5 or click Refresh</li>
<li>Dashboard should reload normally</li>
</ol>
</div>
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
<p class="text-xs text-green-700 dark:text-green-400">Expected: No redirect to login, stats load correctly</p>
</div>
<div class="flex flex-wrap gap-2">
<button @click="navigateTo('/admin/dashboard')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Dashboard</button>
<button @click="window.location.reload()" class="px-3 py-1 text-xs font-medium text-white bg-gray-600 rounded hover:bg-gray-700">Refresh Page</button>
</div>
</div>
{# Test 4: Expired Token #}
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-orange-500">
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
Test 4: Expired Token Handling
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Tests that expired tokens are handled gracefully.
</p>
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
<li>Set Expired Token</li>
<li>Navigate to Dashboard</li>
<li>Should redirect to login</li>
</ol>
</div>
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
<p class="text-xs text-green-700 dark:text-green-400">Expected: 401 response, redirect to login, no loops</p>
</div>
<div class="flex flex-wrap gap-2">
<button @click="setExpiredToken()" class="px-3 py-1 text-xs font-medium text-white bg-orange-600 rounded hover:bg-orange-700">Set Expired Token</button>
<button @click="navigateTo('/admin/dashboard')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Dashboard</button>
</div>
</div>
{# Test 5: Direct Access (No Token) #}
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-red-500">
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
Test 5: Direct Access (Unauthenticated)
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Tests accessing dashboard without token redirects to login.
</p>
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
<li>Clear All Data</li>
<li>Navigate to Dashboard</li>
<li>Should redirect to login</li>
</ol>
</div>
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
<p class="text-xs text-green-700 dark:text-green-400">Expected: Redirect to /admin/login, no API calls</p>
</div>
<div class="flex flex-wrap gap-2">
<button @click="clearAllData()" class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">Clear All Data</button>
<button @click="navigateTo('/admin/dashboard')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Dashboard</button>
</div>
</div>
{# Test 6: Login with Valid Token #}
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-teal-500">
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
Test 6: Login Page with Valid Token
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Tests visiting login page while already authenticated.
</p>
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
<li>Login successfully (Test 2)</li>
<li>Click Go to Login Page</li>
<li>Token should be cleared</li>
</ol>
</div>
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
<p class="text-xs text-green-700 dark:text-green-400">Expected: Token cleared, form displayed, no loops</p>
</div>
<div class="flex flex-wrap gap-2">
<button @click="setMockToken()" class="px-3 py-1 text-xs font-medium text-white bg-green-600 rounded hover:bg-green-700">Set Mock Token</button>
<button @click="navigateTo('/admin/login')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Login</button>
</div>
</div>
</div>
{# Status Panel #}
<div class="px-4 py-3 bg-gray-800 rounded-lg shadow-md">
<div class="flex items-center justify-between mb-3">
<h4 class="text-lg font-semibold text-gray-200">Current Auth Status</h4>
<button @click="updateStatus()" class="px-3 py-1 text-xs text-gray-400 border border-gray-600 rounded hover:bg-gray-700">Refresh</button>
</div>
<div class="font-mono text-sm space-y-2">
<div class="flex justify-between">
<span class="text-gray-500">Current URL:</span>
<span class="text-blue-400" x-text="currentUrl">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Has admin_token:</span>
<span :class="hasToken ? 'text-green-400' : 'text-red-400'" x-text="hasToken ? 'Yes' : 'No'">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Has admin_user:</span>
<span :class="hasUser ? 'text-green-400' : 'text-red-400'" x-text="hasUser ? 'Yes' : 'No'">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Token Preview:</span>
<span class="text-green-400 truncate max-w-xs" x-text="tokenPreview">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Username:</span>
<span class="text-green-400" x-text="username">-</span>
</div>
</div>
</div>
{# Warning Box #}
<div class="mt-6 px-4 py-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<h4 class="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">Important Notes</h4>
<ul class="list-disc list-inside text-sm text-red-600 dark:text-red-400 space-y-1">
<li>Always check browser console for detailed logs</li>
<li>Use Network tab to see actual HTTP requests and redirects</li>
<li>Clear browser cache if you see unexpected behavior</li>
<li>Make sure FastAPI server is running on localhost:8000</li>
<li>Valid admin credentials required for login tests</li>
</ul>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function authFlowTest() {
return {
...data(),
currentPage: 'auth-testing',
currentUrl: '-',
hasToken: false,
hasUser: false,
tokenPreview: '-',
username: '-',
currentLoginLevel: 4,
currentApiLevel: 3,
init() {
this.updateStatus();
setInterval(() => this.updateStatus(), 2000);
console.log('Auth Flow Testing Script Loaded');
},
updateStatus() {
const token = localStorage.getItem('admin_token');
const userStr = localStorage.getItem('admin_user');
let user = null;
@@ -524,121 +258,67 @@
console.error('Failed to parse user data:', e);
}
document.getElementById('currentUrl').textContent = window.location.href;
this.currentUrl = window.location.href;
this.hasToken = !!token;
this.hasUser = !!user;
this.tokenPreview = token ? token.substring(0, 30) + '...' : 'No token';
this.username = user?.username || 'Not logged in';
},
const hasTokenEl = document.getElementById('hasToken');
hasTokenEl.textContent = token ? 'Yes' : 'No';
hasTokenEl.className = token ? 'status-value' : 'status-value false';
const hasUserEl = document.getElementById('hasUser');
hasUserEl.textContent = user ? 'Yes' : 'No';
hasUserEl.className = user ? 'status-value' : 'status-value false';
document.getElementById('tokenPreview').textContent = token
? token.substring(0, 30) + '...'
: 'No token';
document.getElementById('username').textContent = user?.username || 'Not logged in';
console.log('📊 Status Updated:', {
hasToken: !!token,
hasUser: !!user,
user: user
});
}
// Test functions
function clearAllData() {
console.log('🗑️ Clearing all localStorage data...');
clearAllData() {
console.log('Clearing all localStorage data...');
localStorage.clear();
console.log('All data cleared');
alert('All localStorage data cleared!\n\nCheck console for details.');
updateStatus();
}
console.log('All data cleared');
alert('All localStorage data cleared!');
this.updateStatus();
},
function navigateToAdmin() {
console.log('🚀 Navigating to /admin...');
window.location.href = '/admin';
}
navigateTo(path) {
console.log(`Navigating to ${path}...`);
window.location.href = path;
},
function navigateToLogin() {
console.log('🚀 Navigating to /admin/login...');
window.location.href = '/admin/login';
}
function navigateToDashboard() {
console.log('🚀 Navigating to /admin/dashboard...');
window.location.href = '/admin/dashboard';
}
function checkAuthStatus() {
updateStatus();
checkAuthStatus() {
this.updateStatus();
alert('Check console and status panel for auth details.');
}
},
function setExpiredToken() {
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwMjJ9.invalidexpiredtoken';
console.log('⚠️ Setting expired/invalid token...');
setExpiredToken() {
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNTE2MjM5MDIyfQ.invalid';
localStorage.setItem('admin_token', expiredToken);
localStorage.setItem('admin_user', JSON.stringify({
id: 1,
username: 'test_expired',
role: 'admin'
}));
console.log('Expired token set');
alert('⚠️ Expired token set!\n\nNow try navigating to dashboard.');
updateStatus();
}
alert('Expired token set! Now try navigating to dashboard.');
this.updateStatus();
},
function setValidToken() {
// This is a mock token - won't actually work with backend
setMockToken() {
const mockToken = 'mock_valid_token_' + Date.now();
console.log('✅ Setting mock valid token...');
localStorage.setItem('admin_token', mockToken);
localStorage.setItem('admin_user', JSON.stringify({
id: 1,
username: 'test_user',
role: 'admin'
}));
console.log('Mock token set (will not work with real backend)');
alert('✅ Mock token set!\n\nNote: This is a fake token and won\'t work with the real backend.');
updateStatus();
}
alert('Mock token set! Note: This won\'t work with real backend.');
this.updateStatus();
},
// Log level control
function setLogLevel(level) {
console.log(`📊 Setting log level to ${level}...`);
// Note: This only works if login.js and api-client.js are loaded
// In production, you'd need to reload the page or use a more sophisticated approach
if (typeof LOG_LEVEL !== 'undefined') {
setLogLevel(level) {
if (typeof window.LOG_LEVEL !== 'undefined') {
window.LOG_LEVEL = level;
document.getElementById('currentLoginLevel').textContent = level;
console.log('✅ LOGIN log level set to', level);
} else {
console.warn('⚠️ LOG_LEVEL not found (login.js not loaded)');
this.currentLoginLevel = level;
}
if (typeof API_LOG_LEVEL !== 'undefined') {
if (typeof window.API_LOG_LEVEL !== 'undefined') {
window.API_LOG_LEVEL = level;
document.getElementById('currentApiLevel').textContent = level;
console.log('✅ API log level set to', level);
} else {
console.warn('⚠️ API_LOG_LEVEL not found (api-client.js not loaded)');
this.currentApiLevel = level;
}
alert(`Log level set to ${level}\n\n0 = None\n1 = Errors\n2 = Warnings\n3 = Info\n4 = Debug\n\nNote: Changes apply to current page. Reload to apply to all scripts.`);
alert(`Log level set to ${level}. Reload to apply to all scripts.`);
}
// Initialize status on load
updateStatus();
// Auto-refresh status every 2 seconds
setInterval(updateStatus, 2000);
console.log('🧪 Auth Flow Testing Script Loaded');
console.log('📊 Use the buttons above to run tests');
console.log('🔍 Watch browser console and Network tab for details');
</script>
</body>
</html>
};
}
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -41,51 +41,8 @@
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
{# Tailwind CSS with local fallback #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
{# Platform-specific styles #}
<style>
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Custom gradients */
.gradient-primary {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
}
.gradient-accent {
background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-accent) 100%);
}
/* Button styles */
.btn-primary {
background-color: var(--color-primary);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
transition: all 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Card hover effect */
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
</style>
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='platform/css/tailwind.output.css') }}">
{% block extra_head %}{% endblock %}
</head>

View File

@@ -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>

View File

@@ -1,11 +1,15 @@
<DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Address management</title>
</head>
<body>
<-- Address management -->
</body>
</html>
{# app/templates/shop/account/addresses.html #}
{% extends "shop/base.html" %}
{% block title %}My Addresses{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">My Addresses</h1>
{# TODO: Implement address management #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Address management coming soon...</p>
</div>
</div>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{# app/templates/shop/account/forgot-password.html #}
<!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="forgotPassword()" lang="en">
<html :class="{ 'dark': dark }" x-data="forgotPassword()" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -37,9 +37,8 @@
[x-cloak] { display: none !important; }
</style>
{# Tailwind CSS with local fallback #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>

View File

@@ -1,6 +1,6 @@
{# app/templates/shop/account/login.html #}
<!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="customerLogin()" lang="en">
<html :class="{ 'dark': dark }" x-data="customerLogin()" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -37,9 +37,8 @@
[x-cloak] { display: none !important; }
</style>
{# Tailwind CSS with local fallback #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>

View File

@@ -1,11 +1,15 @@
<DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Order history</title>
</head>
<body>
<-- Order history -->
</body>
</html>
{# app/templates/shop/account/orders.html #}
{% extends "shop/base.html" %}
{% block title %}Order History{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Order History</h1>
{# TODO: Implement order history #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Order history coming soon...</p>
</div>
</div>
{% endblock %}

View File

@@ -1,11 +1,15 @@
<DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Customer profile</title>
</head>
<body>
<-- Customer profile -->
</body>
</html>
{# app/templates/shop/account/profile.html #}
{% extends "shop/base.html" %}
{% block title %}My Profile{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">My Profile</h1>
{# TODO: Implement profile management #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Profile management coming soon...</p>
</div>
</div>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{# app/templates/shop/account/register.html #}
<!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="customerRegistration()" lang="en">
<html :class="{ 'dark': dark }" x-data="customerRegistration()" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -37,9 +37,8 @@
[x-cloak] { display: none !important; }
</style>
{# Tailwind CSS with local fallback #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>

View File

@@ -37,9 +37,8 @@
{% endif %}
</style>
{# Tailwind CSS with local fallback #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
{# Base Shop Styles #}
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/shop.css') }}">

View File

@@ -1,11 +1,15 @@
<DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Checkout process</title>
</head>
<body>
<-- Checkout process -->
</body>
</html>
{# app/templates/shop/checkout.html #}
{% extends "shop/base.html" %}
{% block title %}Checkout{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Checkout</h1>
{# TODO: Implement checkout process #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Checkout process coming soon...</p>
</div>
</div>
{% endblock %}

View File

@@ -1,195 +1,108 @@
{# app/templates/shop/errors/base.html #}
{# Error page base template using Tailwind CSS with vendor theme support #}
<!DOCTYPE html>
<html lang="en">
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ status_code }} - {{ status_name }}{% endblock %}{% if vendor %} | {{ vendor.name }}{% endif %}</title>
{# Tailwind CSS #}
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
{# Vendor theme colors via CSS variables #}
<style>
:root {
/* Default theme colors (fallback) */
--color-primary: {{ theme.colors.primary if theme and theme.colors else '#6366f1' }};
--color-secondary: {{ theme.colors.secondary if theme and theme.colors else '#8b5cf6' }};
--color-accent: {{ theme.colors.accent if theme and theme.colors else '#ec4899' }};
--color-background: {{ theme.colors.background if theme and theme.colors else '#ffffff' }};
--color-text: {{ theme.colors.text if theme and theme.colors else '#1f2937' }};
--color-border: {{ theme.colors.border if theme and theme.colors else '#e5e7eb' }};
--font-heading: {{ theme.fonts.heading if theme and theme.fonts else "'Inter', sans-serif" }};
--font-body: {{ theme.fonts.body if theme and theme.fonts else "'Inter', sans-serif" }};
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-body);
.bg-gradient-theme {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text);
padding: 2rem;
}
.error-container {
background: var(--color-background);
border-radius: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
max-width: 600px;
width: 100%;
padding: 3rem;
text-align: center;
}
{% if vendor and vendor.logo %}
.vendor-logo {
max-width: 150px;
max-height: 60px;
margin-bottom: 2rem;
}
{% endif %}
.error-icon {
font-size: 5rem;
margin-bottom: 1rem;
}
.status-code {
font-size: 6rem;
font-weight: 700;
.text-theme-primary {
color: var(--color-primary);
line-height: 1;
margin-bottom: 0.5rem;
font-family: var(--font-heading);
}
.status-name {
font-size: 1.75rem;
font-weight: 600;
color: var(--color-text);
margin-bottom: 1rem;
font-family: var(--font-heading);
.bg-theme-primary {
background-color: var(--color-primary);
}
.error-message {
font-size: 1.125rem;
color: #6b7280;
margin-bottom: 2.5rem;
line-height: 1.6;
.border-theme-primary {
border-color: var(--color-primary);
}
.action-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 2rem;
}
.btn {
display: inline-flex;
align-items: center;
padding: 1rem 2rem;
border-radius: 0.75rem;
font-weight: 600;
text-decoration: none;
transition: all 0.3s ease;
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.btn-secondary {
background: transparent;
color: var(--color-primary);
border: 2px solid var(--color-primary);
}
.btn-secondary:hover {
background: var(--color-primary);
color: white;
transform: translateY(-2px);
}
.support-link {
margin-top: 2.5rem;
padding-top: 2rem;
border-top: 1px solid var(--color-border);
font-size: 0.875rem;
color: #6b7280;
}
.support-link a {
color: var(--color-primary);
text-decoration: none;
font-weight: 600;
}
.support-link a:hover {
text-decoration: underline;
}
.vendor-info {
margin-top: 2rem;
font-size: 0.875rem;
color: #9ca3af;
.hover\:bg-theme-primary:hover {
background-color: var(--color-primary);
}
{% block extra_styles %}{% endblock %}
</style>
{% if theme and theme.custom_css %}
<style>
{{ theme.custom_css | safe }}
</style>
<style>{{ theme.custom_css | safe }}</style>
{% endif %}
</head>
<body>
<div class="error-container">
<body class="h-full bg-gradient-theme flex items-center justify-center p-8">
<div class="bg-white rounded-3xl shadow-2xl max-w-xl w-full p-12 text-center">
{# Vendor Logo #}
{% if vendor and theme and theme.branding and theme.branding.logo %}
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo">
<img src="{{ theme.branding.logo }}"
alt="{{ vendor.name }}"
class="max-w-[150px] max-h-[60px] mx-auto mb-8 object-contain">
{% endif %}
{% block content %}
<div class="error-icon">{% block icon %}⚠️{% endblock %}</div>
<div class="status-code">{{ status_code }}</div>
<div class="status-name">{{ status_name }}</div>
<div class="error-message">{{ message }}</div>
{# Error Icon #}
<div class="text-7xl mb-4">{% block icon %}⚠️{% endblock %}</div>
<div class="action-buttons">
{# Status Code #}
<div class="text-8xl font-bold text-theme-primary leading-none mb-2">
{{ status_code }}
</div>
{# Status Name #}
<h1 class="text-3xl font-semibold text-gray-900 mb-4">
{{ status_name }}
</h1>
{# Error Message #}
<p class="text-lg text-gray-500 mb-10 leading-relaxed">
{{ message }}
</p>
{# Action Buttons #}
<div class="flex gap-4 justify-center flex-wrap mt-8">
{% block action_buttons %}
<a href="{{ base_url }}shop/" class="btn btn-primary">Continue Shopping</a>
<a href="{{ base_url }}shop/contact" class="btn btn-secondary">Contact Us</a>
<a href="{{ base_url }}shop/"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Continue Shopping
</a>
<a href="{{ base_url }}shop/contact"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Contact Us
</a>
{% endblock %}
</div>
{% block extra_content %}{% endblock %}
<div class="support-link">
{# Support Link #}
<div class="mt-10 pt-8 border-t border-gray-200 text-sm text-gray-500">
{% block support_link %}
Need help? <a href="{{ base_url }}shop/contact">Contact our support team</a>
Need help? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact our support team</a>
{% endblock %}
</div>
{# Vendor Info #}
{% if vendor %}
<div class="vendor-info">
<div class="mt-8 text-sm text-gray-400">
{{ vendor.name }}
</div>
{% endif %}
{% endblock %}
</div>
</body>
</html>
</html>

View File

@@ -1,11 +1,15 @@
<DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Search results page</title>
</head>
<body>
<-- Search results page -->
</body>
</html>
{# app/templates/shop/search.html #}
{% extends "shop/base.html" %}
{% block title %}Search Results{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Search Results</h1>
{# TODO: Implement search results #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Search results coming soon...</p>
</div>
</div>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{# app/templates/vendor/base.html #}
<!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
<html :class="{ 'dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -10,11 +10,7 @@
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Tailwind CSS with CDN fallback -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
<!-- Vendor-specific Tailwind customizations -->
<!-- Tailwind CSS v4 (built locally via standalone CLI) -->
<link rel="stylesheet" href="{{ url_for('static', path='vendor/css/tailwind.output.css') }}" />
<!-- Alpine Cloak -->

View File

@@ -1,6 +1,6 @@
{# app/templates/vendor/login.html #}
<!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="vendorLogin()" lang="en">
<html :class="{ 'dark': dark }" x-data="vendorLogin()" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />