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:
100
app/api/deps.py
100
app/api/deps.py
@@ -42,10 +42,14 @@ from app.core.database import get_db
|
||||
from app.exceptions import (
|
||||
AdminRequiredException,
|
||||
InsufficientPermissionsException,
|
||||
InsufficientVendorPermissionsException,
|
||||
InvalidTokenException,
|
||||
UnauthorizedVendorAccessException,
|
||||
VendorAccessDeniedException,
|
||||
VendorNotFoundException,
|
||||
VendorOwnerOnlyException,
|
||||
)
|
||||
from app.services.vendor_service import vendor_service
|
||||
from middleware.auth import AuthManager
|
||||
from middleware.rate_limiter import RateLimiter
|
||||
from models.database.user import User
|
||||
@@ -545,12 +549,16 @@ def require_vendor_permission(permission: str):
|
||||
"""
|
||||
Dependency factory to require a specific vendor permission.
|
||||
|
||||
Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The vendor object is loaded and stored in request.state.vendor for endpoint use.
|
||||
|
||||
Usage:
|
||||
@router.get("/products")
|
||||
def list_products(
|
||||
vendor: Vendor = Depends(get_vendor_from_code),
|
||||
request: Request,
|
||||
user: User = Depends(require_vendor_permission(VendorPermissions.PRODUCTS_VIEW.value))
|
||||
):
|
||||
vendor = request.state.vendor # Vendor is set by this dependency
|
||||
...
|
||||
"""
|
||||
|
||||
@@ -559,10 +567,17 @@ def require_vendor_permission(permission: str):
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
||||
) -> User:
|
||||
# Get vendor from request state (set by middleware)
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if not vendor:
|
||||
raise VendorAccessDeniedException("No vendor context")
|
||||
# Get vendor ID from JWT token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Load vendor from database (raises VendorNotFoundException if not found)
|
||||
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
|
||||
|
||||
# Store vendor in request state for endpoint use
|
||||
request.state.vendor = vendor
|
||||
|
||||
# Check if user has permission
|
||||
if not current_user.has_vendor_permission(vendor.id, permission):
|
||||
@@ -584,16 +599,29 @@ def require_vendor_owner(
|
||||
"""
|
||||
Dependency to require vendor owner role.
|
||||
|
||||
Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The vendor object is loaded and stored in request.state.vendor for endpoint use.
|
||||
|
||||
Usage:
|
||||
@router.delete("/team/{user_id}")
|
||||
def remove_team_member(
|
||||
request: Request,
|
||||
user: User = Depends(require_vendor_owner)
|
||||
):
|
||||
vendor = request.state.vendor # Vendor is set by this dependency
|
||||
...
|
||||
"""
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if not vendor:
|
||||
raise VendorAccessDeniedException("No vendor context")
|
||||
# Get vendor ID from JWT token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Load vendor from database (raises VendorNotFoundException if not found)
|
||||
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
|
||||
|
||||
# Store vendor in request state for endpoint use
|
||||
request.state.vendor = vendor
|
||||
|
||||
if not current_user.is_owner_of(vendor.id):
|
||||
raise VendorOwnerOnlyException(
|
||||
@@ -608,14 +636,19 @@ def require_any_vendor_permission(*permissions: str):
|
||||
"""
|
||||
Dependency factory to require ANY of the specified permissions.
|
||||
|
||||
Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The vendor object is loaded and stored in request.state.vendor for endpoint use.
|
||||
|
||||
Usage:
|
||||
@router.get("/dashboard")
|
||||
def dashboard(
|
||||
request: Request,
|
||||
user: User = Depends(require_any_vendor_permission(
|
||||
VendorPermissions.DASHBOARD_VIEW.value,
|
||||
VendorPermissions.REPORTS_VIEW.value
|
||||
))
|
||||
):
|
||||
vendor = request.state.vendor # Vendor is set by this dependency
|
||||
...
|
||||
"""
|
||||
|
||||
@@ -624,9 +657,17 @@ def require_any_vendor_permission(*permissions: str):
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
||||
) -> User:
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if not vendor:
|
||||
raise VendorAccessDeniedException("No vendor context")
|
||||
# Get vendor ID from JWT token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Load vendor from database (raises VendorNotFoundException if not found)
|
||||
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
|
||||
|
||||
# Store vendor in request state for endpoint use
|
||||
request.state.vendor = vendor
|
||||
|
||||
# Check if user has ANY of the required permissions
|
||||
has_permission = any(
|
||||
@@ -648,14 +689,19 @@ def require_all_vendor_permissions(*permissions: str):
|
||||
"""
|
||||
Dependency factory to require ALL of the specified permissions.
|
||||
|
||||
Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The vendor object is loaded and stored in request.state.vendor for endpoint use.
|
||||
|
||||
Usage:
|
||||
@router.post("/products/bulk-delete")
|
||||
def bulk_delete_products(
|
||||
request: Request,
|
||||
user: User = Depends(require_all_vendor_permissions(
|
||||
VendorPermissions.PRODUCTS_VIEW.value,
|
||||
VendorPermissions.PRODUCTS_DELETE.value
|
||||
))
|
||||
):
|
||||
vendor = request.state.vendor # Vendor is set by this dependency
|
||||
...
|
||||
"""
|
||||
|
||||
@@ -664,9 +710,17 @@ def require_all_vendor_permissions(*permissions: str):
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
||||
) -> User:
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if not vendor:
|
||||
raise VendorAccessDeniedException("No vendor context")
|
||||
# Get vendor ID from JWT token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Load vendor from database (raises VendorNotFoundException if not found)
|
||||
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
|
||||
|
||||
# Store vendor in request state for endpoint use
|
||||
request.state.vendor = vendor
|
||||
|
||||
# Check if user has ALL required permissions
|
||||
missing_permissions = [
|
||||
@@ -688,17 +742,29 @@ def require_all_vendor_permissions(*permissions: str):
|
||||
|
||||
def get_user_permissions(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
||||
) -> list:
|
||||
"""
|
||||
Get all permissions for current user in current vendor.
|
||||
|
||||
Returns empty list if no vendor context.
|
||||
Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
Also sets request.state.vendor for endpoint use.
|
||||
|
||||
Returns empty list if no vendor context in token.
|
||||
"""
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if not vendor:
|
||||
# Get vendor ID from JWT token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
return []
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Load vendor from database
|
||||
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
|
||||
|
||||
# Store vendor in request state for endpoint use
|
||||
request.state.vendor = vendor
|
||||
|
||||
# If owner, return all permissions
|
||||
if current_user.is_owner_of(vendor.id):
|
||||
from app.core.permissions import VendorPermissions
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.core.environment import should_use_secure_cookies
|
||||
from app.exceptions import InvalidCredentialsException
|
||||
from app.services.auth_service import auth_service
|
||||
from models.database.user import User
|
||||
from models.schema.auth import LoginResponse, UserLogin, UserResponse
|
||||
from models.schema.auth import LoginResponse, LogoutResponse, UserLogin, UserResponse
|
||||
|
||||
router = APIRouter(prefix="/auth")
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -97,7 +97,7 @@ def get_current_admin(current_user: User = Depends(get_current_admin_api)):
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
@router.post("/logout", response_model=LogoutResponse)
|
||||
def admin_logout(response: Response):
|
||||
"""
|
||||
Admin logout endpoint.
|
||||
@@ -115,4 +115,4 @@ def admin_logout(response: Response):
|
||||
|
||||
logger.debug("Deleted admin_token cookie")
|
||||
|
||||
return {"message": "Logged out successfully"}
|
||||
return LogoutResponse(message="Logged out successfully")
|
||||
|
||||
@@ -5,12 +5,13 @@ RESTful API for architecture validation and violation management
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import ViolationNotFoundException
|
||||
from app.services.code_quality_service import code_quality_service
|
||||
from models.database.user import User
|
||||
|
||||
@@ -136,25 +137,23 @@ async def trigger_scan(
|
||||
Trigger a new architecture scan
|
||||
|
||||
Requires authentication. Runs the validator script and stores results.
|
||||
Domain exceptions (ScanTimeoutException, ScanParseException) bubble up to global handler.
|
||||
"""
|
||||
try:
|
||||
scan = code_quality_service.run_scan(
|
||||
db, triggered_by=f"manual:{current_user.username}"
|
||||
)
|
||||
scan = code_quality_service.run_scan(
|
||||
db, triggered_by=f"manual:{current_user.username}"
|
||||
)
|
||||
|
||||
return ScanResponse(
|
||||
id=scan.id,
|
||||
timestamp=scan.timestamp.isoformat(),
|
||||
total_files=scan.total_files,
|
||||
total_violations=scan.total_violations,
|
||||
errors=scan.errors,
|
||||
warnings=scan.warnings,
|
||||
duration_seconds=scan.duration_seconds,
|
||||
triggered_by=scan.triggered_by,
|
||||
git_commit_hash=scan.git_commit_hash,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Scan failed: {str(e)}")
|
||||
return ScanResponse(
|
||||
id=scan.id,
|
||||
timestamp=scan.timestamp.isoformat(),
|
||||
total_files=scan.total_files,
|
||||
total_violations=scan.total_violations,
|
||||
errors=scan.errors,
|
||||
warnings=scan.warnings,
|
||||
duration_seconds=scan.duration_seconds,
|
||||
triggered_by=scan.triggered_by,
|
||||
git_commit_hash=scan.git_commit_hash,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scans", response_model=list[ScanResponse])
|
||||
@@ -269,7 +268,7 @@ async def get_violation(
|
||||
violation = code_quality_service.get_violation_by_id(db, violation_id)
|
||||
|
||||
if not violation:
|
||||
raise HTTPException(status_code=404, detail="Violation not found")
|
||||
raise ViolationNotFoundException(violation_id)
|
||||
|
||||
# Format assignments
|
||||
assignments = [
|
||||
@@ -331,29 +330,26 @@ async def assign_violation(
|
||||
|
||||
Updates violation status to 'assigned'.
|
||||
"""
|
||||
try:
|
||||
assignment = code_quality_service.assign_violation(
|
||||
db,
|
||||
violation_id=violation_id,
|
||||
user_id=request.user_id,
|
||||
assigned_by=current_user.id,
|
||||
due_date=request.due_date,
|
||||
priority=request.priority,
|
||||
)
|
||||
assignment = code_quality_service.assign_violation(
|
||||
db,
|
||||
violation_id=violation_id,
|
||||
user_id=request.user_id,
|
||||
assigned_by=current_user.id,
|
||||
due_date=request.due_date,
|
||||
priority=request.priority,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": assignment.id,
|
||||
"violation_id": assignment.violation_id,
|
||||
"user_id": assignment.user_id,
|
||||
"assigned_at": assignment.assigned_at.isoformat(),
|
||||
"assigned_by": assignment.assigned_by,
|
||||
"due_date": (
|
||||
assignment.due_date.isoformat() if assignment.due_date else None
|
||||
),
|
||||
"priority": assignment.priority,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {
|
||||
"id": assignment.id,
|
||||
"violation_id": assignment.violation_id,
|
||||
"user_id": assignment.user_id,
|
||||
"assigned_at": assignment.assigned_at.isoformat(),
|
||||
"assigned_by": assignment.assigned_by,
|
||||
"due_date": (
|
||||
assignment.due_date.isoformat() if assignment.due_date else None
|
||||
),
|
||||
"priority": assignment.priority,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/violations/{violation_id}/resolve")
|
||||
@@ -367,28 +363,24 @@ async def resolve_violation(
|
||||
Mark violation as resolved
|
||||
|
||||
Records resolution timestamp and user.
|
||||
ViolationNotFoundException bubbles up if violation doesn't exist.
|
||||
"""
|
||||
try:
|
||||
violation = code_quality_service.resolve_violation(
|
||||
db,
|
||||
violation_id=violation_id,
|
||||
resolved_by=current_user.id,
|
||||
resolution_note=request.resolution_note,
|
||||
)
|
||||
violation = code_quality_service.resolve_violation(
|
||||
db,
|
||||
violation_id=violation_id,
|
||||
resolved_by=current_user.id,
|
||||
resolution_note=request.resolution_note,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": violation.id,
|
||||
"status": violation.status,
|
||||
"resolved_at": (
|
||||
violation.resolved_at.isoformat() if violation.resolved_at else None
|
||||
),
|
||||
"resolved_by": violation.resolved_by,
|
||||
"resolution_note": violation.resolution_note,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {
|
||||
"id": violation.id,
|
||||
"status": violation.status,
|
||||
"resolved_at": (
|
||||
violation.resolved_at.isoformat() if violation.resolved_at else None
|
||||
),
|
||||
"resolved_by": violation.resolved_by,
|
||||
"resolution_note": violation.resolution_note,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/violations/{violation_id}/ignore")
|
||||
@@ -402,28 +394,24 @@ async def ignore_violation(
|
||||
Mark violation as ignored (won't fix)
|
||||
|
||||
Records reason for ignoring.
|
||||
ViolationNotFoundException bubbles up if violation doesn't exist.
|
||||
"""
|
||||
try:
|
||||
violation = code_quality_service.ignore_violation(
|
||||
db,
|
||||
violation_id=violation_id,
|
||||
ignored_by=current_user.id,
|
||||
reason=request.reason,
|
||||
)
|
||||
violation = code_quality_service.ignore_violation(
|
||||
db,
|
||||
violation_id=violation_id,
|
||||
ignored_by=current_user.id,
|
||||
reason=request.reason,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": violation.id,
|
||||
"status": violation.status,
|
||||
"resolved_at": (
|
||||
violation.resolved_at.isoformat() if violation.resolved_at else None
|
||||
),
|
||||
"resolved_by": violation.resolved_by,
|
||||
"resolution_note": violation.resolution_note,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {
|
||||
"id": violation.id,
|
||||
"status": violation.status,
|
||||
"resolved_at": (
|
||||
violation.resolved_at.isoformat() if violation.resolved_at else None
|
||||
),
|
||||
"resolved_by": violation.resolved_by,
|
||||
"resolution_note": violation.resolution_note,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/violations/{violation_id}/comments")
|
||||
@@ -438,23 +426,20 @@ async def add_comment(
|
||||
|
||||
For team collaboration and discussion.
|
||||
"""
|
||||
try:
|
||||
comment = code_quality_service.add_comment(
|
||||
db,
|
||||
violation_id=violation_id,
|
||||
user_id=current_user.id,
|
||||
comment=request.comment,
|
||||
)
|
||||
comment = code_quality_service.add_comment(
|
||||
db,
|
||||
violation_id=violation_id,
|
||||
user_id=current_user.id,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": comment.id,
|
||||
"violation_id": comment.violation_id,
|
||||
"user_id": comment.user_id,
|
||||
"comment": comment.comment,
|
||||
"created_at": comment.created_at.isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {
|
||||
"id": comment.id,
|
||||
"violation_id": comment.violation_id,
|
||||
"user_id": comment.user_id,
|
||||
"comment": comment.comment,
|
||||
"created_at": comment.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats", response_model=DashboardStatsResponse)
|
||||
|
||||
@@ -10,7 +10,7 @@ Platform administrators can:
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -170,28 +170,9 @@ def list_all_pages(
|
||||
|
||||
Filter by vendor_id to see specific vendor pages.
|
||||
"""
|
||||
if vendor_id:
|
||||
pages = content_page_service.list_all_vendor_pages(
|
||||
db, vendor_id=vendor_id, include_unpublished=include_unpublished
|
||||
)
|
||||
else:
|
||||
# Get all pages (both platform and vendor)
|
||||
from sqlalchemy import and_
|
||||
|
||||
from models.database.content_page import ContentPage
|
||||
|
||||
filters = []
|
||||
if not include_unpublished:
|
||||
filters.append(ContentPage.is_published == True)
|
||||
|
||||
pages = (
|
||||
db.query(ContentPage)
|
||||
.filter(and_(*filters) if filters else True)
|
||||
.order_by(
|
||||
ContentPage.vendor_id, ContentPage.display_order, ContentPage.title
|
||||
)
|
||||
.all()
|
||||
)
|
||||
pages = content_page_service.list_all_pages(
|
||||
db, vendor_id=vendor_id, include_unpublished=include_unpublished
|
||||
)
|
||||
|
||||
return [page.to_dict() for page in pages]
|
||||
|
||||
@@ -203,11 +184,7 @@ def get_page(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get a specific content page by ID."""
|
||||
page = content_page_service.get_page_by_id(db, page_id)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Content page not found")
|
||||
|
||||
page = content_page_service.get_page_by_id_or_raise(db, page_id)
|
||||
return page.to_dict()
|
||||
|
||||
|
||||
@@ -219,7 +196,7 @@ def update_page(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update a content page (platform or vendor)."""
|
||||
page = content_page_service.update_page(
|
||||
page = content_page_service.update_page_or_raise(
|
||||
db,
|
||||
page_id=page_id,
|
||||
title=page_data.title,
|
||||
@@ -234,10 +211,6 @@ def update_page(
|
||||
display_order=page_data.display_order,
|
||||
updated_by=current_user.id,
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Content page not found")
|
||||
|
||||
return page.to_dict()
|
||||
|
||||
|
||||
@@ -248,9 +221,4 @@ def delete_page(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete a content page."""
|
||||
success = content_page_service.delete_page(db, page_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Content page not found")
|
||||
|
||||
return
|
||||
content_page_service.delete_page_or_raise(db, page_id)
|
||||
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.core.logging import reload_log_level
|
||||
from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException
|
||||
from app.services.admin_audit_service import admin_audit_service
|
||||
from app.services.admin_settings_service import admin_settings_service
|
||||
from app.services.log_service import log_service
|
||||
@@ -26,8 +27,12 @@ from models.schema.admin import (
|
||||
ApplicationLogFilters,
|
||||
ApplicationLogListResponse,
|
||||
FileLogResponse,
|
||||
LogCleanupResponse,
|
||||
LogDeleteResponse,
|
||||
LogFileListResponse,
|
||||
LogSettingsResponse,
|
||||
LogSettingsUpdate,
|
||||
LogSettingsUpdateResponse,
|
||||
LogStatistics,
|
||||
)
|
||||
|
||||
@@ -87,7 +92,7 @@ def get_log_statistics(
|
||||
return log_service.get_log_statistics(db, days)
|
||||
|
||||
|
||||
@router.delete("/database/cleanup")
|
||||
@router.delete("/database/cleanup", response_model=LogCleanupResponse)
|
||||
def cleanup_old_logs(
|
||||
retention_days: int = Query(30, ge=1, le=365),
|
||||
confirm: bool = Query(False, description="Must be true to confirm cleanup"),
|
||||
@@ -99,13 +104,8 @@ def cleanup_old_logs(
|
||||
|
||||
Requires confirmation parameter.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
if not confirm:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cleanup requires confirmation parameter: confirm=true",
|
||||
)
|
||||
raise ConfirmationRequiredException(operation="cleanup_logs")
|
||||
|
||||
deleted_count = log_service.cleanup_old_logs(db, retention_days)
|
||||
|
||||
@@ -119,13 +119,13 @@ def cleanup_old_logs(
|
||||
details={"retention_days": retention_days, "deleted_count": deleted_count},
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Deleted {deleted_count} log entries older than {retention_days} days",
|
||||
"deleted_count": deleted_count,
|
||||
}
|
||||
return LogCleanupResponse(
|
||||
message=f"Deleted {deleted_count} log entries older than {retention_days} days",
|
||||
deleted_count=deleted_count,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/database/{log_id}")
|
||||
@router.delete("/database/{log_id}", response_model=LogDeleteResponse)
|
||||
def delete_log(
|
||||
log_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -144,7 +144,7 @@ def delete_log(
|
||||
details={},
|
||||
)
|
||||
|
||||
return {"message": message}
|
||||
return LogDeleteResponse(message=message)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -152,7 +152,7 @@ def delete_log(
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
@router.get("/files", response_model=LogFileListResponse)
|
||||
def list_log_files(
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
@@ -161,7 +161,7 @@ def list_log_files(
|
||||
|
||||
Returns list of log files with size and modification date.
|
||||
"""
|
||||
return {"files": log_service.list_log_files()}
|
||||
return LogFileListResponse(files=log_service.list_log_files())
|
||||
|
||||
|
||||
@router.get("/files/{filename}", response_model=FileLogResponse)
|
||||
@@ -191,7 +191,6 @@ def download_log_file(
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# Determine log file path
|
||||
@@ -202,7 +201,7 @@ def download_log_file(
|
||||
log_file = Path("logs") / filename
|
||||
|
||||
if not log_file.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Log file '{filename}' not found")
|
||||
raise ResourceNotFoundException(resource_type="LogFile", identifier=filename)
|
||||
|
||||
# Log action
|
||||
from app.core.database import get_db
|
||||
@@ -267,7 +266,7 @@ def get_log_settings(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
@router.put("/settings", response_model=LogSettingsUpdateResponse)
|
||||
def update_log_settings(
|
||||
settings_update: LogSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -335,8 +334,8 @@ def update_log_settings(
|
||||
details={"updated_fields": updated},
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Log settings updated successfully",
|
||||
"updated_fields": updated,
|
||||
"note": "Log level changes are applied immediately. File rotation settings require restart.",
|
||||
}
|
||||
return LogSettingsUpdateResponse(
|
||||
message="Log settings updated successfully",
|
||||
updated_fields=updated,
|
||||
note="Log level changes are applied immediately. File rotation settings require restart.",
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException
|
||||
from app.services.admin_audit_service import admin_audit_service
|
||||
from app.services.admin_settings_service import admin_settings_service
|
||||
from models.database.user import User
|
||||
@@ -78,9 +79,9 @@ def get_setting(
|
||||
setting = admin_settings_service.get_setting_by_key(db, key)
|
||||
|
||||
if not setting:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
|
||||
raise ResourceNotFoundException(
|
||||
resource_type="Setting", identifier=key
|
||||
)
|
||||
|
||||
return AdminSettingResponse.model_validate(setting)
|
||||
|
||||
@@ -184,12 +185,10 @@ def delete_setting(
|
||||
Requires confirmation parameter.
|
||||
WARNING: Deleting settings may affect platform functionality.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
if not confirm:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Deletion requires confirmation parameter: confirm=true",
|
||||
raise ConfirmationRequiredException(
|
||||
operation="delete_setting",
|
||||
message="Deletion requires confirmation parameter: confirm=true",
|
||||
)
|
||||
|
||||
message = admin_settings_service.delete_setting(
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
# app/api/v1/admin/users.py
|
||||
"""
|
||||
User management endpoints for admin.
|
||||
|
||||
All endpoints use the admin_service for business logic.
|
||||
Domain exceptions are raised by the service and converted to HTTP responses
|
||||
by the global exception handler.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.services.admin_service import admin_service
|
||||
from middleware.auth import AuthManager
|
||||
from app.services.stats_service import stats_service
|
||||
from models.database.user import User
|
||||
from models.schema.auth import (
|
||||
UserCreate,
|
||||
UserDeleteResponse,
|
||||
UserDetailResponse,
|
||||
UserListResponse,
|
||||
UserResponse,
|
||||
UserSearchResponse,
|
||||
UserStatusToggleResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
|
||||
@@ -38,31 +43,19 @@ def get_all_users(
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get paginated list of all users (Admin only)."""
|
||||
query = db.query(User)
|
||||
|
||||
# Apply filters
|
||||
if search:
|
||||
search_term = f"%{search.lower()}%"
|
||||
query = query.filter(
|
||||
(User.username.ilike(search_term))
|
||||
| (User.email.ilike(search_term))
|
||||
| (User.first_name.ilike(search_term))
|
||||
| (User.last_name.ilike(search_term))
|
||||
)
|
||||
|
||||
if role:
|
||||
query = query.filter(User.role == role)
|
||||
|
||||
# Convert string params to proper types
|
||||
is_active_bool = None
|
||||
if is_active:
|
||||
query = query.filter(User.is_active == (is_active.lower() == "true"))
|
||||
is_active_bool = is_active.lower() == "true"
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
# Apply pagination
|
||||
skip = (page - 1) * per_page
|
||||
users = query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all()
|
||||
users, total, pages = admin_service.list_users(
|
||||
db=db,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
search=search if search else None,
|
||||
role=role if role else None,
|
||||
is_active=is_active_bool,
|
||||
)
|
||||
|
||||
return UserListResponse(
|
||||
items=[UserResponse.model_validate(user) for user in users],
|
||||
@@ -80,30 +73,16 @@ def create_user(
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Create a new user (Admin only)."""
|
||||
# Check if email exists
|
||||
if db.query(User).filter(User.email == user_data.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
# Check if username exists
|
||||
if db.query(User).filter(User.username == user_data.username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
|
||||
# Create user
|
||||
auth_manager = AuthManager()
|
||||
user = User(
|
||||
user = admin_service.create_user(
|
||||
db=db,
|
||||
email=user_data.email,
|
||||
username=user_data.username,
|
||||
hashed_password=auth_manager.hash_password(user_data.password),
|
||||
password=user_data.password,
|
||||
first_name=user_data.first_name,
|
||||
last_name=user_data.last_name,
|
||||
role=user_data.role,
|
||||
is_active=True,
|
||||
current_admin_id=current_admin.id,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"Admin {current_admin.username} created user {user.username}")
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
@@ -118,8 +97,8 @@ def create_user(
|
||||
last_name=user.last_name,
|
||||
full_name=user.full_name,
|
||||
is_email_verified=user.is_email_verified,
|
||||
owned_companies_count=len(user.owned_companies),
|
||||
vendor_memberships_count=len(user.vendor_memberships),
|
||||
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
|
||||
vendor_memberships_count=len(user.vendor_memberships) if user.vendor_memberships else 0,
|
||||
)
|
||||
|
||||
|
||||
@@ -132,7 +111,7 @@ def get_user_statistics(
|
||||
return stats_service.get_user_statistics(db)
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
@router.get("/search", response_model=UserSearchResponse)
|
||||
def search_users(
|
||||
q: str = Query(..., min_length=2, description="Search query (username or email)"),
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
@@ -144,25 +123,8 @@ def search_users(
|
||||
|
||||
Used for autocomplete in ownership transfer.
|
||||
"""
|
||||
search_term = f"%{q.lower()}%"
|
||||
users = (
|
||||
db.query(User)
|
||||
.filter((User.username.ilike(search_term)) | (User.email.ilike(search_term)))
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return {
|
||||
"users": [
|
||||
{
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"is_active": user.is_active,
|
||||
}
|
||||
for user in users
|
||||
]
|
||||
}
|
||||
users = admin_service.search_users(db=db, query=q, limit=limit)
|
||||
return UserSearchResponse(users=users)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserDetailResponse)
|
||||
@@ -172,15 +134,7 @@ def get_user_details(
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get detailed user information (Admin only)."""
|
||||
user = (
|
||||
db.query(User)
|
||||
.options(joinedload(User.owned_companies), joinedload(User.vendor_memberships))
|
||||
.filter(User.id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
user = admin_service.get_user_details(db=db, user_id=user_id)
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
@@ -195,8 +149,8 @@ def get_user_details(
|
||||
last_name=user.last_name,
|
||||
full_name=user.full_name,
|
||||
is_email_verified=user.is_email_verified,
|
||||
owned_companies_count=len(user.owned_companies),
|
||||
vendor_memberships_count=len(user.vendor_memberships),
|
||||
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
|
||||
vendor_memberships_count=len(user.vendor_memberships) if user.vendor_memberships else 0,
|
||||
)
|
||||
|
||||
|
||||
@@ -208,36 +162,19 @@ def update_user(
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Update user information (Admin only)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent changing own admin status
|
||||
if user.id == current_admin.id and user_update.role and user_update.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Cannot change your own admin role"
|
||||
)
|
||||
|
||||
# Check email uniqueness if changing
|
||||
if user_update.email and user_update.email != user.email:
|
||||
if db.query(User).filter(User.email == user_update.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
# Check username uniqueness if changing
|
||||
if user_update.username and user_update.username != user.username:
|
||||
if db.query(User).filter(User.username == user_update.username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
|
||||
# Update fields
|
||||
update_data = user_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"Admin {current_admin.username} updated user {user.username}")
|
||||
user = admin_service.update_user(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
current_admin_id=current_admin.id,
|
||||
email=update_data.get("email"),
|
||||
username=update_data.get("username"),
|
||||
first_name=update_data.get("first_name"),
|
||||
last_name=update_data.get("last_name"),
|
||||
role=update_data.get("role"),
|
||||
is_active=update_data.get("is_active"),
|
||||
)
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
@@ -252,68 +189,38 @@ def update_user(
|
||||
last_name=user.last_name,
|
||||
full_name=user.full_name,
|
||||
is_email_verified=user.is_email_verified,
|
||||
owned_companies_count=len(user.owned_companies),
|
||||
vendor_memberships_count=len(user.vendor_memberships),
|
||||
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
|
||||
vendor_memberships_count=len(user.vendor_memberships) if user.vendor_memberships else 0,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{user_id}/status")
|
||||
@router.put("/{user_id}/status", response_model=UserStatusToggleResponse)
|
||||
def toggle_user_status(
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Toggle user active status (Admin only)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
user, message = admin_service.toggle_user_status(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
current_admin_id=current_admin.id,
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent deactivating yourself
|
||||
if user.id == current_admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
|
||||
|
||||
user.is_active = not user.is_active
|
||||
db.commit()
|
||||
|
||||
action = "activated" if user.is_active else "deactivated"
|
||||
logger.info(f"Admin {current_admin.username} {action} user {user.username}")
|
||||
|
||||
return {"message": f"User {action} successfully", "is_active": user.is_active}
|
||||
return UserStatusToggleResponse(message=message, is_active=user.is_active)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
@router.delete("/{user_id}", response_model=UserDeleteResponse)
|
||||
def delete_user(
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Delete a user (Admin only)."""
|
||||
user = (
|
||||
db.query(User)
|
||||
.options(joinedload(User.owned_companies))
|
||||
.filter(User.id == user_id)
|
||||
.first()
|
||||
message = admin_service.delete_user(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
current_admin_id=current_admin.id,
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent deleting yourself
|
||||
if user.id == current_admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
# Prevent deleting users who own companies
|
||||
if user.owned_companies:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot delete user who owns {len(user.owned_companies)} company(ies). Transfer ownership first.",
|
||||
)
|
||||
|
||||
username = user.username
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Admin {current_admin.username} deleted user {username}")
|
||||
|
||||
return {"message": "User deleted successfully"}
|
||||
return UserDeleteResponse(message=message)
|
||||
|
||||
@@ -16,14 +16,20 @@ This prevents:
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.environment import should_use_secure_cookies
|
||||
from app.exceptions import VendorNotFoundException
|
||||
from app.services.customer_service import customer_service
|
||||
from models.schema.auth import UserLogin
|
||||
from models.schema.auth import (
|
||||
LogoutResponse,
|
||||
PasswordResetRequestResponse,
|
||||
PasswordResetResponse,
|
||||
UserLogin,
|
||||
)
|
||||
from models.schema.customer import CustomerRegister, CustomerResponse
|
||||
|
||||
router = APIRouter()
|
||||
@@ -62,10 +68,7 @@ def register_customer(
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] register_customer for vendor {vendor.subdomain}",
|
||||
@@ -122,10 +125,7 @@ def customer_login(
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] customer_login for vendor {vendor.subdomain}",
|
||||
@@ -199,7 +199,7 @@ def customer_login(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/logout")
|
||||
@router.post("/auth/logout", response_model=LogoutResponse)
|
||||
def customer_logout(request: Request, response: Response):
|
||||
"""
|
||||
Customer logout for current vendor.
|
||||
@@ -245,10 +245,10 @@ def customer_logout(request: Request, response: Response):
|
||||
|
||||
logger.debug(f"Deleted customer_token cookie (path={cookie_path})")
|
||||
|
||||
return {"message": "Logged out successfully"}
|
||||
return LogoutResponse(message="Logged out successfully")
|
||||
|
||||
|
||||
@router.post("/auth/forgot-password")
|
||||
@router.post("/auth/forgot-password", response_model=PasswordResetRequestResponse)
|
||||
def forgot_password(request: Request, email: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Request password reset for customer.
|
||||
@@ -263,10 +263,7 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] forgot_password for vendor {vendor.subdomain}",
|
||||
@@ -285,12 +282,12 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
|
||||
|
||||
logger.info(f"Password reset requested for {email} (vendor: {vendor.subdomain})")
|
||||
|
||||
return {
|
||||
"message": "If an account exists with this email, a password reset link has been sent."
|
||||
}
|
||||
return PasswordResetRequestResponse(
|
||||
message="If an account exists with this email, a password reset link has been sent."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/reset-password")
|
||||
@router.post("/auth/reset-password", response_model=PasswordResetResponse)
|
||||
def reset_password(
|
||||
request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)
|
||||
):
|
||||
@@ -307,10 +304,7 @@ def reset_password(
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] reset_password for vendor {vendor.subdomain}",
|
||||
@@ -329,6 +323,6 @@ def reset_password(
|
||||
|
||||
logger.info(f"Password reset completed (vendor: {vendor.subdomain})")
|
||||
|
||||
return {
|
||||
"message": "Password reset successfully. You can now log in with your new password."
|
||||
}
|
||||
return PasswordResetResponse(
|
||||
message="Password reset successfully. You can now log in with your new password."
|
||||
)
|
||||
|
||||
@@ -3,17 +3,21 @@
|
||||
Shop Shopping Cart API (Public)
|
||||
|
||||
Public endpoints for managing shopping cart in shop frontend.
|
||||
Uses vendor from request.state (injected by VendorContextMiddleware).
|
||||
Uses vendor from middleware context (VendorContextMiddleware).
|
||||
No authentication required - uses session ID for cart tracking.
|
||||
|
||||
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request
|
||||
from fastapi import APIRouter, Body, Depends, Path
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.cart_service import cart_service
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.cart import (
|
||||
AddToCartRequest,
|
||||
CartOperationResponse,
|
||||
@@ -31,30 +35,21 @@ logger = logging.getLogger(__name__)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/cart/{session_id}", response_model=CartResponse)
|
||||
@router.get("/cart/{session_id}", response_model=CartResponse) # public
|
||||
def get_cart(
|
||||
request: Request,
|
||||
session_id: str = Path(..., description="Shopping session ID"),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
) -> CartResponse:
|
||||
"""
|
||||
Get shopping cart contents for current vendor.
|
||||
|
||||
Vendor is automatically determined from request context.
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
No authentication required - uses session ID for cart tracking.
|
||||
|
||||
Path Parameters:
|
||||
- session_id: Unique session identifier for the cart
|
||||
"""
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[SHOP_API] get_cart for session {session_id}, vendor {vendor.id}",
|
||||
extra={
|
||||
@@ -79,17 +74,17 @@ def get_cart(
|
||||
return CartResponse.from_service_dict(cart)
|
||||
|
||||
|
||||
@router.post("/cart/{session_id}/items", response_model=CartOperationResponse)
|
||||
@router.post("/cart/{session_id}/items", response_model=CartOperationResponse) # public
|
||||
def add_to_cart(
|
||||
request: Request,
|
||||
session_id: str = Path(..., description="Shopping session ID"),
|
||||
cart_data: AddToCartRequest = Body(...),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
) -> CartOperationResponse:
|
||||
"""
|
||||
Add product to cart for current vendor.
|
||||
|
||||
Vendor is automatically determined from request context.
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
No authentication required - uses session ID.
|
||||
|
||||
Path Parameters:
|
||||
@@ -99,15 +94,6 @@ def add_to_cart(
|
||||
- product_id: ID of product to add
|
||||
- quantity: Quantity to add (default: 1)
|
||||
"""
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[SHOP_API] add_to_cart: product {cart_data.product_id}, qty {cart_data.quantity}, session {session_id}",
|
||||
extra={
|
||||
@@ -140,18 +126,18 @@ def add_to_cart(
|
||||
|
||||
@router.put(
|
||||
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
|
||||
)
|
||||
) # public
|
||||
def update_cart_item(
|
||||
request: Request,
|
||||
session_id: str = Path(..., description="Shopping session ID"),
|
||||
product_id: int = Path(..., description="Product ID", gt=0),
|
||||
cart_data: UpdateCartItemRequest = Body(...),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
) -> CartOperationResponse:
|
||||
"""
|
||||
Update cart item quantity for current vendor.
|
||||
|
||||
Vendor is automatically determined from request context.
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
No authentication required - uses session ID.
|
||||
|
||||
Path Parameters:
|
||||
@@ -161,15 +147,6 @@ def update_cart_item(
|
||||
Request Body:
|
||||
- quantity: New quantity (must be >= 1)
|
||||
"""
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] update_cart_item: product {product_id}, qty {cart_data.quantity}",
|
||||
extra={
|
||||
@@ -194,32 +171,23 @@ def update_cart_item(
|
||||
|
||||
@router.delete(
|
||||
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
|
||||
)
|
||||
) # public
|
||||
def remove_from_cart(
|
||||
request: Request,
|
||||
session_id: str = Path(..., description="Shopping session ID"),
|
||||
product_id: int = Path(..., description="Product ID", gt=0),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
) -> CartOperationResponse:
|
||||
"""
|
||||
Remove item from cart for current vendor.
|
||||
|
||||
Vendor is automatically determined from request context.
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
No authentication required - uses session ID.
|
||||
|
||||
Path Parameters:
|
||||
- session_id: Unique session identifier for the cart
|
||||
- product_id: ID of product to remove
|
||||
"""
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] remove_from_cart: product {product_id}",
|
||||
extra={
|
||||
@@ -237,30 +205,21 @@ def remove_from_cart(
|
||||
return CartOperationResponse(**result)
|
||||
|
||||
|
||||
@router.delete("/cart/{session_id}", response_model=ClearCartResponse)
|
||||
@router.delete("/cart/{session_id}", response_model=ClearCartResponse) # public
|
||||
def clear_cart(
|
||||
request: Request,
|
||||
session_id: str = Path(..., description="Shopping session ID"),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ClearCartResponse:
|
||||
"""
|
||||
Clear all items from cart for current vendor.
|
||||
|
||||
Vendor is automatically determined from request context.
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
No authentication required - uses session ID.
|
||||
|
||||
Path Parameters:
|
||||
- session_id: Unique session identifier for the cart
|
||||
"""
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] clear_cart for session {session_id}",
|
||||
extra={
|
||||
|
||||
@@ -8,7 +8,7 @@ No authentication required.
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -90,16 +90,13 @@ def get_content_page(slug: str, request: Request, db: Session = Depends(get_db))
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
vendor_id = vendor.id if vendor else None
|
||||
|
||||
page = content_page_service.get_page_for_vendor(
|
||||
page = content_page_service.get_page_for_vendor_or_raise(
|
||||
db,
|
||||
slug=slug,
|
||||
vendor_id=vendor_id,
|
||||
include_unpublished=False, # Only show published pages
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail=f"Content page not found: {slug}")
|
||||
|
||||
return {
|
||||
"slug": page.slug,
|
||||
"title": page.title,
|
||||
|
||||
@@ -3,17 +3,21 @@
|
||||
Shop Product Catalog API (Public)
|
||||
|
||||
Public endpoints for browsing product catalog in shop frontend.
|
||||
Uses vendor from request.state (injected by VendorContextMiddleware).
|
||||
Uses vendor from middleware context (VendorContextMiddleware).
|
||||
No authentication required.
|
||||
|
||||
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.product_service import product_service
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.product import (
|
||||
ProductDetailResponse,
|
||||
ProductListResponse,
|
||||
@@ -24,19 +28,19 @@ router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/products", response_model=ProductListResponse)
|
||||
@router.get("/products", response_model=ProductListResponse) # public
|
||||
def get_product_catalog(
|
||||
request: Request,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
search: str | None = Query(None, description="Search products by name"),
|
||||
is_featured: bool | None = Query(None, description="Filter by featured products"),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get product catalog for current vendor.
|
||||
|
||||
Vendor is automatically determined from request context (domain/subdomain/path).
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
Only returns active products visible to customers.
|
||||
No authentication required.
|
||||
|
||||
@@ -46,15 +50,6 @@ def get_product_catalog(
|
||||
- search: Search query for product name/description
|
||||
- is_featured: Filter by featured products only
|
||||
"""
|
||||
# Get vendor from middleware (injected by VendorContextMiddleware)
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] get_product_catalog for vendor: {vendor.subdomain}",
|
||||
extra={
|
||||
@@ -85,30 +80,21 @@ def get_product_catalog(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/products/{product_id}", response_model=ProductDetailResponse)
|
||||
@router.get("/products/{product_id}", response_model=ProductDetailResponse) # public
|
||||
def get_product_details(
|
||||
request: Request,
|
||||
product_id: int = Path(..., description="Product ID", gt=0),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get detailed product information for customers.
|
||||
|
||||
Vendor is automatically determined from request context.
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
No authentication required.
|
||||
|
||||
Path Parameters:
|
||||
- product_id: ID of the product to retrieve
|
||||
"""
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] get_product_details for product {product_id}",
|
||||
extra={
|
||||
@@ -131,19 +117,19 @@ def get_product_details(
|
||||
return ProductDetailResponse.model_validate(product)
|
||||
|
||||
|
||||
@router.get("/products/search", response_model=ProductListResponse)
|
||||
@router.get("/products/search", response_model=ProductListResponse) # public
|
||||
def search_products(
|
||||
request: Request,
|
||||
q: str = Query(..., min_length=1, description="Search query"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Search products in current vendor's catalog.
|
||||
|
||||
Searches in product names, descriptions, and SKUs.
|
||||
Vendor is automatically determined from request context.
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
No authentication required.
|
||||
|
||||
Query Parameters:
|
||||
@@ -151,15 +137,6 @@ def search_products(
|
||||
- skip: Number of results to skip (pagination)
|
||||
- limit: Maximum number of results to return
|
||||
"""
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] search_products: '{q}'",
|
||||
extra={
|
||||
|
||||
16
app/api/v1/vendor/analytics.py
vendored
16
app/api/v1/vendor/analytics.py
vendored
@@ -1,6 +1,8 @@
|
||||
# app/api/v1/vendor/analytics.py
|
||||
"""
|
||||
Vendor analytics and reporting endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -10,21 +12,27 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidTokenException
|
||||
from app.services.stats_service import stats_service
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
router = APIRouter(prefix="/analytics")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_id_from_token(current_user: User) -> int:
|
||||
"""Helper to get vendor_id from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return current_user.token_vendor_id
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_vendor_analytics(
|
||||
period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get vendor analytics data for specified time period."""
|
||||
return stats_service.get_vendor_analytics(db, vendor.id, period)
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return stats_service.get_vendor_analytics(db, vendor_id, period)
|
||||
|
||||
83
app/api/v1/vendor/auth.py
vendored
83
app/api/v1/vendor/auth.py
vendored
@@ -25,8 +25,8 @@ from app.exceptions import InvalidCredentialsException
|
||||
from app.services.auth_service import auth_service
|
||||
from middleware.vendor_context import get_current_vendor
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Role, Vendor, VendorUser
|
||||
from models.schema.auth import UserLogin
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.auth import LogoutResponse, UserLogin, VendorUserResponse
|
||||
|
||||
router = APIRouter(prefix="/auth")
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -68,13 +68,7 @@ def vendor_login(
|
||||
if not vendor and hasattr(user_credentials, "vendor_code"):
|
||||
vendor_code = getattr(user_credentials, "vendor_code", None)
|
||||
if vendor_code:
|
||||
vendor = (
|
||||
db.query(Vendor)
|
||||
.filter(
|
||||
Vendor.vendor_code == vendor_code.upper(), Vendor.is_active == True
|
||||
)
|
||||
.first()
|
||||
)
|
||||
vendor = auth_service.get_vendor_by_code(db, vendor_code)
|
||||
|
||||
# Authenticate user
|
||||
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
|
||||
@@ -91,51 +85,22 @@ def vendor_login(
|
||||
vendor_role = "Member"
|
||||
|
||||
if vendor:
|
||||
# Check if user is vendor owner (via company ownership)
|
||||
is_owner = vendor.company and vendor.company.owner_user_id == user.id
|
||||
# Check if user has access to this vendor
|
||||
has_access, role = auth_service.get_user_vendor_role(db, user, vendor)
|
||||
|
||||
if is_owner:
|
||||
vendor_role = "Owner"
|
||||
if has_access:
|
||||
vendor_role = role
|
||||
else:
|
||||
# Check if user is team member
|
||||
vendor_user = (
|
||||
db.query(VendorUser)
|
||||
.join(Role)
|
||||
.filter(
|
||||
VendorUser.user_id == user.id,
|
||||
VendorUser.vendor_id == vendor.id,
|
||||
VendorUser.is_active == True,
|
||||
)
|
||||
.first()
|
||||
logger.warning(
|
||||
f"User {user.username} attempted login to vendor {vendor.vendor_code} "
|
||||
f"but is not authorized"
|
||||
)
|
||||
raise InvalidCredentialsException(
|
||||
"You do not have access to this vendor"
|
||||
)
|
||||
|
||||
if vendor_user:
|
||||
vendor_role = vendor_user.role.name
|
||||
else:
|
||||
logger.warning(
|
||||
f"User {user.username} attempted login to vendor {vendor.vendor_code} "
|
||||
f"but is not authorized"
|
||||
)
|
||||
raise InvalidCredentialsException(
|
||||
"You do not have access to this vendor"
|
||||
)
|
||||
else:
|
||||
# No vendor context - find which vendor this user belongs to
|
||||
# Check owned vendors first (via company ownership)
|
||||
for company in user.owned_companies:
|
||||
if company.vendors:
|
||||
vendor = company.vendors[0]
|
||||
vendor_role = "Owner"
|
||||
break
|
||||
|
||||
# Check vendor memberships if no owned vendor found
|
||||
if not vendor and user.vendor_memberships:
|
||||
active_membership = next(
|
||||
(vm for vm in user.vendor_memberships if vm.is_active), None
|
||||
)
|
||||
if active_membership:
|
||||
vendor = active_membership.vendor
|
||||
vendor_role = active_membership.role.name
|
||||
vendor, vendor_role = auth_service.find_user_vendor(user)
|
||||
|
||||
if not vendor:
|
||||
raise InvalidCredentialsException("User is not associated with any vendor")
|
||||
@@ -194,7 +159,7 @@ def vendor_login(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
@router.post("/logout", response_model=LogoutResponse)
|
||||
def vendor_logout(response: Response):
|
||||
"""
|
||||
Vendor team member logout.
|
||||
@@ -212,10 +177,10 @@ def vendor_logout(response: Response):
|
||||
|
||||
logger.debug("Deleted vendor_token cookie")
|
||||
|
||||
return {"message": "Logged out successfully"}
|
||||
return LogoutResponse(message="Logged out successfully")
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@router.get("/me", response_model=VendorUserResponse)
|
||||
def get_current_vendor_user(
|
||||
user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db)
|
||||
):
|
||||
@@ -225,10 +190,10 @@ def get_current_vendor_user(
|
||||
This endpoint can be called to verify authentication and get user info.
|
||||
Requires Authorization header (header-only authentication for API endpoints).
|
||||
"""
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
}
|
||||
return VendorUserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
)
|
||||
|
||||
63
app/api/v1/vendor/content_pages.py
vendored
63
app/api/v1/vendor/content_pages.py
vendored
@@ -10,11 +10,12 @@ Vendors can:
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api, get_db
|
||||
from app.exceptions.content_page import VendorNotAssociatedException
|
||||
from app.services.content_page_service import content_page_service
|
||||
from models.database.user import User
|
||||
|
||||
@@ -106,9 +107,7 @@ def list_vendor_pages(
|
||||
Returns vendor-specific overrides + platform defaults (vendor overrides take precedence).
|
||||
"""
|
||||
if not current_user.vendor_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="User is not associated with a vendor"
|
||||
)
|
||||
raise VendorNotAssociatedException()
|
||||
|
||||
pages = content_page_service.list_pages_for_vendor(
|
||||
db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
|
||||
@@ -129,9 +128,7 @@ def list_vendor_overrides(
|
||||
Shows what the vendor has customized.
|
||||
"""
|
||||
if not current_user.vendor_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="User is not associated with a vendor"
|
||||
)
|
||||
raise VendorNotAssociatedException()
|
||||
|
||||
pages = content_page_service.list_all_vendor_pages(
|
||||
db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
|
||||
@@ -153,20 +150,15 @@ def get_page(
|
||||
Returns vendor override if exists, otherwise platform default.
|
||||
"""
|
||||
if not current_user.vendor_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="User is not associated with a vendor"
|
||||
)
|
||||
raise VendorNotAssociatedException()
|
||||
|
||||
page = content_page_service.get_page_for_vendor(
|
||||
page = content_page_service.get_page_for_vendor_or_raise(
|
||||
db,
|
||||
slug=slug,
|
||||
vendor_id=current_user.vendor_id,
|
||||
include_unpublished=include_unpublished,
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail=f"Content page not found: {slug}")
|
||||
|
||||
return page.to_dict()
|
||||
|
||||
|
||||
@@ -182,9 +174,7 @@ def create_vendor_page(
|
||||
This will be shown instead of the platform default for this vendor.
|
||||
"""
|
||||
if not current_user.vendor_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="User is not associated with a vendor"
|
||||
)
|
||||
raise VendorNotAssociatedException()
|
||||
|
||||
page = content_page_service.create_page(
|
||||
db,
|
||||
@@ -218,24 +208,13 @@ def update_vendor_page(
|
||||
Can only update pages owned by this vendor.
|
||||
"""
|
||||
if not current_user.vendor_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="User is not associated with a vendor"
|
||||
)
|
||||
raise VendorNotAssociatedException()
|
||||
|
||||
# Verify ownership
|
||||
existing_page = content_page_service.get_page_by_id(db, page_id)
|
||||
if not existing_page:
|
||||
raise HTTPException(status_code=404, detail="Content page not found")
|
||||
|
||||
if existing_page.vendor_id != current_user.vendor_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Cannot edit pages from other vendors"
|
||||
)
|
||||
|
||||
# Update
|
||||
page = content_page_service.update_page(
|
||||
# Update with ownership check in service layer
|
||||
page = content_page_service.update_vendor_page(
|
||||
db,
|
||||
page_id=page_id,
|
||||
vendor_id=current_user.vendor_id,
|
||||
title=page_data.title,
|
||||
content=page_data.content,
|
||||
content_format=page_data.content_format,
|
||||
@@ -264,21 +243,7 @@ def delete_vendor_page(
|
||||
After deletion, platform default will be shown (if exists).
|
||||
"""
|
||||
if not current_user.vendor_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="User is not associated with a vendor"
|
||||
)
|
||||
raise VendorNotAssociatedException()
|
||||
|
||||
# Verify ownership
|
||||
existing_page = content_page_service.get_page_by_id(db, page_id)
|
||||
if not existing_page:
|
||||
raise HTTPException(status_code=404, detail="Content page not found")
|
||||
|
||||
if existing_page.vendor_id != current_user.vendor_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Cannot delete pages from other vendors"
|
||||
)
|
||||
|
||||
# Delete
|
||||
content_page_service.delete_page(db, page_id)
|
||||
|
||||
return
|
||||
# Delete with ownership check in service layer
|
||||
content_page_service.delete_vendor_page(db, page_id, current_user.vendor_id)
|
||||
|
||||
25
app/api/v1/vendor/customers.py
vendored
25
app/api/v1/vendor/customers.py
vendored
@@ -2,6 +2,8 @@
|
||||
# app/api/v1/vendor/customers.py
|
||||
"""
|
||||
Vendor customer management endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -11,21 +13,27 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from app.exceptions import InvalidTokenException
|
||||
from app.services.vendor_service import vendor_service
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
router = APIRouter(prefix="/customers")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_from_token(current_user: User, db: Session):
|
||||
"""Helper to get vendor from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_vendor_customers(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
search: str | None = Query(None),
|
||||
is_active: bool | None = Query(None),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -38,6 +46,7 @@ def get_vendor_customers(
|
||||
- Support filtering by active status
|
||||
- Return paginated results
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"customers": [],
|
||||
"total": 0,
|
||||
@@ -50,7 +59,6 @@ def get_vendor_customers(
|
||||
@router.get("/{customer_id}")
|
||||
def get_customer_details(
|
||||
customer_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -63,13 +71,13 @@ def get_customer_details(
|
||||
- Include order history
|
||||
- Include total spent, etc.
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Customer details coming in Slice 4"}
|
||||
|
||||
|
||||
@router.get("/{customer_id}/orders")
|
||||
def get_customer_orders(
|
||||
customer_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -81,6 +89,7 @@ def get_customer_orders(
|
||||
- Filter by vendor_id
|
||||
- Return order details
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"orders": [], "message": "Customer orders coming in Slice 5"}
|
||||
|
||||
|
||||
@@ -88,7 +97,6 @@ def get_customer_orders(
|
||||
def update_customer(
|
||||
customer_id: int,
|
||||
customer_data: dict,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -100,13 +108,13 @@ def update_customer(
|
||||
- Verify customer belongs to vendor
|
||||
- Update customer preferences
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Customer update coming in Slice 4"}
|
||||
|
||||
|
||||
@router.put("/{customer_id}/status")
|
||||
def toggle_customer_status(
|
||||
customer_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -118,13 +126,13 @@ def toggle_customer_status(
|
||||
- Verify customer belongs to vendor
|
||||
- Log the change
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Customer status toggle coming in Slice 4"}
|
||||
|
||||
|
||||
@router.get("/{customer_id}/stats")
|
||||
def get_customer_statistics(
|
||||
customer_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -137,6 +145,7 @@ def get_customer_statistics(
|
||||
- Average order value
|
||||
- Last order date
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"total_orders": 0,
|
||||
"total_spent": 0.0,
|
||||
|
||||
18
app/api/v1/vendor/dashboard.py
vendored
18
app/api/v1/vendor/dashboard.py
vendored
@@ -10,7 +10,9 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidTokenException, VendorNotActiveException
|
||||
from app.services.stats_service import stats_service
|
||||
from app.services.vendor_service import vendor_service
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter(prefix="/dashboard")
|
||||
@@ -35,23 +37,17 @@ def get_vendor_dashboard_stats(
|
||||
Vendor is determined from the JWT token (vendor_id claim).
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token (set by get_current_vendor_api)
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Get vendor object to include in response
|
||||
from models.database.vendor import Vendor
|
||||
# Get vendor object (raises VendorNotFoundException if not found)
|
||||
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
|
||||
|
||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor or not vendor.is_active:
|
||||
raise HTTPException(status_code=404, detail="Vendor not found or inactive")
|
||||
if not vendor.is_active:
|
||||
raise VendorNotActiveException(vendor.vendor_code)
|
||||
|
||||
# Get vendor-scoped statistics
|
||||
stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor_id)
|
||||
|
||||
51
app/api/v1/vendor/inventory.py
vendored
51
app/api/v1/vendor/inventory.py
vendored
@@ -1,4 +1,9 @@
|
||||
# app/api/v1/vendor/inventory.py
|
||||
"""
|
||||
Vendor inventory management endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
"""
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
@@ -6,10 +11,9 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidTokenException
|
||||
from app.services.inventory_service import inventory_service
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.inventory import (
|
||||
InventoryAdjust,
|
||||
InventoryCreate,
|
||||
@@ -24,70 +28,77 @@ router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_id_from_token(current_user: User) -> int:
|
||||
"""Helper to get vendor_id from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return current_user.token_vendor_id
|
||||
|
||||
|
||||
@router.post("/inventory/set", response_model=InventoryResponse)
|
||||
def set_inventory(
|
||||
inventory: InventoryCreate,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Set exact inventory quantity (replaces existing)."""
|
||||
return inventory_service.set_inventory(db, vendor.id, inventory)
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return inventory_service.set_inventory(db, vendor_id, inventory)
|
||||
|
||||
|
||||
@router.post("/inventory/adjust", response_model=InventoryResponse)
|
||||
def adjust_inventory(
|
||||
adjustment: InventoryAdjust,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Adjust inventory (positive to add, negative to remove)."""
|
||||
return inventory_service.adjust_inventory(db, vendor.id, adjustment)
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return inventory_service.adjust_inventory(db, vendor_id, adjustment)
|
||||
|
||||
|
||||
@router.post("/inventory/reserve", response_model=InventoryResponse)
|
||||
def reserve_inventory(
|
||||
reservation: InventoryReserve,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Reserve inventory for an order."""
|
||||
return inventory_service.reserve_inventory(db, vendor.id, reservation)
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return inventory_service.reserve_inventory(db, vendor_id, reservation)
|
||||
|
||||
|
||||
@router.post("/inventory/release", response_model=InventoryResponse)
|
||||
def release_reservation(
|
||||
reservation: InventoryReserve,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Release reserved inventory (cancel order)."""
|
||||
return inventory_service.release_reservation(db, vendor.id, reservation)
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return inventory_service.release_reservation(db, vendor_id, reservation)
|
||||
|
||||
|
||||
@router.post("/inventory/fulfill", response_model=InventoryResponse)
|
||||
def fulfill_reservation(
|
||||
reservation: InventoryReserve,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Fulfill reservation (complete order, remove from stock)."""
|
||||
return inventory_service.fulfill_reservation(db, vendor.id, reservation)
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return inventory_service.fulfill_reservation(db, vendor_id, reservation)
|
||||
|
||||
|
||||
@router.get("/inventory/product/{product_id}", response_model=ProductInventorySummary)
|
||||
def get_product_inventory(
|
||||
product_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get inventory summary for a product."""
|
||||
return inventory_service.get_product_inventory(db, vendor.id, product_id)
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return inventory_service.get_product_inventory(db, vendor_id, product_id)
|
||||
|
||||
|
||||
@router.get("/inventory", response_model=InventoryListResponse)
|
||||
@@ -96,13 +107,13 @@ def get_vendor_inventory(
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
location: str | None = Query(None),
|
||||
low_stock: int | None = Query(None, ge=0),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get all inventory for vendor."""
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
inventories = inventory_service.get_vendor_inventory(
|
||||
db, vendor.id, skip, limit, location, low_stock
|
||||
db, vendor_id, skip, limit, location, low_stock
|
||||
)
|
||||
|
||||
# Get total count
|
||||
@@ -117,23 +128,23 @@ def get_vendor_inventory(
|
||||
def update_inventory(
|
||||
inventory_id: int,
|
||||
inventory_update: InventoryUpdate,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update inventory entry."""
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return inventory_service.update_inventory(
|
||||
db, vendor.id, inventory_id, inventory_update
|
||||
db, vendor_id, inventory_id, inventory_update
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/inventory/{inventory_id}")
|
||||
def delete_inventory(
|
||||
inventory_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete inventory entry."""
|
||||
inventory_service.delete_inventory(db, vendor.id, inventory_id)
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
inventory_service.delete_inventory(db, vendor_id, inventory_id)
|
||||
return {"message": "Inventory deleted successfully"}
|
||||
|
||||
37
app/api/v1/vendor/marketplace.py
vendored
37
app/api/v1/vendor/marketplace.py
vendored
@@ -1,7 +1,8 @@
|
||||
# app/api/v1/vendor/marketplace.py # Note: Should be under /vendor/ route
|
||||
# app/api/v1/vendor/marketplace.py
|
||||
"""
|
||||
Marketplace import endpoints for vendors.
|
||||
Vendor context is automatically injected by middleware.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -11,37 +12,45 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidTokenException, UnauthorizedVendorAccessException
|
||||
from app.services.marketplace_import_job_service import marketplace_import_job_service
|
||||
from app.services.vendor_service import vendor_service
|
||||
from app.tasks.background_tasks import process_marketplace_import
|
||||
from middleware.decorators import rate_limit
|
||||
from middleware.vendor_context import require_vendor_context # IMPORTANT
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.marketplace_import_job import (
|
||||
MarketplaceImportJobRequest,
|
||||
MarketplaceImportJobResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(prefix="/marketplace")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_from_token(current_user: User, db: Session):
|
||||
"""Helper to get vendor from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
|
||||
@router.post("/import", response_model=MarketplaceImportJobResponse)
|
||||
@rate_limit(max_requests=10, window_seconds=3600)
|
||||
async def import_products_from_marketplace(
|
||||
request: MarketplaceImportJobRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
vendor: Vendor = Depends(require_vendor_context()), # ADDED: Vendor from middleware
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Import products from marketplace CSV with background processing (Protected)."""
|
||||
vendor = _get_vendor_from_token(current_user, db)
|
||||
|
||||
logger.info(
|
||||
f"Starting marketplace import: {request.marketplace} for vendor {vendor.vendor_code} "
|
||||
f"by user {current_user.username}"
|
||||
)
|
||||
|
||||
# Create import job (vendor comes from middleware)
|
||||
# Create import job (vendor comes from token)
|
||||
import_job = marketplace_import_job_service.create_import_job(
|
||||
db, request, vendor, current_user
|
||||
)
|
||||
@@ -50,9 +59,9 @@ async def import_products_from_marketplace(
|
||||
background_tasks.add_task(
|
||||
process_marketplace_import,
|
||||
import_job.id,
|
||||
request.source_url, # FIXED: was request.url
|
||||
request.source_url,
|
||||
request.marketplace,
|
||||
vendor.id, # Pass vendor_id instead of vendor_code
|
||||
vendor.id,
|
||||
request.batch_size or 1000,
|
||||
)
|
||||
|
||||
@@ -62,7 +71,7 @@ async def import_products_from_marketplace(
|
||||
marketplace=request.marketplace,
|
||||
vendor_id=import_job.vendor_id,
|
||||
vendor_code=vendor.vendor_code,
|
||||
vendor_name=vendor.name, # FIXED: from vendor object
|
||||
vendor_name=vendor.name,
|
||||
source_url=request.source_url,
|
||||
message=f"Marketplace import started from {request.marketplace}. "
|
||||
f"Check status with /import-status/{import_job.id}",
|
||||
@@ -77,17 +86,16 @@ async def import_products_from_marketplace(
|
||||
@router.get("/imports/{job_id}", response_model=MarketplaceImportJobResponse)
|
||||
def get_marketplace_import_status(
|
||||
job_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get status of marketplace import job (Protected)."""
|
||||
vendor = _get_vendor_from_token(current_user, db)
|
||||
|
||||
job = marketplace_import_job_service.get_import_job_by_id(db, job_id, current_user)
|
||||
|
||||
# Verify job belongs to current vendor
|
||||
if job.vendor_id != vendor.id:
|
||||
from app.exceptions import UnauthorizedVendorAccessException
|
||||
|
||||
raise UnauthorizedVendorAccessException(vendor.vendor_code, current_user.id)
|
||||
|
||||
return marketplace_import_job_service.convert_to_response_model(job)
|
||||
@@ -98,11 +106,12 @@ def get_marketplace_import_jobs(
|
||||
marketplace: str | None = Query(None, description="Filter by marketplace"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get marketplace import jobs for current vendor (Protected)."""
|
||||
vendor = _get_vendor_from_token(current_user, db)
|
||||
|
||||
jobs = marketplace_import_job_service.get_import_jobs(
|
||||
db=db,
|
||||
vendor=vendor,
|
||||
|
||||
29
app/api/v1/vendor/media.py
vendored
29
app/api/v1/vendor/media.py
vendored
@@ -2,6 +2,8 @@
|
||||
# app/api/v1/vendor/media.py
|
||||
"""
|
||||
Vendor media and file management endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -11,21 +13,27 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from app.exceptions import InvalidTokenException
|
||||
from app.services.vendor_service import vendor_service
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
router = APIRouter(prefix="/media")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_from_token(current_user: User, db: Session):
|
||||
"""Helper to get vendor from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_media_library(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
media_type: str | None = Query(None, description="image, video, document"),
|
||||
search: str | None = Query(None),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -39,6 +47,7 @@ def get_media_library(
|
||||
- Support pagination
|
||||
- Return file URLs, sizes, metadata
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"media": [],
|
||||
"total": 0,
|
||||
@@ -52,7 +61,6 @@ def get_media_library(
|
||||
async def upload_media(
|
||||
file: UploadFile = File(...),
|
||||
folder: str | None = Query(None, description="products, general, etc."),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -67,6 +75,7 @@ async def upload_media(
|
||||
- Save metadata to database
|
||||
- Return file URL
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"file_url": None,
|
||||
"thumbnail_url": None,
|
||||
@@ -78,7 +87,6 @@ async def upload_media(
|
||||
async def upload_multiple_media(
|
||||
files: list[UploadFile] = File(...),
|
||||
folder: str | None = Query(None),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -91,6 +99,7 @@ async def upload_multiple_media(
|
||||
- Return list of uploaded file URLs
|
||||
- Handle errors gracefully
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"uploaded_files": [],
|
||||
"failed_files": [],
|
||||
@@ -101,7 +110,6 @@ async def upload_multiple_media(
|
||||
@router.get("/{media_id}")
|
||||
def get_media_details(
|
||||
media_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -113,6 +121,7 @@ def get_media_details(
|
||||
- Return file URL
|
||||
- Return usage information (which products use this file)
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Media details coming in Slice 3"}
|
||||
|
||||
|
||||
@@ -120,7 +129,6 @@ def get_media_details(
|
||||
def update_media_metadata(
|
||||
media_id: int,
|
||||
metadata: dict,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -133,13 +141,13 @@ def update_media_metadata(
|
||||
- Update tags/categories
|
||||
- Update description
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Media update coming in Slice 3"}
|
||||
|
||||
|
||||
@router.delete("/{media_id}")
|
||||
def delete_media(
|
||||
media_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -153,13 +161,13 @@ def delete_media(
|
||||
- Delete database record
|
||||
- Return success/error
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Media deletion coming in Slice 3"}
|
||||
|
||||
|
||||
@router.get("/{media_id}/usage")
|
||||
def get_media_usage(
|
||||
media_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -171,6 +179,7 @@ def get_media_usage(
|
||||
- Check other entities using this media
|
||||
- Return list of usage
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"products": [],
|
||||
"other_usage": [],
|
||||
@@ -181,7 +190,6 @@ def get_media_usage(
|
||||
@router.post("/optimize/{media_id}")
|
||||
def optimize_media(
|
||||
media_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -194,4 +202,5 @@ def optimize_media(
|
||||
- Keep original
|
||||
- Update database with new versions
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Media optimization coming in Slice 3"}
|
||||
|
||||
33
app/api/v1/vendor/notifications.py
vendored
33
app/api/v1/vendor/notifications.py
vendored
@@ -2,6 +2,8 @@
|
||||
# app/api/v1/vendor/notifications.py
|
||||
"""
|
||||
Vendor notification management endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -11,20 +13,26 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from app.exceptions import InvalidTokenException
|
||||
from app.services.vendor_service import vendor_service
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
router = APIRouter(prefix="/notifications")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_from_token(current_user: User, db: Session):
|
||||
"""Helper to get vendor from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_notifications(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
unread_only: bool | None = Query(False),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -37,6 +45,7 @@ def get_notifications(
|
||||
- Support pagination
|
||||
- Return notification details
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"notifications": [],
|
||||
"total": 0,
|
||||
@@ -47,7 +56,6 @@ def get_notifications(
|
||||
|
||||
@router.get("/unread-count")
|
||||
def get_unread_count(
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -58,13 +66,13 @@ def get_unread_count(
|
||||
- Count unread notifications for vendor
|
||||
- Used for notification badge
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"unread_count": 0, "message": "Unread count coming in Slice 5"}
|
||||
|
||||
|
||||
@router.put("/{notification_id}/read")
|
||||
def mark_as_read(
|
||||
notification_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -75,12 +83,12 @@ def mark_as_read(
|
||||
- Mark single notification as read
|
||||
- Update read timestamp
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Mark as read coming in Slice 5"}
|
||||
|
||||
|
||||
@router.put("/mark-all-read")
|
||||
def mark_all_as_read(
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -91,13 +99,13 @@ def mark_all_as_read(
|
||||
- Mark all vendor notifications as read
|
||||
- Update timestamps
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Mark all as read coming in Slice 5"}
|
||||
|
||||
|
||||
@router.delete("/{notification_id}")
|
||||
def delete_notification(
|
||||
notification_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -108,12 +116,12 @@ def delete_notification(
|
||||
- Delete single notification
|
||||
- Verify notification belongs to vendor
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Notification deletion coming in Slice 5"}
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
def get_notification_settings(
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -125,6 +133,7 @@ def get_notification_settings(
|
||||
- Get in-app notification settings
|
||||
- Get notification types enabled/disabled
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"email_notifications": True,
|
||||
"in_app_notifications": True,
|
||||
@@ -136,7 +145,6 @@ def get_notification_settings(
|
||||
@router.put("/settings")
|
||||
def update_notification_settings(
|
||||
settings: dict,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -148,12 +156,12 @@ def update_notification_settings(
|
||||
- Update in-app notification settings
|
||||
- Enable/disable specific notification types
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Notification settings update coming in Slice 5"}
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
def get_notification_templates(
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -165,6 +173,7 @@ def get_notification_templates(
|
||||
- Include: order confirmation, shipping notification, etc.
|
||||
- Return template details
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"templates": [], "message": "Notification templates coming in Slice 5"}
|
||||
|
||||
|
||||
@@ -172,7 +181,6 @@ def get_notification_templates(
|
||||
def update_notification_template(
|
||||
template_id: int,
|
||||
template_data: dict,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -185,13 +193,13 @@ def update_notification_template(
|
||||
- Validate template variables
|
||||
- Preview template
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Template update coming in Slice 5"}
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
def send_test_notification(
|
||||
notification_data: dict,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -203,4 +211,5 @@ def send_test_notification(
|
||||
- Use specified template
|
||||
- Send to current user's email
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Test notification coming in Slice 5"}
|
||||
|
||||
29
app/api/v1/vendor/payments.py
vendored
29
app/api/v1/vendor/payments.py
vendored
@@ -2,6 +2,8 @@
|
||||
# app/api/v1/vendor/payments.py
|
||||
"""
|
||||
Vendor payment configuration and processing endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -11,17 +13,23 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from app.exceptions import InvalidTokenException
|
||||
from app.services.vendor_service import vendor_service
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
router = APIRouter(prefix="/payments")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_from_token(current_user: User, db: Session):
|
||||
"""Helper to get vendor from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
def get_payment_configuration(
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -34,6 +42,7 @@ def get_payment_configuration(
|
||||
- Get currency settings
|
||||
- Return masked/secure information only
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"payment_gateway": None,
|
||||
"accepted_methods": [],
|
||||
@@ -46,7 +55,6 @@ def get_payment_configuration(
|
||||
@router.put("/config")
|
||||
def update_payment_configuration(
|
||||
payment_config: dict,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -59,13 +67,13 @@ def update_payment_configuration(
|
||||
- Update accepted payment methods
|
||||
- Validate configuration before saving
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Payment configuration update coming in Slice 5"}
|
||||
|
||||
|
||||
@router.post("/stripe/connect")
|
||||
def connect_stripe_account(
|
||||
stripe_data: dict,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -78,12 +86,12 @@ def connect_stripe_account(
|
||||
- Verify Stripe account is active
|
||||
- Enable payment processing
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Stripe connection coming in Slice 5"}
|
||||
|
||||
|
||||
@router.delete("/stripe/disconnect")
|
||||
def disconnect_stripe_account(
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -95,12 +103,12 @@ def disconnect_stripe_account(
|
||||
- Disable payment processing
|
||||
- Warn about pending payments
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Stripe disconnection coming in Slice 5"}
|
||||
|
||||
|
||||
@router.get("/methods")
|
||||
def get_payment_methods(
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -111,12 +119,12 @@ def get_payment_methods(
|
||||
- Return list of enabled payment methods
|
||||
- Include: credit card, PayPal, bank transfer, etc.
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"methods": [], "message": "Payment methods coming in Slice 5"}
|
||||
|
||||
|
||||
@router.get("/transactions")
|
||||
def get_payment_transactions(
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -129,6 +137,7 @@ def get_payment_transactions(
|
||||
- Include payment details
|
||||
- Support pagination
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"transactions": [],
|
||||
"total": 0,
|
||||
@@ -138,7 +147,6 @@ def get_payment_transactions(
|
||||
|
||||
@router.get("/balance")
|
||||
def get_payment_balance(
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -151,6 +159,7 @@ def get_payment_balance(
|
||||
- Get next payout date
|
||||
- Get payout history
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"available_balance": 0.0,
|
||||
"pending_balance": 0.0,
|
||||
@@ -164,7 +173,6 @@ def get_payment_balance(
|
||||
def refund_payment(
|
||||
payment_id: int,
|
||||
refund_data: dict,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -177,4 +185,5 @@ def refund_payment(
|
||||
- Update order status
|
||||
- Send refund notification to customer
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Payment refund coming in Slice 5"}
|
||||
|
||||
71
app/api/v1/vendor/products.py
vendored
71
app/api/v1/vendor/products.py
vendored
@@ -10,13 +10,16 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidTokenException
|
||||
from app.services.product_service import product_service
|
||||
from models.database.user import User
|
||||
from models.schema.product import (
|
||||
ProductCreate,
|
||||
ProductDeleteResponse,
|
||||
ProductDetailResponse,
|
||||
ProductListResponse,
|
||||
ProductResponse,
|
||||
ProductToggleResponse,
|
||||
ProductUpdate,
|
||||
)
|
||||
|
||||
@@ -42,14 +45,9 @@ def get_vendor_products(
|
||||
|
||||
Vendor is determined from JWT token (vendor_id claim).
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
@@ -77,14 +75,9 @@ def get_product_details(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get detailed product information including inventory."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
@@ -106,14 +99,9 @@ def add_product_to_catalog(
|
||||
|
||||
This publishes a MarketplaceProduct to the vendor's public catalog.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
@@ -137,14 +125,9 @@ def update_product(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update product in vendor catalog."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
@@ -160,21 +143,16 @@ def update_product(
|
||||
return ProductResponse.model_validate(product)
|
||||
|
||||
|
||||
@router.delete("/{product_id}")
|
||||
@router.delete("/{product_id}", response_model=ProductDeleteResponse)
|
||||
def remove_product_from_catalog(
|
||||
product_id: int,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Remove product from vendor catalog."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
@@ -185,7 +163,7 @@ def remove_product_from_catalog(
|
||||
f"for vendor {current_user.token_vendor_code}"
|
||||
)
|
||||
|
||||
return {"message": f"Product {product_id} removed from catalog"}
|
||||
return ProductDeleteResponse(message=f"Product {product_id} removed from catalog")
|
||||
|
||||
|
||||
@router.post("/from-import/{marketplace_product_id}", response_model=ProductResponse)
|
||||
@@ -199,14 +177,9 @@ def publish_from_marketplace(
|
||||
|
||||
Shortcut endpoint for publishing directly from marketplace import.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
@@ -226,21 +199,16 @@ def publish_from_marketplace(
|
||||
return ProductResponse.model_validate(product)
|
||||
|
||||
|
||||
@router.put("/{product_id}/toggle-active")
|
||||
@router.put("/{product_id}/toggle-active", response_model=ProductToggleResponse)
|
||||
def toggle_product_active(
|
||||
product_id: int,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Toggle product active status."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
@@ -253,24 +221,19 @@ def toggle_product_active(
|
||||
status = "activated" if product.is_active else "deactivated"
|
||||
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
|
||||
|
||||
return {"message": f"Product {status}", "is_active": product.is_active}
|
||||
return ProductToggleResponse(message=f"Product {status}", is_active=product.is_active)
|
||||
|
||||
|
||||
@router.put("/{product_id}/toggle-featured")
|
||||
@router.put("/{product_id}/toggle-featured", response_model=ProductToggleResponse)
|
||||
def toggle_product_featured(
|
||||
product_id: int,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Toggle product featured status."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
@@ -283,4 +246,4 @@ def toggle_product_featured(
|
||||
status = "featured" if product.is_featured else "unfeatured"
|
||||
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
|
||||
|
||||
return {"message": f"Product {status}", "is_featured": product.is_featured}
|
||||
return ProductToggleResponse(message=f"Product {status}", is_featured=product.is_featured)
|
||||
|
||||
21
app/api/v1/vendor/profile.py
vendored
21
app/api/v1/vendor/profile.py
vendored
@@ -1,45 +1,54 @@
|
||||
# app/api/v1/vendor/profile.py
|
||||
"""
|
||||
Vendor profile management endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
|
||||
from app.services.vendor_service import vendor_service
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.vendor import VendorResponse, VendorUpdate
|
||||
|
||||
router = APIRouter(prefix="/profile")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_from_token(current_user: User, db: Session):
|
||||
"""Helper to get vendor from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
|
||||
@router.get("", response_model=VendorResponse)
|
||||
def get_vendor_profile(
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get current vendor profile information."""
|
||||
vendor = _get_vendor_from_token(current_user, db)
|
||||
return vendor
|
||||
|
||||
|
||||
@router.put("", response_model=VendorResponse)
|
||||
def update_vendor_profile(
|
||||
vendor_update: VendorUpdate,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update vendor profile information."""
|
||||
vendor = _get_vendor_from_token(current_user, db)
|
||||
|
||||
# Verify user has permission to update vendor
|
||||
if not vendor_service.can_update_vendor(vendor, current_user):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
raise InsufficientPermissionsException(required_permission="vendor:profile:update")
|
||||
|
||||
return vendor_service.update_vendor(db, vendor.id, vendor_update)
|
||||
|
||||
25
app/api/v1/vendor/settings.py
vendored
25
app/api/v1/vendor/settings.py
vendored
@@ -1,19 +1,20 @@
|
||||
# app/api/v1/vendor/settings.py
|
||||
"""
|
||||
Vendor settings and configuration endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
|
||||
from app.services.vendor_service import vendor_service
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
router = APIRouter(prefix="/settings")
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -21,11 +22,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@router.get("")
|
||||
def get_vendor_settings(
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get vendor settings and configuration."""
|
||||
# Get vendor ID from JWT token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
return {
|
||||
"vendor_code": vendor.vendor_code,
|
||||
"subdomain": vendor.subdomain,
|
||||
@@ -46,14 +52,21 @@ def get_vendor_settings(
|
||||
@router.put("/marketplace")
|
||||
def update_marketplace_settings(
|
||||
marketplace_config: dict,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update marketplace integration settings."""
|
||||
# Get vendor ID from JWT token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
# Verify permissions
|
||||
if not vendor_service.can_update_vendor(vendor, current_user):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
raise InsufficientPermissionsException(
|
||||
required_permission="vendor:settings:update"
|
||||
)
|
||||
|
||||
# Update Letzshop URLs
|
||||
if "letzshop_csv_url_fr" in marketplace_config:
|
||||
|
||||
@@ -14,7 +14,9 @@ from .admin import (
|
||||
CannotModifySelfException,
|
||||
ConfirmationRequiredException,
|
||||
InvalidAdminActionException,
|
||||
UserCannotBeDeletedException,
|
||||
UserNotFoundException,
|
||||
UserRoleChangeException,
|
||||
UserStatusChangeException,
|
||||
VendorVerificationException,
|
||||
)
|
||||
@@ -44,6 +46,17 @@ from .base import (
|
||||
WizamartException,
|
||||
)
|
||||
|
||||
# Code quality exceptions
|
||||
from .code_quality import (
|
||||
InvalidViolationStatusException,
|
||||
ScanExecutionException,
|
||||
ScanNotFoundException,
|
||||
ScanParseException,
|
||||
ScanTimeoutException,
|
||||
ViolationNotFoundException,
|
||||
ViolationOperationException,
|
||||
)
|
||||
|
||||
# Cart exceptions
|
||||
from .cart import (
|
||||
CartItemNotFoundException,
|
||||
@@ -155,13 +168,16 @@ from .team import (
|
||||
|
||||
# Vendor exceptions
|
||||
from .vendor import (
|
||||
InsufficientVendorPermissionsException,
|
||||
InvalidVendorDataException,
|
||||
MaxVendorsReachedException,
|
||||
UnauthorizedVendorAccessException,
|
||||
VendorAccessDeniedException,
|
||||
VendorAlreadyExistsException,
|
||||
VendorNotActiveException,
|
||||
VendorNotFoundException,
|
||||
VendorNotVerifiedException,
|
||||
VendorOwnerOnlyException,
|
||||
VendorValidationException,
|
||||
)
|
||||
|
||||
@@ -245,13 +261,16 @@ __all__ = [
|
||||
"InvalidQuantityException",
|
||||
"LocationNotFoundException",
|
||||
# Vendor exceptions
|
||||
"VendorNotFoundException",
|
||||
"VendorAlreadyExistsException",
|
||||
"VendorNotActiveException",
|
||||
"VendorNotVerifiedException",
|
||||
"UnauthorizedVendorAccessException",
|
||||
"InsufficientVendorPermissionsException",
|
||||
"InvalidVendorDataException",
|
||||
"MaxVendorsReachedException",
|
||||
"UnauthorizedVendorAccessException",
|
||||
"VendorAccessDeniedException",
|
||||
"VendorAlreadyExistsException",
|
||||
"VendorNotActiveException",
|
||||
"VendorNotFoundException",
|
||||
"VendorNotVerifiedException",
|
||||
"VendorOwnerOnlyException",
|
||||
"VendorValidationException",
|
||||
# Vendor Domain
|
||||
"VendorDomainNotFoundException",
|
||||
@@ -334,4 +353,12 @@ __all__ = [
|
||||
"InvalidAdminActionException",
|
||||
"BulkOperationException",
|
||||
"ConfirmationRequiredException",
|
||||
# Code quality exceptions
|
||||
"ViolationNotFoundException",
|
||||
"ScanNotFoundException",
|
||||
"ScanExecutionException",
|
||||
"ScanTimeoutException",
|
||||
"ScanParseException",
|
||||
"ViolationOperationException",
|
||||
"InvalidViolationStatusException",
|
||||
]
|
||||
|
||||
@@ -236,3 +236,37 @@ class VendorVerificationException(BusinessLogicException):
|
||||
error_code="VENDOR_VERIFICATION_FAILED",
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class UserCannotBeDeletedException(BusinessLogicException):
|
||||
"""Raised when a user cannot be deleted due to ownership constraints."""
|
||||
|
||||
def __init__(self, user_id: int, reason: str, owned_count: int = 0):
|
||||
details = {
|
||||
"user_id": user_id,
|
||||
"reason": reason,
|
||||
}
|
||||
if owned_count > 0:
|
||||
details["owned_companies_count"] = owned_count
|
||||
|
||||
super().__init__(
|
||||
message=f"Cannot delete user {user_id}: {reason}",
|
||||
error_code="USER_CANNOT_BE_DELETED",
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class UserRoleChangeException(BusinessLogicException):
|
||||
"""Raised when user role cannot be changed."""
|
||||
|
||||
def __init__(self, user_id: int, current_role: str, target_role: str, reason: str):
|
||||
super().__init__(
|
||||
message=f"Cannot change user {user_id} role from {current_role} to {target_role}: {reason}",
|
||||
error_code="USER_ROLE_CHANGE_FAILED",
|
||||
details={
|
||||
"user_id": user_id,
|
||||
"current_role": current_role,
|
||||
"target_role": target_role,
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
|
||||
95
app/exceptions/code_quality.py
Normal file
95
app/exceptions/code_quality.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# app/exceptions/code_quality.py
|
||||
"""
|
||||
Code Quality Domain Exceptions
|
||||
|
||||
These exceptions are raised by the code quality service layer
|
||||
and converted to HTTP responses by the global exception handler.
|
||||
"""
|
||||
|
||||
from app.exceptions.base import (
|
||||
BusinessLogicException,
|
||||
ExternalServiceException,
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
|
||||
|
||||
class ViolationNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when a violation is not found."""
|
||||
|
||||
def __init__(self, violation_id: int):
|
||||
super().__init__(
|
||||
resource_type="Violation",
|
||||
identifier=str(violation_id),
|
||||
error_code="VIOLATION_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class ScanNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when a scan is not found."""
|
||||
|
||||
def __init__(self, scan_id: int):
|
||||
super().__init__(
|
||||
resource_type="Scan",
|
||||
identifier=str(scan_id),
|
||||
error_code="SCAN_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class ScanExecutionException(ExternalServiceException):
|
||||
"""Raised when architecture scan execution fails."""
|
||||
|
||||
def __init__(self, reason: str):
|
||||
super().__init__(
|
||||
service_name="ArchitectureValidator",
|
||||
message=f"Scan execution failed: {reason}",
|
||||
error_code="SCAN_EXECUTION_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class ScanTimeoutException(ExternalServiceException):
|
||||
"""Raised when architecture scan times out."""
|
||||
|
||||
def __init__(self, timeout_seconds: int = 300):
|
||||
super().__init__(
|
||||
service_name="ArchitectureValidator",
|
||||
message=f"Scan timed out after {timeout_seconds} seconds",
|
||||
error_code="SCAN_TIMEOUT",
|
||||
)
|
||||
|
||||
|
||||
class ScanParseException(BusinessLogicException):
|
||||
"""Raised when scan results cannot be parsed."""
|
||||
|
||||
def __init__(self, reason: str):
|
||||
super().__init__(
|
||||
message=f"Failed to parse scan results: {reason}",
|
||||
error_code="SCAN_PARSE_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class ViolationOperationException(BusinessLogicException):
|
||||
"""Raised when a violation operation fails."""
|
||||
|
||||
def __init__(self, operation: str, violation_id: int, reason: str):
|
||||
super().__init__(
|
||||
message=f"Failed to {operation} violation {violation_id}: {reason}",
|
||||
error_code="VIOLATION_OPERATION_FAILED",
|
||||
details={
|
||||
"operation": operation,
|
||||
"violation_id": violation_id,
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class InvalidViolationStatusException(ValidationException):
|
||||
"""Raised when a violation status transition is invalid."""
|
||||
|
||||
def __init__(self, violation_id: int, current_status: str, target_status: str):
|
||||
super().__init__(
|
||||
message=f"Cannot change violation {violation_id} from '{current_status}' to '{target_status}'",
|
||||
field="status",
|
||||
value=target_status,
|
||||
)
|
||||
self.error_code = "INVALID_VIOLATION_STATUS"
|
||||
82
app/exceptions/content_page.py
Normal file
82
app/exceptions/content_page.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# app/exceptions/content_page.py
|
||||
"""
|
||||
Content Page Domain Exceptions
|
||||
|
||||
These exceptions are raised by the content page service layer
|
||||
and converted to HTTP responses by the global exception handler.
|
||||
"""
|
||||
|
||||
from app.exceptions.base import (
|
||||
AuthorizationException,
|
||||
BusinessLogicException,
|
||||
ConflictException,
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
|
||||
|
||||
class ContentPageNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when a content page is not found."""
|
||||
|
||||
def __init__(self, identifier: str | int | None = None):
|
||||
if identifier:
|
||||
message = f"Content page not found: {identifier}"
|
||||
else:
|
||||
message = "Content page not found"
|
||||
super().__init__(message=message, resource_type="content_page")
|
||||
|
||||
|
||||
class ContentPageAlreadyExistsException(ConflictException):
|
||||
"""Raised when a content page with the same slug already exists."""
|
||||
|
||||
def __init__(self, slug: str, vendor_id: int | None = None):
|
||||
if vendor_id:
|
||||
message = f"Content page with slug '{slug}' already exists for this vendor"
|
||||
else:
|
||||
message = f"Platform content page with slug '{slug}' already exists"
|
||||
super().__init__(message=message)
|
||||
|
||||
|
||||
class ContentPageSlugReservedException(ValidationException):
|
||||
"""Raised when trying to use a reserved slug."""
|
||||
|
||||
def __init__(self, slug: str):
|
||||
super().__init__(
|
||||
message=f"Content page slug '{slug}' is reserved",
|
||||
field="slug",
|
||||
value=slug,
|
||||
)
|
||||
|
||||
|
||||
class ContentPageNotPublishedException(BusinessLogicException):
|
||||
"""Raised when trying to access an unpublished content page."""
|
||||
|
||||
def __init__(self, slug: str):
|
||||
super().__init__(message=f"Content page '{slug}' is not published")
|
||||
|
||||
|
||||
class UnauthorizedContentPageAccessException(AuthorizationException):
|
||||
"""Raised when a user tries to access/modify a content page they don't own."""
|
||||
|
||||
def __init__(self, action: str = "access"):
|
||||
super().__init__(
|
||||
message=f"Cannot {action} content pages from other vendors",
|
||||
required_permission=f"content_page:{action}",
|
||||
)
|
||||
|
||||
|
||||
class VendorNotAssociatedException(AuthorizationException):
|
||||
"""Raised when a user is not associated with a vendor."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
message="User is not associated with a vendor",
|
||||
required_permission="vendor:member",
|
||||
)
|
||||
|
||||
|
||||
class ContentPageValidationException(ValidationException):
|
||||
"""Raised when content page data validation fails."""
|
||||
|
||||
def __init__(self, field: str, message: str, value: str | None = None):
|
||||
super().__init__(message=message, field=field, value=value)
|
||||
@@ -148,3 +148,43 @@ class MaxVendorsReachedException(BusinessLogicException):
|
||||
error_code="MAX_VENDORS_REACHED",
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class VendorAccessDeniedException(AuthorizationException):
|
||||
"""Raised when no vendor context is available for an authenticated endpoint."""
|
||||
|
||||
def __init__(self, message: str = "No vendor context available"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="VENDOR_ACCESS_DENIED",
|
||||
)
|
||||
|
||||
|
||||
class VendorOwnerOnlyException(AuthorizationException):
|
||||
"""Raised when operation requires vendor owner role."""
|
||||
|
||||
def __init__(self, operation: str, vendor_code: str | None = None):
|
||||
details = {"operation": operation}
|
||||
if vendor_code:
|
||||
details["vendor_code"] = vendor_code
|
||||
|
||||
super().__init__(
|
||||
message=f"Operation '{operation}' requires vendor owner role",
|
||||
error_code="VENDOR_OWNER_ONLY",
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class InsufficientVendorPermissionsException(AuthorizationException):
|
||||
"""Raised when user lacks required vendor permission."""
|
||||
|
||||
def __init__(self, required_permission: str, vendor_code: str | None = None):
|
||||
details = {"required_permission": required_permission}
|
||||
if vendor_code:
|
||||
details["vendor_code"] = vendor_code
|
||||
|
||||
super().__init__(
|
||||
message=f"Permission required: {required_permission}",
|
||||
error_code="INSUFFICIENT_VENDOR_PERMISSIONS",
|
||||
details=details,
|
||||
)
|
||||
|
||||
@@ -21,13 +21,17 @@ from sqlalchemy.orm import Session, joinedload
|
||||
from app.exceptions import (
|
||||
AdminOperationException,
|
||||
CannotModifySelfException,
|
||||
UserCannotBeDeletedException,
|
||||
UserNotFoundException,
|
||||
UserRoleChangeException,
|
||||
UserStatusChangeException,
|
||||
ValidationException,
|
||||
VendorAlreadyExistsException,
|
||||
VendorNotFoundException,
|
||||
VendorVerificationException,
|
||||
)
|
||||
from app.exceptions.auth import UserAlreadyExistsException
|
||||
from middleware.auth import AuthManager
|
||||
from models.database.company import Company
|
||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
from models.database.user import User
|
||||
@@ -97,6 +101,244 @@ class AdminService:
|
||||
reason="Database update failed",
|
||||
)
|
||||
|
||||
def list_users(
|
||||
self,
|
||||
db: Session,
|
||||
page: int = 1,
|
||||
per_page: int = 10,
|
||||
search: str | None = None,
|
||||
role: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> tuple[list[User], int, int]:
|
||||
"""
|
||||
Get paginated list of users with filtering.
|
||||
|
||||
Returns:
|
||||
Tuple of (users, total_count, total_pages)
|
||||
"""
|
||||
import math
|
||||
|
||||
query = db.query(User)
|
||||
|
||||
# Apply filters
|
||||
if search:
|
||||
search_term = f"%{search.lower()}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
User.username.ilike(search_term),
|
||||
User.email.ilike(search_term),
|
||||
User.first_name.ilike(search_term),
|
||||
User.last_name.ilike(search_term),
|
||||
)
|
||||
)
|
||||
|
||||
if role:
|
||||
query = query.filter(User.role == role)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(User.is_active == is_active)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
# Apply pagination
|
||||
skip = (page - 1) * per_page
|
||||
users = query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all()
|
||||
|
||||
return users, total, pages
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
db: Session,
|
||||
email: str,
|
||||
username: str,
|
||||
password: str,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
role: str = "customer",
|
||||
current_admin_id: int | None = None,
|
||||
) -> User:
|
||||
"""
|
||||
Create a new user.
|
||||
|
||||
Raises:
|
||||
UserAlreadyExistsException: If email or username already exists
|
||||
"""
|
||||
# Check if email exists
|
||||
if db.query(User).filter(User.email == email).first():
|
||||
raise UserAlreadyExistsException("Email already registered", field="email")
|
||||
|
||||
# Check if username exists
|
||||
if db.query(User).filter(User.username == username).first():
|
||||
raise UserAlreadyExistsException("Username already taken", field="username")
|
||||
|
||||
# Create user
|
||||
auth_manager = AuthManager()
|
||||
user = User(
|
||||
email=email,
|
||||
username=username,
|
||||
hashed_password=auth_manager.hash_password(password),
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
role=role,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"Admin {current_admin_id} created user {user.username}")
|
||||
return user
|
||||
|
||||
def get_user_details(self, db: Session, user_id: int) -> User:
|
||||
"""
|
||||
Get user with relationships loaded.
|
||||
|
||||
Raises:
|
||||
UserNotFoundException: If user not found
|
||||
"""
|
||||
user = (
|
||||
db.query(User)
|
||||
.options(joinedload(User.owned_companies), joinedload(User.vendor_memberships))
|
||||
.filter(User.id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise UserNotFoundException(str(user_id))
|
||||
|
||||
return user
|
||||
|
||||
def update_user(
|
||||
self,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
current_admin_id: int,
|
||||
email: str | None = None,
|
||||
username: str | None = None,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
role: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> User:
|
||||
"""
|
||||
Update user information.
|
||||
|
||||
Raises:
|
||||
UserNotFoundException: If user not found
|
||||
UserAlreadyExistsException: If email/username already taken
|
||||
UserRoleChangeException: If trying to change own admin role
|
||||
"""
|
||||
user = self._get_user_by_id_or_raise(db, user_id)
|
||||
|
||||
# Prevent changing own admin status
|
||||
if user.id == current_admin_id and role and role != "admin":
|
||||
raise UserRoleChangeException(
|
||||
user_id=user_id,
|
||||
current_role=user.role,
|
||||
target_role=role,
|
||||
reason="Cannot change your own admin role",
|
||||
)
|
||||
|
||||
# Check email uniqueness if changing
|
||||
if email and email != user.email:
|
||||
if db.query(User).filter(User.email == email).first():
|
||||
raise UserAlreadyExistsException("Email already registered", field="email")
|
||||
|
||||
# Check username uniqueness if changing
|
||||
if username and username != user.username:
|
||||
if db.query(User).filter(User.username == username).first():
|
||||
raise UserAlreadyExistsException("Username already taken", field="username")
|
||||
|
||||
# Update fields
|
||||
if email is not None:
|
||||
user.email = email
|
||||
if username is not None:
|
||||
user.username = username
|
||||
if first_name is not None:
|
||||
user.first_name = first_name
|
||||
if last_name is not None:
|
||||
user.last_name = last_name
|
||||
if role is not None:
|
||||
user.role = role
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
|
||||
user.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"Admin {current_admin_id} updated user {user.username}")
|
||||
return user
|
||||
|
||||
def delete_user(self, db: Session, user_id: int, current_admin_id: int) -> str:
|
||||
"""
|
||||
Delete a user.
|
||||
|
||||
Raises:
|
||||
UserNotFoundException: If user not found
|
||||
CannotModifySelfException: If trying to delete yourself
|
||||
UserCannotBeDeletedException: If user owns companies
|
||||
"""
|
||||
user = (
|
||||
db.query(User)
|
||||
.options(joinedload(User.owned_companies))
|
||||
.filter(User.id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise UserNotFoundException(str(user_id))
|
||||
|
||||
# Prevent deleting yourself
|
||||
if user.id == current_admin_id:
|
||||
raise CannotModifySelfException(user_id, "delete account")
|
||||
|
||||
# Prevent deleting users who own companies
|
||||
if user.owned_companies:
|
||||
raise UserCannotBeDeletedException(
|
||||
user_id=user_id,
|
||||
reason=f"User owns {len(user.owned_companies)} company(ies). Transfer ownership first.",
|
||||
owned_count=len(user.owned_companies),
|
||||
)
|
||||
|
||||
username = user.username
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Admin {current_admin_id} deleted user {username}")
|
||||
return f"User {username} deleted successfully"
|
||||
|
||||
def search_users(
|
||||
self,
|
||||
db: Session,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Search users by username or email.
|
||||
|
||||
Used for autocomplete in ownership transfer.
|
||||
"""
|
||||
search_term = f"%{query.lower()}%"
|
||||
users = (
|
||||
db.query(User)
|
||||
.filter(or_(User.username.ilike(search_term), User.email.ilike(search_term)))
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"is_active": user.is_active,
|
||||
}
|
||||
for user in users
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# VENDOR MANAGEMENT
|
||||
# ============================================================================
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.exceptions import (
|
||||
)
|
||||
from middleware.auth import AuthManager
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor, VendorUser
|
||||
from models.schema.auth import UserLogin, UserRegister
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -214,6 +215,84 @@ class AuthService:
|
||||
logger.error(f"Error creating access token with data: {str(e)}")
|
||||
raise ValidationException("Failed to create access token")
|
||||
|
||||
def get_vendor_by_code(self, db: Session, vendor_code: str) -> Vendor | None:
|
||||
"""
|
||||
Get active vendor by vendor code.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_code: Vendor code to look up
|
||||
|
||||
Returns:
|
||||
Vendor if found and active, None otherwise
|
||||
"""
|
||||
return (
|
||||
db.query(Vendor)
|
||||
.filter(Vendor.vendor_code == vendor_code.upper(), Vendor.is_active == True)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_user_vendor_role(
|
||||
self, db: Session, user: User, vendor: Vendor
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Check if user has access to vendor and return their role.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user: User to check
|
||||
vendor: Vendor to check access for
|
||||
|
||||
Returns:
|
||||
Tuple of (has_access: bool, role_name: str | None)
|
||||
"""
|
||||
# Check if user is vendor owner (via company ownership)
|
||||
if vendor.company and vendor.company.owner_user_id == user.id:
|
||||
return True, "Owner"
|
||||
|
||||
# Check if user is team member
|
||||
vendor_user = (
|
||||
db.query(VendorUser)
|
||||
.filter(
|
||||
VendorUser.user_id == user.id,
|
||||
VendorUser.vendor_id == vendor.id,
|
||||
VendorUser.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if vendor_user:
|
||||
return True, vendor_user.role.name
|
||||
|
||||
return False, None
|
||||
|
||||
def find_user_vendor(self, user: User) -> tuple[Vendor | None, str | None]:
|
||||
"""
|
||||
Find which vendor a user belongs to when no vendor context is provided.
|
||||
|
||||
Checks owned companies first, then vendor memberships.
|
||||
|
||||
Args:
|
||||
user: User to find vendor for
|
||||
|
||||
Returns:
|
||||
Tuple of (vendor: Vendor | None, role: str | None)
|
||||
"""
|
||||
# Check owned vendors first (via company ownership)
|
||||
for company in user.owned_companies:
|
||||
if company.vendors:
|
||||
return company.vendors[0], "Owner"
|
||||
|
||||
# Check vendor memberships
|
||||
if user.vendor_memberships:
|
||||
active_membership = next(
|
||||
(vm for vm in user.vendor_memberships if vm.is_active), None
|
||||
)
|
||||
if active_membership:
|
||||
return active_membership.vendor, active_membership.role.name
|
||||
|
||||
return None, None
|
||||
|
||||
# Private helper methods
|
||||
def _email_exists(self, db: Session, email: str) -> bool:
|
||||
"""Check if email already exists."""
|
||||
|
||||
@@ -11,6 +11,11 @@ from datetime import datetime
|
||||
from sqlalchemy import desc, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
ScanParseException,
|
||||
ScanTimeoutException,
|
||||
ViolationNotFoundException,
|
||||
)
|
||||
from models.database.architecture_scan import (
|
||||
ArchitectureScan,
|
||||
ArchitectureViolation,
|
||||
@@ -54,7 +59,7 @@ class CodeQualityService:
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Architecture scan timed out after 5 minutes")
|
||||
raise Exception("Scan timed out")
|
||||
raise ScanTimeoutException(timeout_seconds=300)
|
||||
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
@@ -77,7 +82,7 @@ class CodeQualityService:
|
||||
logger.error(f"Failed to parse validator output: {e}")
|
||||
logger.error(f"Stdout: {result.stdout}")
|
||||
logger.error(f"Stderr: {result.stderr}")
|
||||
raise Exception(f"Failed to parse scan results: {e}")
|
||||
raise ScanParseException(reason=str(e))
|
||||
|
||||
# Create scan record
|
||||
scan = ArchitectureScan(
|
||||
@@ -285,7 +290,7 @@ class CodeQualityService:
|
||||
"""
|
||||
violation = self.get_violation_by_id(db, violation_id)
|
||||
if not violation:
|
||||
raise ValueError(f"Violation {violation_id} not found")
|
||||
raise ViolationNotFoundException(violation_id)
|
||||
|
||||
violation.status = "resolved"
|
||||
violation.resolved_at = datetime.now()
|
||||
@@ -313,7 +318,7 @@ class CodeQualityService:
|
||||
"""
|
||||
violation = self.get_violation_by_id(db, violation_id)
|
||||
if not violation:
|
||||
raise ValueError(f"Violation {violation_id} not found")
|
||||
raise ViolationNotFoundException(violation_id)
|
||||
|
||||
violation.status = "ignored"
|
||||
violation.resolved_at = datetime.now()
|
||||
|
||||
@@ -22,6 +22,10 @@ from datetime import UTC, datetime
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions.content_page import (
|
||||
ContentPageNotFoundException,
|
||||
UnauthorizedContentPageAccessException,
|
||||
)
|
||||
from models.database.content_page import ContentPage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -319,6 +323,214 @@ class ContentPageService:
|
||||
"""Get content page by ID."""
|
||||
return db.query(ContentPage).filter(ContentPage.id == page_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_page_by_id_or_raise(db: Session, page_id: int) -> ContentPage:
|
||||
"""
|
||||
Get content page by ID or raise ContentPageNotFoundException.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
page_id: Page ID
|
||||
|
||||
Returns:
|
||||
ContentPage
|
||||
|
||||
Raises:
|
||||
ContentPageNotFoundException: If page not found
|
||||
"""
|
||||
page = db.query(ContentPage).filter(ContentPage.id == page_id).first()
|
||||
if not page:
|
||||
raise ContentPageNotFoundException(identifier=page_id)
|
||||
return page
|
||||
|
||||
@staticmethod
|
||||
def get_page_for_vendor_or_raise(
|
||||
db: Session,
|
||||
slug: str,
|
||||
vendor_id: int | None = None,
|
||||
include_unpublished: bool = False,
|
||||
) -> ContentPage:
|
||||
"""
|
||||
Get content page for a vendor with fallback to platform default.
|
||||
Raises ContentPageNotFoundException if not found.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
slug: Page slug
|
||||
vendor_id: Vendor ID
|
||||
include_unpublished: Include draft pages
|
||||
|
||||
Returns:
|
||||
ContentPage
|
||||
|
||||
Raises:
|
||||
ContentPageNotFoundException: If page not found
|
||||
"""
|
||||
page = ContentPageService.get_page_for_vendor(
|
||||
db, slug=slug, vendor_id=vendor_id, include_unpublished=include_unpublished
|
||||
)
|
||||
if not page:
|
||||
raise ContentPageNotFoundException(identifier=slug)
|
||||
return page
|
||||
|
||||
@staticmethod
|
||||
def update_page_or_raise(
|
||||
db: Session,
|
||||
page_id: int,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
content_format: str | None = None,
|
||||
template: str | None = None,
|
||||
meta_description: str | None = None,
|
||||
meta_keywords: str | None = None,
|
||||
is_published: bool | None = None,
|
||||
show_in_footer: bool | None = None,
|
||||
show_in_header: bool | None = None,
|
||||
display_order: int | None = None,
|
||||
updated_by: int | None = None,
|
||||
) -> ContentPage:
|
||||
"""
|
||||
Update an existing content page or raise exception.
|
||||
|
||||
Raises:
|
||||
ContentPageNotFoundException: If page not found
|
||||
"""
|
||||
page = ContentPageService.update_page(
|
||||
db,
|
||||
page_id=page_id,
|
||||
title=title,
|
||||
content=content,
|
||||
content_format=content_format,
|
||||
template=template,
|
||||
meta_description=meta_description,
|
||||
meta_keywords=meta_keywords,
|
||||
is_published=is_published,
|
||||
show_in_footer=show_in_footer,
|
||||
show_in_header=show_in_header,
|
||||
display_order=display_order,
|
||||
updated_by=updated_by,
|
||||
)
|
||||
if not page:
|
||||
raise ContentPageNotFoundException(identifier=page_id)
|
||||
return page
|
||||
|
||||
@staticmethod
|
||||
def delete_page_or_raise(db: Session, page_id: int) -> None:
|
||||
"""
|
||||
Delete a content page or raise exception.
|
||||
|
||||
Raises:
|
||||
ContentPageNotFoundException: If page not found
|
||||
"""
|
||||
success = ContentPageService.delete_page(db, page_id)
|
||||
if not success:
|
||||
raise ContentPageNotFoundException(identifier=page_id)
|
||||
|
||||
@staticmethod
|
||||
def update_vendor_page(
|
||||
db: Session,
|
||||
page_id: int,
|
||||
vendor_id: int,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
content_format: str | None = None,
|
||||
meta_description: str | None = None,
|
||||
meta_keywords: str | None = None,
|
||||
is_published: bool | None = None,
|
||||
show_in_footer: bool | None = None,
|
||||
show_in_header: bool | None = None,
|
||||
display_order: int | None = None,
|
||||
updated_by: int | None = None,
|
||||
) -> ContentPage:
|
||||
"""
|
||||
Update a vendor-specific content page with ownership check.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
page_id: Page ID
|
||||
vendor_id: Vendor ID (for ownership verification)
|
||||
... other fields
|
||||
|
||||
Returns:
|
||||
Updated ContentPage
|
||||
|
||||
Raises:
|
||||
ContentPageNotFoundException: If page not found
|
||||
UnauthorizedContentPageAccessException: If page doesn't belong to vendor
|
||||
"""
|
||||
page = ContentPageService.get_page_by_id_or_raise(db, page_id)
|
||||
|
||||
if page.vendor_id != vendor_id:
|
||||
raise UnauthorizedContentPageAccessException(action="edit")
|
||||
|
||||
return ContentPageService.update_page_or_raise(
|
||||
db,
|
||||
page_id=page_id,
|
||||
title=title,
|
||||
content=content,
|
||||
content_format=content_format,
|
||||
meta_description=meta_description,
|
||||
meta_keywords=meta_keywords,
|
||||
is_published=is_published,
|
||||
show_in_footer=show_in_footer,
|
||||
show_in_header=show_in_header,
|
||||
display_order=display_order,
|
||||
updated_by=updated_by,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_vendor_page(db: Session, page_id: int, vendor_id: int) -> None:
|
||||
"""
|
||||
Delete a vendor-specific content page with ownership check.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
page_id: Page ID
|
||||
vendor_id: Vendor ID (for ownership verification)
|
||||
|
||||
Raises:
|
||||
ContentPageNotFoundException: If page not found
|
||||
UnauthorizedContentPageAccessException: If page doesn't belong to vendor
|
||||
"""
|
||||
page = ContentPageService.get_page_by_id_or_raise(db, page_id)
|
||||
|
||||
if page.vendor_id != vendor_id:
|
||||
raise UnauthorizedContentPageAccessException(action="delete")
|
||||
|
||||
ContentPageService.delete_page_or_raise(db, page_id)
|
||||
|
||||
@staticmethod
|
||||
def list_all_pages(
|
||||
db: Session,
|
||||
vendor_id: int | None = None,
|
||||
include_unpublished: bool = False,
|
||||
) -> list[ContentPage]:
|
||||
"""
|
||||
List all content pages (platform defaults and vendor overrides).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Optional filter by vendor ID
|
||||
include_unpublished: Include draft pages
|
||||
|
||||
Returns:
|
||||
List of ContentPage objects
|
||||
"""
|
||||
filters = []
|
||||
|
||||
if vendor_id:
|
||||
filters.append(ContentPage.vendor_id == vendor_id)
|
||||
|
||||
if not include_unpublished:
|
||||
filters.append(ContentPage.is_published == True)
|
||||
|
||||
return (
|
||||
db.query(ContentPage)
|
||||
.filter(and_(*filters) if filters else True)
|
||||
.order_by(ContentPage.vendor_id, ContentPage.display_order, ContentPage.title)
|
||||
.all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def list_all_vendor_pages(
|
||||
db: Session, vendor_id: int, include_unpublished: bool = False
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# app/templates/admin/base.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
|
||||
<html :class="{ 'dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -10,11 +10,7 @@
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Tailwind CSS with CDN fallback -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
|
||||
<!-- Admin-specific Tailwind customizations -->
|
||||
<!-- Tailwind CSS v4 (built locally via standalone CLI) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
|
||||
|
||||
<!-- Alpine Cloak -->
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# app/templates/admin/login.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="adminLogin()" lang="en">
|
||||
<html :class="{ 'dark': dark }" x-data="adminLogin()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<DOCTYPE html>
|
||||
{# standalone - Minimal monitoring page without admin chrome #}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
|
||||
@@ -1,519 +1,253 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Auth Flow Testing - Admin Panel</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
{# app/templates/admin/test-auth-flow.html #}
|
||||
{% extends 'admin/base.html' %}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
{% block title %}Auth Flow Testing{% endblock %}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.test-section h2 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.test-description {
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.test-steps {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.test-steps ol {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.test-steps li {
|
||||
margin-bottom: 8px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.expected-result {
|
||||
background: #e8f5e9;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #4caf50;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.expected-result strong {
|
||||
color: #2e7d32;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.expected-result ul {
|
||||
margin-left: 20px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
margin-top: 30px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-panel h3 {
|
||||
color: #38bdf8;
|
||||
margin-bottom: 15px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
color: #34d399;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-value.false {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.log-level-control {
|
||||
background: #fef3c7;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 30px;
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.log-level-control h3 {
|
||||
color: #92400e;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.log-level-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.log-level-buttons button {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.warning-box h3 {
|
||||
color: #991b1b;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.warning-box ul {
|
||||
margin-left: 20px;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
.warning-box li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🧪 Auth Flow Testing</h1>
|
||||
<p class="subtitle">Comprehensive testing for the Jinja2 migration auth loop fix</p>
|
||||
|
||||
<!-- Log Level Control -->
|
||||
<div class="log-level-control">
|
||||
<h3>📊 Log Level Control</h3>
|
||||
<p style="color: #78350f; font-size: 13px; margin-bottom: 10px;">
|
||||
Change logging verbosity for login.js and api-client.js
|
||||
{% block content %}
|
||||
<div x-data="authFlowTest()" x-init="init()">
|
||||
{# Page Header #}
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Auth Flow Testing
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Comprehensive testing for Jinja2 migration auth loop fix
|
||||
</p>
|
||||
<div class="log-level-buttons">
|
||||
<button onclick="setLogLevel(0)" class="btn-secondary">0 - None</button>
|
||||
<button onclick="setLogLevel(1)" class="btn-danger">1 - Errors Only</button>
|
||||
<button onclick="setLogLevel(2)" class="btn-warning">2 - Warnings</button>
|
||||
<button onclick="setLogLevel(3)" class="btn-success">3 - Info (Production)</button>
|
||||
<button onclick="setLogLevel(4)" class="btn-primary">4 - Debug (Full)</button>
|
||||
</div>
|
||||
<p style="color: #78350f; font-size: 12px; margin-top: 10px; font-style: italic;">
|
||||
Current levels: LOGIN = <span id="currentLoginLevel">4</span>, API = <span id="currentApiLevel">3</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Test 1: Clean Slate -->
|
||||
<div class="test-section">
|
||||
<h2>Test 1: Clean Slate - Fresh Login Flow</h2>
|
||||
<p class="test-description">
|
||||
Tests the complete login flow from scratch with no existing tokens.
|
||||
</p>
|
||||
|
||||
<div class="test-steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li>Click "Clear All Data" below</li>
|
||||
<li>Click "Navigate to /admin"</li>
|
||||
<li>Observe browser behavior and console logs</li>
|
||||
<li>You should land on login page</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="expected-result">
|
||||
<strong>✅ Expected Result:</strong>
|
||||
<ul>
|
||||
<li>Single redirect: /admin → /admin/login</li>
|
||||
<li>Login page loads with NO API calls to /admin/auth/me</li>
|
||||
<li>No loops, no errors in console</li>
|
||||
<li>Form is ready for input</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="clearAllData()" class="btn-danger">Clear All Data</button>
|
||||
<button onclick="navigateToAdmin()" class="btn-primary">Navigate to /admin</button>
|
||||
<button onclick="navigateToLogin()" class="btn-secondary">Go to Login</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test 2: Login Success -->
|
||||
<div class="test-section">
|
||||
<h2>Test 2: Successful Login</h2>
|
||||
<p class="test-description">
|
||||
Tests that login works correctly and redirects to dashboard.
|
||||
</p>
|
||||
|
||||
<div class="test-steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li>Ensure you're on /admin/login</li>
|
||||
<li>Enter valid admin credentials</li>
|
||||
<li>Click "Login"</li>
|
||||
<li>Observe redirect and dashboard load</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="expected-result">
|
||||
<strong>✅ Expected Result:</strong>
|
||||
<ul>
|
||||
<li>Login API call succeeds (check Network tab)</li>
|
||||
<li>Token stored in localStorage</li>
|
||||
<li>Success message shows briefly</li>
|
||||
<li>Redirect to /admin/dashboard after 500ms</li>
|
||||
<li>Dashboard loads with stats and recent vendors</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="navigateToLogin()" class="btn-primary">Go to Login Page</button>
|
||||
<button onclick="checkAuthStatus()" class="btn-secondary">Check Auth Status</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test 3: Dashboard Refresh -->
|
||||
<div class="test-section">
|
||||
<h2>Test 3: Dashboard Refresh (Authenticated)</h2>
|
||||
<p class="test-description">
|
||||
Tests that refreshing the dashboard works without redirect loops.
|
||||
</p>
|
||||
|
||||
<div class="test-steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li>Complete Test 2 (login successfully)</li>
|
||||
<li>On dashboard, press F5 or click "Refresh Page"</li>
|
||||
<li>Observe page reload behavior</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="expected-result">
|
||||
<strong>✅ Expected Result:</strong>
|
||||
<ul>
|
||||
<li>Dashboard reloads normally</li>
|
||||
<li>No redirects to login</li>
|
||||
<li>Stats and vendors load correctly</li>
|
||||
<li>No console errors</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="navigateToDashboard()" class="btn-primary">Go to Dashboard</button>
|
||||
<button onclick="window.location.reload()" class="btn-secondary">Refresh Page</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test 4: Expired Token -->
|
||||
<div class="test-section">
|
||||
<h2>Test 4: Expired Token Handling</h2>
|
||||
<p class="test-description">
|
||||
Tests that expired tokens are handled gracefully with redirect to login.
|
||||
</p>
|
||||
|
||||
<div class="test-steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li>Click "Set Expired Token"</li>
|
||||
<li>Click "Navigate to Dashboard"</li>
|
||||
<li>Observe authentication failure and redirect</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="expected-result">
|
||||
<strong>✅ Expected Result:</strong>
|
||||
<ul>
|
||||
<li>Server detects expired token</li>
|
||||
<li>Returns 401 Unauthorized</li>
|
||||
<li>Browser redirects to /admin/login</li>
|
||||
<li>Token is cleared from localStorage</li>
|
||||
<li>No infinite loops</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="setExpiredToken()" class="btn-warning">Set Expired Token</button>
|
||||
<button onclick="navigateToDashboard()" class="btn-primary">Navigate to Dashboard</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test 5: Direct Dashboard Access (No Token) -->
|
||||
<div class="test-section">
|
||||
<h2>Test 5: Direct Dashboard Access (Unauthenticated)</h2>
|
||||
<p class="test-description">
|
||||
Tests that accessing dashboard without token redirects to login.
|
||||
</p>
|
||||
|
||||
<div class="test-steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li>Click "Clear All Data"</li>
|
||||
<li>Click "Navigate to Dashboard"</li>
|
||||
<li>Observe immediate redirect to login</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="expected-result">
|
||||
<strong>✅ Expected Result:</strong>
|
||||
<ul>
|
||||
<li>Redirect from /admin/dashboard to /admin/login</li>
|
||||
<li>No API calls attempted</li>
|
||||
<li>Login page loads correctly</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="clearAllData()" class="btn-danger">Clear All Data</button>
|
||||
<button onclick="navigateToDashboard()" class="btn-primary">Navigate to Dashboard</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test 6: Login Page with Valid Token -->
|
||||
<div class="test-section">
|
||||
<h2>Test 6: Login Page with Valid Token</h2>
|
||||
<p class="test-description">
|
||||
Tests what happens when user visits login page while already authenticated.
|
||||
</p>
|
||||
|
||||
<div class="test-steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li>Login successfully (Test 2)</li>
|
||||
<li>Click "Go to Login Page" below</li>
|
||||
<li>Observe behavior</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="expected-result">
|
||||
<strong>✅ Expected Result:</strong>
|
||||
<ul>
|
||||
<li>Login page loads</li>
|
||||
<li>Existing token is cleared (init() clears it)</li>
|
||||
<li>Form is displayed normally</li>
|
||||
<li>NO redirect loops</li>
|
||||
<li>NO API calls to validate token</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="setValidToken()" class="btn-success">Set Valid Token (Mock)</button>
|
||||
<button onclick="navigateToLogin()" class="btn-primary">Go to Login Page</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Panel -->
|
||||
<div class="status-panel">
|
||||
<h3>🔍 Current Auth Status</h3>
|
||||
<div id="statusDisplay">
|
||||
<div class="status-item">
|
||||
<span class="status-label">Current URL:</span>
|
||||
<span class="status-value" id="currentUrl">-</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Has admin_token:</span>
|
||||
<span class="status-value" id="hasToken">-</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Has admin_user:</span>
|
||||
<span class="status-value" id="hasUser">-</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Token Preview:</span>
|
||||
<span class="status-value" id="tokenPreview">-</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Username:</span>
|
||||
<span class="status-value" id="username">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="updateStatus()" style="margin-top: 15px; background: #38bdf8; color: #0f172a; padding: 8px 16px; border-radius: 4px; font-size: 12px; cursor: pointer; border: none;">
|
||||
🔄 Refresh Status
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Warning Box -->
|
||||
<div class="warning-box">
|
||||
<h3>⚠️ Important Notes</h3>
|
||||
<ul>
|
||||
<li>Always check browser console for detailed logs</li>
|
||||
<li>Use Network tab to see actual HTTP requests and redirects</li>
|
||||
<li>Clear browser cache if you see unexpected behavior</li>
|
||||
<li>Make sure FastAPI server is running on localhost:8000</li>
|
||||
<li>Valid admin credentials required for login tests</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Update status display
|
||||
function updateStatus() {
|
||||
{# Log Level Control #}
|
||||
<div class="px-4 py-3 mb-6 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg shadow-md border-l-4 border-yellow-500">
|
||||
<h4 class="mb-2 text-lg font-semibold text-yellow-800 dark:text-yellow-200">Log Level Control</h4>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300 mb-3">
|
||||
Change logging verbosity for login.js and api-client.js
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="setLogLevel(0)" class="px-3 py-1 text-xs font-medium text-white bg-gray-600 rounded hover:bg-gray-700">0 - None</button>
|
||||
<button @click="setLogLevel(1)" class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">1 - Errors</button>
|
||||
<button @click="setLogLevel(2)" class="px-3 py-1 text-xs font-medium text-white bg-yellow-600 rounded hover:bg-yellow-700">2 - Warnings</button>
|
||||
<button @click="setLogLevel(3)" class="px-3 py-1 text-xs font-medium text-white bg-green-600 rounded hover:bg-green-700">3 - Info</button>
|
||||
<button @click="setLogLevel(4)" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">4 - Debug</button>
|
||||
</div>
|
||||
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-2 italic">
|
||||
Current: LOGIN = <span x-text="currentLoginLevel">4</span>, API = <span x-text="currentApiLevel">3</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Test Sections Grid #}
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2">
|
||||
{# Test 1: Clean Slate #}
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-blue-500">
|
||||
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||
Test 1: Clean Slate - Fresh Login
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Tests complete login flow from scratch with no existing tokens.
|
||||
</p>
|
||||
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
|
||||
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>Clear All Data</li>
|
||||
<li>Navigate to /admin</li>
|
||||
<li>Should land on login page</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
|
||||
<p class="text-xs text-green-700 dark:text-green-400">Expected: Single redirect /admin -> /admin/login, no loops</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="clearAllData()" class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">Clear All Data</button>
|
||||
<button @click="navigateTo('/admin')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to /admin</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Test 2: Successful Login #}
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-green-500">
|
||||
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||
Test 2: Successful Login
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Tests that login works correctly and redirects to dashboard.
|
||||
</p>
|
||||
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
|
||||
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>Go to /admin/login</li>
|
||||
<li>Enter valid admin credentials</li>
|
||||
<li>Click Login</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
|
||||
<p class="text-xs text-green-700 dark:text-green-400">Expected: Token stored, redirect to /admin/dashboard</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="navigateTo('/admin/login')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Login</button>
|
||||
<button @click="checkAuthStatus()" class="px-3 py-1 text-xs font-medium text-white bg-gray-600 rounded hover:bg-gray-700">Check Status</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Test 3: Dashboard Refresh #}
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-purple-500">
|
||||
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||
Test 3: Dashboard Refresh
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Tests that refreshing dashboard works without redirect loops.
|
||||
</p>
|
||||
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
|
||||
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>Complete Test 2 (login)</li>
|
||||
<li>Press F5 or click Refresh</li>
|
||||
<li>Dashboard should reload normally</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
|
||||
<p class="text-xs text-green-700 dark:text-green-400">Expected: No redirect to login, stats load correctly</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="navigateTo('/admin/dashboard')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Dashboard</button>
|
||||
<button @click="window.location.reload()" class="px-3 py-1 text-xs font-medium text-white bg-gray-600 rounded hover:bg-gray-700">Refresh Page</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Test 4: Expired Token #}
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-orange-500">
|
||||
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||
Test 4: Expired Token Handling
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Tests that expired tokens are handled gracefully.
|
||||
</p>
|
||||
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
|
||||
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>Set Expired Token</li>
|
||||
<li>Navigate to Dashboard</li>
|
||||
<li>Should redirect to login</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
|
||||
<p class="text-xs text-green-700 dark:text-green-400">Expected: 401 response, redirect to login, no loops</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="setExpiredToken()" class="px-3 py-1 text-xs font-medium text-white bg-orange-600 rounded hover:bg-orange-700">Set Expired Token</button>
|
||||
<button @click="navigateTo('/admin/dashboard')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Dashboard</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Test 5: Direct Access (No Token) #}
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-red-500">
|
||||
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||
Test 5: Direct Access (Unauthenticated)
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Tests accessing dashboard without token redirects to login.
|
||||
</p>
|
||||
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
|
||||
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>Clear All Data</li>
|
||||
<li>Navigate to Dashboard</li>
|
||||
<li>Should redirect to login</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
|
||||
<p class="text-xs text-green-700 dark:text-green-400">Expected: Redirect to /admin/login, no API calls</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="clearAllData()" class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">Clear All Data</button>
|
||||
<button @click="navigateTo('/admin/dashboard')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Dashboard</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Test 6: Login with Valid Token #}
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-teal-500">
|
||||
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||
Test 6: Login Page with Valid Token
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Tests visiting login page while already authenticated.
|
||||
</p>
|
||||
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
|
||||
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>Login successfully (Test 2)</li>
|
||||
<li>Click Go to Login Page</li>
|
||||
<li>Token should be cleared</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
|
||||
<p class="text-xs text-green-700 dark:text-green-400">Expected: Token cleared, form displayed, no loops</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="setMockToken()" class="px-3 py-1 text-xs font-medium text-white bg-green-600 rounded hover:bg-green-700">Set Mock Token</button>
|
||||
<button @click="navigateTo('/admin/login')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Login</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Status Panel #}
|
||||
<div class="px-4 py-3 bg-gray-800 rounded-lg shadow-md">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-lg font-semibold text-gray-200">Current Auth Status</h4>
|
||||
<button @click="updateStatus()" class="px-3 py-1 text-xs text-gray-400 border border-gray-600 rounded hover:bg-gray-700">Refresh</button>
|
||||
</div>
|
||||
<div class="font-mono text-sm space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Current URL:</span>
|
||||
<span class="text-blue-400" x-text="currentUrl">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Has admin_token:</span>
|
||||
<span :class="hasToken ? 'text-green-400' : 'text-red-400'" x-text="hasToken ? 'Yes' : 'No'">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Has admin_user:</span>
|
||||
<span :class="hasUser ? 'text-green-400' : 'text-red-400'" x-text="hasUser ? 'Yes' : 'No'">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Token Preview:</span>
|
||||
<span class="text-green-400 truncate max-w-xs" x-text="tokenPreview">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Username:</span>
|
||||
<span class="text-green-400" x-text="username">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Warning Box #}
|
||||
<div class="mt-6 px-4 py-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<h4 class="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">Important Notes</h4>
|
||||
<ul class="list-disc list-inside text-sm text-red-600 dark:text-red-400 space-y-1">
|
||||
<li>Always check browser console for detailed logs</li>
|
||||
<li>Use Network tab to see actual HTTP requests and redirects</li>
|
||||
<li>Clear browser cache if you see unexpected behavior</li>
|
||||
<li>Make sure FastAPI server is running on localhost:8000</li>
|
||||
<li>Valid admin credentials required for login tests</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function authFlowTest() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'auth-testing',
|
||||
|
||||
currentUrl: '-',
|
||||
hasToken: false,
|
||||
hasUser: false,
|
||||
tokenPreview: '-',
|
||||
username: '-',
|
||||
currentLoginLevel: 4,
|
||||
currentApiLevel: 3,
|
||||
|
||||
init() {
|
||||
this.updateStatus();
|
||||
setInterval(() => this.updateStatus(), 2000);
|
||||
console.log('Auth Flow Testing Script Loaded');
|
||||
},
|
||||
|
||||
updateStatus() {
|
||||
const token = localStorage.getItem('admin_token');
|
||||
const userStr = localStorage.getItem('admin_user');
|
||||
let user = null;
|
||||
@@ -524,121 +258,67 @@
|
||||
console.error('Failed to parse user data:', e);
|
||||
}
|
||||
|
||||
document.getElementById('currentUrl').textContent = window.location.href;
|
||||
this.currentUrl = window.location.href;
|
||||
this.hasToken = !!token;
|
||||
this.hasUser = !!user;
|
||||
this.tokenPreview = token ? token.substring(0, 30) + '...' : 'No token';
|
||||
this.username = user?.username || 'Not logged in';
|
||||
},
|
||||
|
||||
const hasTokenEl = document.getElementById('hasToken');
|
||||
hasTokenEl.textContent = token ? 'Yes' : 'No';
|
||||
hasTokenEl.className = token ? 'status-value' : 'status-value false';
|
||||
|
||||
const hasUserEl = document.getElementById('hasUser');
|
||||
hasUserEl.textContent = user ? 'Yes' : 'No';
|
||||
hasUserEl.className = user ? 'status-value' : 'status-value false';
|
||||
|
||||
document.getElementById('tokenPreview').textContent = token
|
||||
? token.substring(0, 30) + '...'
|
||||
: 'No token';
|
||||
|
||||
document.getElementById('username').textContent = user?.username || 'Not logged in';
|
||||
|
||||
console.log('📊 Status Updated:', {
|
||||
hasToken: !!token,
|
||||
hasUser: !!user,
|
||||
user: user
|
||||
});
|
||||
}
|
||||
|
||||
// Test functions
|
||||
function clearAllData() {
|
||||
console.log('🗑️ Clearing all localStorage data...');
|
||||
clearAllData() {
|
||||
console.log('Clearing all localStorage data...');
|
||||
localStorage.clear();
|
||||
console.log('✅ All data cleared');
|
||||
alert('✅ All localStorage data cleared!\n\nCheck console for details.');
|
||||
updateStatus();
|
||||
}
|
||||
console.log('All data cleared');
|
||||
alert('All localStorage data cleared!');
|
||||
this.updateStatus();
|
||||
},
|
||||
|
||||
function navigateToAdmin() {
|
||||
console.log('🚀 Navigating to /admin...');
|
||||
window.location.href = '/admin';
|
||||
}
|
||||
navigateTo(path) {
|
||||
console.log(`Navigating to ${path}...`);
|
||||
window.location.href = path;
|
||||
},
|
||||
|
||||
function navigateToLogin() {
|
||||
console.log('🚀 Navigating to /admin/login...');
|
||||
window.location.href = '/admin/login';
|
||||
}
|
||||
|
||||
function navigateToDashboard() {
|
||||
console.log('🚀 Navigating to /admin/dashboard...');
|
||||
window.location.href = '/admin/dashboard';
|
||||
}
|
||||
|
||||
function checkAuthStatus() {
|
||||
updateStatus();
|
||||
checkAuthStatus() {
|
||||
this.updateStatus();
|
||||
alert('Check console and status panel for auth details.');
|
||||
}
|
||||
},
|
||||
|
||||
function setExpiredToken() {
|
||||
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwMjJ9.invalidexpiredtoken';
|
||||
console.log('⚠️ Setting expired/invalid token...');
|
||||
setExpiredToken() {
|
||||
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNTE2MjM5MDIyfQ.invalid';
|
||||
localStorage.setItem('admin_token', expiredToken);
|
||||
localStorage.setItem('admin_user', JSON.stringify({
|
||||
id: 1,
|
||||
username: 'test_expired',
|
||||
role: 'admin'
|
||||
}));
|
||||
console.log('✅ Expired token set');
|
||||
alert('⚠️ Expired token set!\n\nNow try navigating to dashboard.');
|
||||
updateStatus();
|
||||
}
|
||||
alert('Expired token set! Now try navigating to dashboard.');
|
||||
this.updateStatus();
|
||||
},
|
||||
|
||||
function setValidToken() {
|
||||
// This is a mock token - won't actually work with backend
|
||||
setMockToken() {
|
||||
const mockToken = 'mock_valid_token_' + Date.now();
|
||||
console.log('✅ Setting mock valid token...');
|
||||
localStorage.setItem('admin_token', mockToken);
|
||||
localStorage.setItem('admin_user', JSON.stringify({
|
||||
id: 1,
|
||||
username: 'test_user',
|
||||
role: 'admin'
|
||||
}));
|
||||
console.log('✅ Mock token set (will not work with real backend)');
|
||||
alert('✅ Mock token set!\n\nNote: This is a fake token and won\'t work with the real backend.');
|
||||
updateStatus();
|
||||
}
|
||||
alert('Mock token set! Note: This won\'t work with real backend.');
|
||||
this.updateStatus();
|
||||
},
|
||||
|
||||
// Log level control
|
||||
function setLogLevel(level) {
|
||||
console.log(`📊 Setting log level to ${level}...`);
|
||||
|
||||
// Note: This only works if login.js and api-client.js are loaded
|
||||
// In production, you'd need to reload the page or use a more sophisticated approach
|
||||
if (typeof LOG_LEVEL !== 'undefined') {
|
||||
setLogLevel(level) {
|
||||
if (typeof window.LOG_LEVEL !== 'undefined') {
|
||||
window.LOG_LEVEL = level;
|
||||
document.getElementById('currentLoginLevel').textContent = level;
|
||||
console.log('✅ LOGIN log level set to', level);
|
||||
} else {
|
||||
console.warn('⚠️ LOG_LEVEL not found (login.js not loaded)');
|
||||
this.currentLoginLevel = level;
|
||||
}
|
||||
|
||||
if (typeof API_LOG_LEVEL !== 'undefined') {
|
||||
if (typeof window.API_LOG_LEVEL !== 'undefined') {
|
||||
window.API_LOG_LEVEL = level;
|
||||
document.getElementById('currentApiLevel').textContent = level;
|
||||
console.log('✅ API log level set to', level);
|
||||
} else {
|
||||
console.warn('⚠️ API_LOG_LEVEL not found (api-client.js not loaded)');
|
||||
this.currentApiLevel = level;
|
||||
}
|
||||
|
||||
alert(`Log level set to ${level}\n\n0 = None\n1 = Errors\n2 = Warnings\n3 = Info\n4 = Debug\n\nNote: Changes apply to current page. Reload to apply to all scripts.`);
|
||||
alert(`Log level set to ${level}. Reload to apply to all scripts.`);
|
||||
}
|
||||
|
||||
// Initialize status on load
|
||||
updateStatus();
|
||||
|
||||
// Auto-refresh status every 2 seconds
|
||||
setInterval(updateStatus, 2000);
|
||||
|
||||
console.log('🧪 Auth Flow Testing Script Loaded');
|
||||
console.log('📊 Use the buttons above to run tests');
|
||||
console.log('🔍 Watch browser console and Network tab for details');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,51 +41,8 @@
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
{# Tailwind CSS with local fallback #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
|
||||
{# Platform-specific styles #}
|
||||
<style>
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Custom gradients */
|
||||
.gradient-primary {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
|
||||
}
|
||||
|
||||
.gradient-accent {
|
||||
background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-accent) 100%);
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Card hover effect */
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
</style>
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='platform/css/tailwind.output.css') }}">
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
{# app/templates/shared/cdn-fallback.html #}
|
||||
{# CDN with Local Fallback Pattern #}
|
||||
{# This partial handles loading CDN resources with automatic fallback to local copies #}
|
||||
|
||||
{# Tailwind CSS with fallback #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
|
||||
{# Alpine.js with fallback - must be loaded at the end of body #}
|
||||
{# Usage: Include this partial at the bottom of your template, before page-specific scripts #}
|
||||
<script>
|
||||
// Alpine.js CDN with fallback
|
||||
(function() {
|
||||
var script = document.createElement('script');
|
||||
script.defer = true;
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js';
|
||||
|
||||
script.onerror = function() {
|
||||
console.warn('Alpine.js CDN failed, loading local copy...');
|
||||
var fallbackScript = document.createElement('script');
|
||||
fallbackScript.defer = true;
|
||||
fallbackScript.src = '{{ url_for("static", path="shared/js/vendor/alpine.min.js") }}';
|
||||
document.head.appendChild(fallbackScript);
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
@@ -1,11 +1,15 @@
|
||||
<DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Address management</title>
|
||||
</head>
|
||||
<body>
|
||||
<-- Address management -->
|
||||
</body>
|
||||
</html>
|
||||
{# app/templates/shop/account/addresses.html #}
|
||||
{% extends "shop/base.html" %}
|
||||
|
||||
{% block title %}My Addresses{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">My Addresses</h1>
|
||||
|
||||
{# TODO: Implement address management #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p class="text-gray-600 dark:text-gray-400">Address management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# app/templates/shop/account/forgot-password.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="forgotPassword()" lang="en">
|
||||
<html :class="{ 'dark': dark }" x-data="forgotPassword()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -37,9 +37,8 @@
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS with local fallback #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# app/templates/shop/account/login.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="customerLogin()" lang="en">
|
||||
<html :class="{ 'dark': dark }" x-data="customerLogin()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -37,9 +37,8 @@
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS with local fallback #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Order history</title>
|
||||
</head>
|
||||
<body>
|
||||
<-- Order history -->
|
||||
</body>
|
||||
</html>
|
||||
{# app/templates/shop/account/orders.html #}
|
||||
{% extends "shop/base.html" %}
|
||||
|
||||
{% block title %}Order History{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Order History</h1>
|
||||
|
||||
{# TODO: Implement order history #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p class="text-gray-600 dark:text-gray-400">Order history coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Customer profile</title>
|
||||
</head>
|
||||
<body>
|
||||
<-- Customer profile -->
|
||||
</body>
|
||||
</html>
|
||||
{# app/templates/shop/account/profile.html #}
|
||||
{% extends "shop/base.html" %}
|
||||
|
||||
{% block title %}My Profile{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">My Profile</h1>
|
||||
|
||||
{# TODO: Implement profile management #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p class="text-gray-600 dark:text-gray-400">Profile management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# app/templates/shop/account/register.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="customerRegistration()" lang="en">
|
||||
<html :class="{ 'dark': dark }" x-data="customerRegistration()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -37,9 +37,8 @@
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS with local fallback #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
|
||||
@@ -37,9 +37,8 @@
|
||||
{% endif %}
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS with local fallback #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
|
||||
{# Base Shop Styles #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/shop.css') }}">
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Checkout process</title>
|
||||
</head>
|
||||
<body>
|
||||
<-- Checkout process -->
|
||||
</body>
|
||||
</html>
|
||||
{# app/templates/shop/checkout.html #}
|
||||
{% extends "shop/base.html" %}
|
||||
|
||||
{% block title %}Checkout{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Checkout</h1>
|
||||
|
||||
{# TODO: Implement checkout process #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p class="text-gray-600 dark:text-gray-400">Checkout process coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,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>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Search results page</title>
|
||||
</head>
|
||||
<body>
|
||||
<-- Search results page -->
|
||||
</body>
|
||||
</html>
|
||||
{# app/templates/shop/search.html #}
|
||||
{% extends "shop/base.html" %}
|
||||
|
||||
{% block title %}Search Results{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Search Results</h1>
|
||||
|
||||
{# TODO: Implement search results #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p class="text-gray-600 dark:text-gray-400">Search results coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
8
app/templates/vendor/base.html
vendored
8
app/templates/vendor/base.html
vendored
@@ -1,6 +1,6 @@
|
||||
{# app/templates/vendor/base.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
|
||||
<html :class="{ 'dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -10,11 +10,7 @@
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Tailwind CSS with CDN fallback -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
|
||||
|
||||
<!-- Vendor-specific Tailwind customizations -->
|
||||
<!-- Tailwind CSS v4 (built locally via standalone CLI) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='vendor/css/tailwind.output.css') }}" />
|
||||
|
||||
<!-- Alpine Cloak -->
|
||||
|
||||
2
app/templates/vendor/login.html
vendored
2
app/templates/vendor/login.html
vendored
@@ -1,6 +1,6 @@
|
||||
{# app/templates/vendor/login.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="vendorLogin()" lang="en">
|
||||
<html :class="{ 'dark': dark }" x-data="vendorLogin()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
Reference in New Issue
Block a user