Exception handling enhancement
This commit is contained in:
@@ -7,7 +7,7 @@ This module provides classes and functions for:
|
|||||||
- ....
|
- ....
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException
|
from fastapi import Depends
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ from middleware.auth import AuthManager
|
|||||||
from middleware.rate_limiter import RateLimiter
|
from middleware.rate_limiter import RateLimiter
|
||||||
from models.database.shop import Shop
|
from models.database.shop import Shop
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
|
from app.exceptions import (AdminRequiredException,ShopNotFoundException, UnauthorizedShopAccessException)
|
||||||
|
|
||||||
# Set auto_error=False to prevent automatic 403 responses
|
# Set auto_error=False to prevent automatic 403 responses
|
||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
@@ -30,11 +31,13 @@ def get_current_user(
|
|||||||
"""Get current authenticated user."""
|
"""Get current authenticated user."""
|
||||||
# Check if credentials are provided
|
# Check if credentials are provided
|
||||||
if not credentials:
|
if not credentials:
|
||||||
raise HTTPException(status_code=401, detail="Authorization header required")
|
from app.exceptions.auth import InvalidTokenException
|
||||||
|
raise InvalidTokenException("Authorization header required")
|
||||||
|
|
||||||
return auth_manager.get_current_user(db, credentials)
|
return auth_manager.get_current_user(db, credentials)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_admin_user(current_user: User = Depends(get_current_user)):
|
def get_current_admin_user(current_user: User = Depends(get_current_user)):
|
||||||
"""Require admin user."""
|
"""Require admin user."""
|
||||||
return auth_manager.require_admin(current_user)
|
return auth_manager.require_admin(current_user)
|
||||||
@@ -48,9 +51,10 @@ def get_user_shop(
|
|||||||
"""Get shop and verify user ownership."""
|
"""Get shop and verify user ownership."""
|
||||||
shop = db.query(Shop).filter(Shop.shop_code == shop_code.upper()).first()
|
shop = db.query(Shop).filter(Shop.shop_code == shop_code.upper()).first()
|
||||||
if not shop:
|
if not shop:
|
||||||
raise HTTPException(status_code=404, detail="Shop not found")
|
raise ShopNotFoundException(shop_code)
|
||||||
|
|
||||||
if current_user.role != "admin" and shop.owner_id != current_user.id:
|
if current_user.role != "admin" and shop.owner_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="Access denied to this shop")
|
raise UnauthorizedShopAccessException(shop_code, current_user.id)
|
||||||
|
|
||||||
return shop
|
return shop
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
# app/api/v1/admin.py
|
# app/api/v1/admin.py
|
||||||
"""Summary description ....
|
"""
|
||||||
|
Admin endpoints - simplified with service-level exception handling.
|
||||||
|
|
||||||
This module provides classes and functions for:
|
This module provides classes and functions for:
|
||||||
- ....
|
- User management (view, toggle status)
|
||||||
- ....
|
- Shop management (view, verify, toggle status)
|
||||||
- ....
|
- Marketplace import job monitoring
|
||||||
|
- Admin dashboard statistics
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_admin_user
|
from app.api.deps import get_current_admin_user
|
||||||
@@ -25,7 +27,6 @@ router = APIRouter()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Admin-only routes
|
|
||||||
@router.get("/admin/users", response_model=List[UserResponse])
|
@router.get("/admin/users", response_model=List[UserResponse])
|
||||||
def get_all_users(
|
def get_all_users(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
@@ -34,12 +35,8 @@ def get_all_users(
|
|||||||
current_admin: User = Depends(get_current_admin_user),
|
current_admin: User = Depends(get_current_admin_user),
|
||||||
):
|
):
|
||||||
"""Get all users (Admin only)."""
|
"""Get all users (Admin only)."""
|
||||||
try:
|
users = admin_service.get_all_users(db=db, skip=skip, limit=limit)
|
||||||
users = admin_service.get_all_users(db=db, skip=skip, limit=limit)
|
return [UserResponse.model_validate(user) for user in users]
|
||||||
return [UserResponse.model_validate(user) for user in users]
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting users: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/admin/users/{user_id}/status")
|
@router.put("/admin/users/{user_id}/status")
|
||||||
@@ -49,14 +46,8 @@ def toggle_user_status(
|
|||||||
current_admin: User = Depends(get_current_admin_user),
|
current_admin: User = Depends(get_current_admin_user),
|
||||||
):
|
):
|
||||||
"""Toggle user active status (Admin only)."""
|
"""Toggle user active status (Admin only)."""
|
||||||
try:
|
user, message = admin_service.toggle_user_status(db, user_id, current_admin.id)
|
||||||
user, message = admin_service.toggle_user_status(db, user_id, current_admin.id)
|
return {"message": message}
|
||||||
return {"message": message}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error toggling user {user_id} status: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/shops", response_model=ShopListResponse)
|
@router.get("/admin/shops", response_model=ShopListResponse)
|
||||||
@@ -67,13 +58,8 @@ def get_all_shops_admin(
|
|||||||
current_admin: User = Depends(get_current_admin_user),
|
current_admin: User = Depends(get_current_admin_user),
|
||||||
):
|
):
|
||||||
"""Get all shops with admin view (Admin only)."""
|
"""Get all shops with admin view (Admin only)."""
|
||||||
try:
|
shops, total = admin_service.get_all_shops(db=db, skip=skip, limit=limit)
|
||||||
shops, total = admin_service.get_all_shops(db=db, skip=skip, limit=limit)
|
return ShopListResponse(shops=shops, total=total, skip=skip, limit=limit)
|
||||||
|
|
||||||
return ShopListResponse(shops=shops, total=total, skip=skip, limit=limit)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting shops: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/admin/shops/{shop_id}/verify")
|
@router.put("/admin/shops/{shop_id}/verify")
|
||||||
@@ -83,14 +69,8 @@ def verify_shop(
|
|||||||
current_admin: User = Depends(get_current_admin_user),
|
current_admin: User = Depends(get_current_admin_user),
|
||||||
):
|
):
|
||||||
"""Verify/unverify shop (Admin only)."""
|
"""Verify/unverify shop (Admin only)."""
|
||||||
try:
|
shop, message = admin_service.verify_shop(db, shop_id)
|
||||||
shop, message = admin_service.verify_shop(db, shop_id)
|
return {"message": message}
|
||||||
return {"message": message}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error verifying shop {shop_id}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/admin/shops/{shop_id}/status")
|
@router.put("/admin/shops/{shop_id}/status")
|
||||||
@@ -100,14 +80,8 @@ def toggle_shop_status(
|
|||||||
current_admin: User = Depends(get_current_admin_user),
|
current_admin: User = Depends(get_current_admin_user),
|
||||||
):
|
):
|
||||||
"""Toggle shop active status (Admin only)."""
|
"""Toggle shop active status (Admin only)."""
|
||||||
try:
|
shop, message = admin_service.toggle_shop_status(db, shop_id)
|
||||||
shop, message = admin_service.toggle_shop_status(db, shop_id)
|
return {"message": message}
|
||||||
return {"message": message}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error toggling shop {shop_id} status: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -123,15 +97,29 @@ def get_all_marketplace_import_jobs(
|
|||||||
current_admin: User = Depends(get_current_admin_user),
|
current_admin: User = Depends(get_current_admin_user),
|
||||||
):
|
):
|
||||||
"""Get all marketplace import jobs (Admin only)."""
|
"""Get all marketplace import jobs (Admin only)."""
|
||||||
try:
|
return admin_service.get_marketplace_import_jobs(
|
||||||
return admin_service.get_marketplace_import_jobs(
|
db=db,
|
||||||
db=db,
|
marketplace=marketplace,
|
||||||
marketplace=marketplace,
|
shop_name=shop_name,
|
||||||
shop_name=shop_name,
|
status=status,
|
||||||
status=status,
|
skip=skip,
|
||||||
skip=skip,
|
limit=limit,
|
||||||
limit=limit,
|
)
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting marketplace import jobs: {str(e)}")
|
@router.get("/admin/stats/users")
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
def get_user_statistics(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""Get user statistics for admin dashboard (Admin only)."""
|
||||||
|
return admin_service.get_user_statistics(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/stats/shops")
|
||||||
|
def get_shop_statistics(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""Get shop statistics for admin dashboard (Admin only)."""
|
||||||
|
return admin_service.get_shop_statistics(db)
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
# app/api/v1/auth.py
|
# app/api/v1/auth.py
|
||||||
"""Summary description ....
|
"""
|
||||||
|
Authentication endpoints - simplified with service-level exception handling.
|
||||||
|
|
||||||
This module provides classes and functions for:
|
This module provides classes and functions for:
|
||||||
- ....
|
- User registration and validation
|
||||||
- ....
|
- User authentication and JWT token generation
|
||||||
- ....
|
- Current user information retrieval
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
@@ -23,37 +24,24 @@ router = APIRouter()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Authentication Routes
|
|
||||||
@router.post("/auth/register", response_model=UserResponse)
|
@router.post("/auth/register", response_model=UserResponse)
|
||||||
def register_user(user_data: UserRegister, db: Session = Depends(get_db)):
|
def register_user(user_data: UserRegister, db: Session = Depends(get_db)):
|
||||||
"""Register a new user."""
|
"""Register a new user."""
|
||||||
try:
|
user = auth_service.register_user(db=db, user_data=user_data)
|
||||||
user = auth_service.register_user(db=db, user_data=user_data)
|
return UserResponse.model_validate(user)
|
||||||
return UserResponse.model_validate(user)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error registering user: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/auth/login", response_model=LoginResponse)
|
@router.post("/auth/login", response_model=LoginResponse)
|
||||||
def login_user(user_credentials: UserLogin, db: Session = Depends(get_db)):
|
def login_user(user_credentials: UserLogin, db: Session = Depends(get_db)):
|
||||||
"""Login user and return JWT token."""
|
"""Login user and return JWT token."""
|
||||||
try:
|
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
|
||||||
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
|
|
||||||
|
|
||||||
return LoginResponse(
|
return LoginResponse(
|
||||||
access_token=login_result["token_data"]["access_token"],
|
access_token=login_result["token_data"]["access_token"],
|
||||||
token_type=login_result["token_data"]["token_type"],
|
token_type=login_result["token_data"]["token_type"],
|
||||||
expires_in=login_result["token_data"]["expires_in"],
|
expires_in=login_result["token_data"]["expires_in"],
|
||||||
user=UserResponse.model_validate(login_result["user"]),
|
user=UserResponse.model_validate(login_result["user"]),
|
||||||
)
|
)
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error logging in user: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auth/me", response_model=UserResponse)
|
@router.get("/auth/me", response_model=UserResponse)
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
# app/api/v1/marketplace.py
|
# app/api/v1/marketplace.py
|
||||||
"""Summary description ....
|
"""
|
||||||
|
Marketplace endpoints - simplified with service-level exception handling.
|
||||||
|
|
||||||
This module provides classes and functions for:
|
This module provides classes and functions for:
|
||||||
- ....
|
- Product import from marketplace CSV files
|
||||||
- ....
|
- Import job management and monitoring
|
||||||
- ....
|
- Import statistics and job cancellation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
@@ -26,7 +27,6 @@ router = APIRouter()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Marketplace Import Routes (Protected)
|
|
||||||
@router.post("/marketplace/import-product", response_model=MarketplaceImportJobResponse)
|
@router.post("/marketplace/import-product", response_model=MarketplaceImportJobResponse)
|
||||||
@rate_limit(max_requests=10, window_seconds=3600) # Limit marketplace imports
|
@rate_limit(max_requests=10, window_seconds=3600) # Limit marketplace imports
|
||||||
async def import_products_from_marketplace(
|
async def import_products_from_marketplace(
|
||||||
@@ -36,42 +36,33 @@ async def import_products_from_marketplace(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Import products from marketplace CSV with background processing (Protected)."""
|
"""Import products from marketplace CSV with background processing (Protected)."""
|
||||||
try:
|
logger.info(
|
||||||
logger.info(
|
f"Starting marketplace import: {request.marketplace} -> {request.shop_code} by user {current_user.username}"
|
||||||
f"Starting marketplace import: {request.marketplace} -> {request.shop_code} by user {current_user.username}"
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Create import job through service
|
# Create import job through service
|
||||||
import_job = marketplace_service.create_import_job(db, request, current_user)
|
import_job = marketplace_service.create_import_job(db, request, current_user)
|
||||||
|
|
||||||
# Process in background
|
# Process in background
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
process_marketplace_import,
|
process_marketplace_import,
|
||||||
import_job.id,
|
import_job.id,
|
||||||
request.url,
|
request.url,
|
||||||
request.marketplace,
|
request.marketplace,
|
||||||
request.shop_code,
|
request.shop_code,
|
||||||
request.batch_size or 1000,
|
request.batch_size or 1000,
|
||||||
)
|
)
|
||||||
|
|
||||||
return MarketplaceImportJobResponse(
|
return MarketplaceImportJobResponse(
|
||||||
job_id=import_job.id,
|
job_id=import_job.id,
|
||||||
status="pending",
|
status="pending",
|
||||||
marketplace=request.marketplace,
|
marketplace=request.marketplace,
|
||||||
shop_code=request.shop_code,
|
shop_code=request.shop_code,
|
||||||
shop_id=import_job.shop_id,
|
shop_id=import_job.shop_id,
|
||||||
shop_name=import_job.shop_name,
|
shop_name=import_job.shop_name,
|
||||||
message=f"Marketplace import started from {request.marketplace}. Check status with "
|
message=f"Marketplace import started from {request.marketplace}. Check status with "
|
||||||
f"/import-status/{import_job.id}",
|
f"/import-status/{import_job.id}",
|
||||||
)
|
)
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
except PermissionError as e:
|
|
||||||
raise HTTPException(status_code=403, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error starting marketplace import: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -83,17 +74,8 @@ def get_marketplace_import_status(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Get status of marketplace import job (Protected)."""
|
"""Get status of marketplace import job (Protected)."""
|
||||||
try:
|
job = marketplace_service.get_import_job_by_id(db, job_id, current_user)
|
||||||
job = marketplace_service.get_import_job_by_id(db, job_id, current_user)
|
return marketplace_service.convert_to_response_model(job)
|
||||||
return marketplace_service.convert_to_response_model(job)
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
except PermissionError as e:
|
|
||||||
raise HTTPException(status_code=403, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting import job status {job_id}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -108,21 +90,16 @@ def get_marketplace_import_jobs(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Get marketplace import jobs with filtering (Protected)."""
|
"""Get marketplace import jobs with filtering (Protected)."""
|
||||||
try:
|
jobs = marketplace_service.get_import_jobs(
|
||||||
jobs = marketplace_service.get_import_jobs(
|
db=db,
|
||||||
db=db,
|
user=current_user,
|
||||||
user=current_user,
|
marketplace=marketplace,
|
||||||
marketplace=marketplace,
|
shop_name=shop_name,
|
||||||
shop_name=shop_name,
|
skip=skip,
|
||||||
skip=skip,
|
limit=limit,
|
||||||
limit=limit,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return [marketplace_service.convert_to_response_model(job) for job in jobs]
|
return [marketplace_service.convert_to_response_model(job) for job in jobs]
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting import jobs: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/marketplace/marketplace-import-stats")
|
@router.get("/marketplace/marketplace-import-stats")
|
||||||
@@ -130,13 +107,7 @@ def get_marketplace_import_stats(
|
|||||||
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Get statistics about marketplace import jobs (Protected)."""
|
"""Get statistics about marketplace import jobs (Protected)."""
|
||||||
try:
|
return marketplace_service.get_job_stats(db, current_user)
|
||||||
stats = marketplace_service.get_job_stats(db, current_user)
|
|
||||||
return stats
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting import stats: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
@@ -149,17 +120,8 @@ def cancel_marketplace_import_job(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Cancel a pending or running marketplace import job (Protected)."""
|
"""Cancel a pending or running marketplace import job (Protected)."""
|
||||||
try:
|
job = marketplace_service.cancel_import_job(db, job_id, current_user)
|
||||||
job = marketplace_service.cancel_import_job(db, job_id, current_user)
|
return marketplace_service.convert_to_response_model(job)
|
||||||
return marketplace_service.convert_to_response_model(job)
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except PermissionError as e:
|
|
||||||
raise HTTPException(status_code=403, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error cancelling import job {job_id}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/marketplace/import-jobs/{job_id}")
|
@router.delete("/marketplace/import-jobs/{job_id}")
|
||||||
@@ -169,14 +131,5 @@ def delete_marketplace_import_job(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Delete a completed marketplace import job (Protected)."""
|
"""Delete a completed marketplace import job (Protected)."""
|
||||||
try:
|
marketplace_service.delete_import_job(db, job_id, current_user)
|
||||||
marketplace_service.delete_import_job(db, job_id, current_user)
|
return {"message": "Marketplace import job deleted successfully"}
|
||||||
return {"message": "Marketplace import job deleted successfully"}
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except PermissionError as e:
|
|
||||||
raise HTTPException(status_code=403, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deleting import job {job_id}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
# app/api/v1/product.py
|
# app/api/v1/product.py
|
||||||
"""Summary description ....
|
"""
|
||||||
|
Product endpoints - simplified with service-level exception handling.
|
||||||
|
|
||||||
This module provides classes and functions for:
|
This module provides classes and functions for:
|
||||||
- ....
|
- Product CRUD operations with marketplace support
|
||||||
- ....
|
- Advanced product filtering and search
|
||||||
- ....
|
- Product export functionality
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -26,183 +27,115 @@ router = APIRouter()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Enhanced Product Routes with Marketplace Support
|
|
||||||
@router.get("/product", response_model=ProductListResponse)
|
@router.get("/product", response_model=ProductListResponse)
|
||||||
def get_products(
|
def get_products(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
brand: Optional[str] = Query(None),
|
brand: Optional[str] = Query(None),
|
||||||
category: Optional[str] = Query(None),
|
category: Optional[str] = Query(None),
|
||||||
availability: Optional[str] = Query(None),
|
availability: Optional[str] = Query(None),
|
||||||
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
|
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
|
||||||
shop_name: Optional[str] = Query(None, description="Filter by shop name"),
|
shop_name: Optional[str] = Query(None, description="Filter by shop name"),
|
||||||
search: Optional[str] = Query(None),
|
search: Optional[str] = Query(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Get products with advanced filtering including marketplace and shop (Protected)."""
|
"""Get products with advanced filtering including marketplace and shop (Protected)."""
|
||||||
try:
|
products, total = product_service.get_products_with_filters(
|
||||||
products, total = product_service.get_products_with_filters(
|
db=db,
|
||||||
db=db,
|
skip=skip,
|
||||||
skip=skip,
|
limit=limit,
|
||||||
limit=limit,
|
brand=brand,
|
||||||
brand=brand,
|
category=category,
|
||||||
category=category,
|
availability=availability,
|
||||||
availability=availability,
|
marketplace=marketplace,
|
||||||
marketplace=marketplace,
|
shop_name=shop_name,
|
||||||
shop_name=shop_name,
|
search=search,
|
||||||
search=search,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return ProductListResponse(
|
return ProductListResponse(
|
||||||
products=products, total=total, skip=skip, limit=limit
|
products=products, total=total, skip=skip, limit=limit
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting products: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/product", response_model=ProductResponse)
|
@router.post("/product", response_model=ProductResponse)
|
||||||
def create_product(
|
def create_product(
|
||||||
product: ProductCreate,
|
product: ProductCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Create a new product with validation and marketplace support (Protected)."""
|
"""Create a new product with validation and marketplace support (Protected)."""
|
||||||
try:
|
logger.info(f"Starting product creation for ID: {product.product_id}")
|
||||||
logger.info(f"Starting product creation for ID: {product.product_id}")
|
|
||||||
|
|
||||||
# Check if product_id already exists
|
db_product = product_service.create_product(db, product)
|
||||||
logger.info("Checking for existing product...")
|
logger.info("Product created successfully")
|
||||||
existing = product_service.get_product_by_id(db, product.product_id)
|
|
||||||
logger.info(f"Existing product found: {existing is not None}")
|
|
||||||
|
|
||||||
if existing:
|
return db_product
|
||||||
logger.info("Product already exists, raising 400 error")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400, detail="Product with this ID already exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("No existing product found, proceeding to create...")
|
|
||||||
db_product = product_service.create_product(db, product)
|
|
||||||
logger.info("Product created successfully")
|
|
||||||
|
|
||||||
return db_product
|
|
||||||
|
|
||||||
except HTTPException as he:
|
|
||||||
logger.info(f"HTTPException raised: {he.status_code} - {he.detail}")
|
|
||||||
raise # Re-raise HTTP exceptions
|
|
||||||
except ValueError as e:
|
|
||||||
logger.error(f"ValueError: {str(e)}")
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Unexpected error: {str(e)}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/product/{product_id}", response_model=ProductDetailResponse)
|
@router.get("/product/{product_id}", response_model=ProductDetailResponse)
|
||||||
def get_product(
|
def get_product(
|
||||||
product_id: str,
|
product_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Get product with stock information (Protected)."""
|
"""Get product with stock information (Protected)."""
|
||||||
try:
|
product = product_service.get_product_by_id_or_raise(db, product_id)
|
||||||
product = product_service.get_product_by_id(db, product_id)
|
|
||||||
if not product:
|
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
|
||||||
|
|
||||||
# Get stock information if GTIN exists
|
# Get stock information if GTIN exists
|
||||||
stock_info = None
|
stock_info = None
|
||||||
if product.gtin:
|
if product.gtin:
|
||||||
stock_info = product_service.get_stock_info(db, product.gtin)
|
stock_info = product_service.get_stock_info(db, product.gtin)
|
||||||
|
|
||||||
return ProductDetailResponse(product=product, stock_info=stock_info)
|
return ProductDetailResponse(product=product, stock_info=stock_info)
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting product {product_id}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/product/{product_id}", response_model=ProductResponse)
|
@router.put("/product/{product_id}", response_model=ProductResponse)
|
||||||
def update_product(
|
def update_product(
|
||||||
product_id: str,
|
product_id: str,
|
||||||
product_update: ProductUpdate,
|
product_update: ProductUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Update product with validation and marketplace support (Protected)."""
|
"""Update product with validation and marketplace support (Protected)."""
|
||||||
try:
|
updated_product = product_service.update_product(db, product_id, product_update)
|
||||||
product = product_service.get_product_by_id(db, product_id)
|
return updated_product
|
||||||
if not product:
|
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
|
||||||
|
|
||||||
updated_product = product_service.update_product(db, product_id, product_update)
|
|
||||||
return updated_product
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating product {product_id}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/product/{product_id}")
|
@router.delete("/product/{product_id}")
|
||||||
def delete_product(
|
def delete_product(
|
||||||
product_id: str,
|
product_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Delete product and associated stock (Protected)."""
|
"""Delete product and associated stock (Protected)."""
|
||||||
try:
|
product_service.delete_product(db, product_id)
|
||||||
product = product_service.get_product_by_id(db, product_id)
|
return {"message": "Product and associated stock deleted successfully"}
|
||||||
if not product:
|
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
|
||||||
|
|
||||||
product_service.delete_product(db, product_id)
|
|
||||||
|
|
||||||
return {"message": "Product and associated stock deleted successfully"}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deleting product {product_id}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
# Export with streaming for large datasets (Protected)
|
|
||||||
@router.get("/export-csv")
|
@router.get("/export-csv")
|
||||||
async def export_csv(
|
async def export_csv(
|
||||||
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
|
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
|
||||||
shop_name: Optional[str] = Query(None, description="Filter by shop name"),
|
shop_name: Optional[str] = Query(None, description="Filter by shop name"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Export products as CSV with streaming and marketplace filtering (Protected)."""
|
"""Export products as CSV with streaming and marketplace filtering (Protected)."""
|
||||||
try:
|
|
||||||
|
|
||||||
def generate_csv():
|
def generate_csv():
|
||||||
return product_service.generate_csv_export(
|
return product_service.generate_csv_export(
|
||||||
db=db, marketplace=marketplace, shop_name=shop_name
|
db=db, marketplace=marketplace, shop_name=shop_name
|
||||||
)
|
|
||||||
|
|
||||||
filename = "products_export"
|
|
||||||
if marketplace:
|
|
||||||
filename += f"_{marketplace}"
|
|
||||||
if shop_name:
|
|
||||||
filename += f"_{shop_name}"
|
|
||||||
filename += ".csv"
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
generate_csv(),
|
|
||||||
media_type="text/csv",
|
|
||||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
filename = "products_export"
|
||||||
logger.error(f"Error exporting CSV: {str(e)}")
|
if marketplace:
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
filename += f"_{marketplace}"
|
||||||
|
if shop_name:
|
||||||
|
filename += f"_{shop_name}"
|
||||||
|
filename += ".csv"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate_csv(),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
# app/api/v1/shop.py
|
# app/api/v1/shop.py
|
||||||
"""Summary description ....
|
"""
|
||||||
|
Shop endpoints - simplified with service-level exception handling.
|
||||||
|
|
||||||
This module provides classes and functions for:
|
This module provides classes and functions for:
|
||||||
- ....
|
- Shop CRUD operations and management
|
||||||
- ....
|
- Shop product catalog management
|
||||||
- ....
|
- Shop filtering and verification
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_user, get_user_shop
|
from app.api.deps import get_current_user, get_user_shop
|
||||||
@@ -23,7 +24,6 @@ router = APIRouter()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Shop Management Routes
|
|
||||||
@router.post("/shop", response_model=ShopResponse)
|
@router.post("/shop", response_model=ShopResponse)
|
||||||
def create_shop(
|
def create_shop(
|
||||||
shop_data: ShopCreate,
|
shop_data: ShopCreate,
|
||||||
@@ -31,16 +31,10 @@ def create_shop(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Create a new shop (Protected)."""
|
"""Create a new shop (Protected)."""
|
||||||
try:
|
shop = shop_service.create_shop(
|
||||||
shop = shop_service.create_shop(
|
db=db, shop_data=shop_data, current_user=current_user
|
||||||
db=db, shop_data=shop_data, current_user=current_user
|
)
|
||||||
)
|
return ShopResponse.model_validate(shop)
|
||||||
return ShopResponse.model_validate(shop)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error creating shop: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/shop", response_model=ShopListResponse)
|
@router.get("/shop", response_model=ShopListResponse)
|
||||||
@@ -53,22 +47,16 @@ def get_shops(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Get shops with filtering (Protected)."""
|
"""Get shops with filtering (Protected)."""
|
||||||
try:
|
shops, total = shop_service.get_shops(
|
||||||
shops, total = shop_service.get_shops(
|
db=db,
|
||||||
db=db,
|
current_user=current_user,
|
||||||
current_user=current_user,
|
skip=skip,
|
||||||
skip=skip,
|
limit=limit,
|
||||||
limit=limit,
|
active_only=active_only,
|
||||||
active_only=active_only,
|
verified_only=verified_only,
|
||||||
verified_only=verified_only,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return ShopListResponse(shops=shops, total=total, skip=skip, limit=limit)
|
return ShopListResponse(shops=shops, total=total, skip=skip, limit=limit)
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting shops: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/shop/{shop_code}", response_model=ShopResponse)
|
@router.get("/shop/{shop_code}", response_model=ShopResponse)
|
||||||
@@ -78,19 +66,12 @@ def get_shop(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Get shop details (Protected)."""
|
"""Get shop details (Protected)."""
|
||||||
try:
|
shop = shop_service.get_shop_by_code(
|
||||||
shop = shop_service.get_shop_by_code(
|
db=db, shop_code=shop_code, current_user=current_user
|
||||||
db=db, shop_code=shop_code, current_user=current_user
|
)
|
||||||
)
|
return ShopResponse.model_validate(shop)
|
||||||
return ShopResponse.model_validate(shop)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting shop {shop_code}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
# Shop Product Management
|
|
||||||
@router.post("/shop/{shop_code}/products", response_model=ShopProductResponse)
|
@router.post("/shop/{shop_code}/products", response_model=ShopProductResponse)
|
||||||
def add_product_to_shop(
|
def add_product_to_shop(
|
||||||
shop_code: str,
|
shop_code: str,
|
||||||
@@ -99,24 +80,18 @@ def add_product_to_shop(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Add existing product to shop catalog with shop-specific settings (Protected)."""
|
"""Add existing product to shop catalog with shop-specific settings (Protected)."""
|
||||||
try:
|
# Get and verify shop (using existing dependency)
|
||||||
# Get and verify shop (using existing dependency)
|
shop = get_user_shop(shop_code, current_user, db)
|
||||||
shop = get_user_shop(shop_code, current_user, db)
|
|
||||||
|
|
||||||
# Add product to shop
|
# Add product to shop
|
||||||
new_shop_product = shop_service.add_product_to_shop(
|
new_shop_product = shop_service.add_product_to_shop(
|
||||||
db=db, shop=shop, shop_product=shop_product
|
db=db, shop=shop, shop_product=shop_product
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return with product details
|
# Return with product details
|
||||||
response = ShopProductResponse.model_validate(new_shop_product)
|
response = ShopProductResponse.model_validate(new_shop_product)
|
||||||
response.product = new_shop_product.product
|
response.product = new_shop_product.product
|
||||||
return response
|
return response
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error adding product to shop {shop_code}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/shop/{shop_code}/products")
|
@router.get("/shop/{shop_code}/products")
|
||||||
@@ -130,39 +105,33 @@ def get_shop_products(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Get products in shop catalog (Protected)."""
|
"""Get products in shop catalog (Protected)."""
|
||||||
try:
|
# Get shop
|
||||||
# Get shop
|
shop = shop_service.get_shop_by_code(
|
||||||
shop = shop_service.get_shop_by_code(
|
db=db, shop_code=shop_code, current_user=current_user
|
||||||
db=db, shop_code=shop_code, current_user=current_user
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Get shop products
|
# Get shop products
|
||||||
shop_products, total = shop_service.get_shop_products(
|
shop_products, total = shop_service.get_shop_products(
|
||||||
db=db,
|
db=db,
|
||||||
shop=shop,
|
shop=shop,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
skip=skip,
|
skip=skip,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
active_only=active_only,
|
active_only=active_only,
|
||||||
featured_only=featured_only,
|
featured_only=featured_only,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Format response
|
# Format response
|
||||||
products = []
|
products = []
|
||||||
for sp in shop_products:
|
for sp in shop_products:
|
||||||
product_response = ShopProductResponse.model_validate(sp)
|
product_response = ShopProductResponse.model_validate(sp)
|
||||||
product_response.product = sp.product
|
product_response.product = sp.product
|
||||||
products.append(product_response)
|
products.append(product_response)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"products": products,
|
"products": products,
|
||||||
"total": total,
|
"total": total,
|
||||||
"skip": skip,
|
"skip": skip,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"shop": ShopResponse.model_validate(shop),
|
"shop": ShopResponse.model_validate(shop),
|
||||||
}
|
}
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting products for shop {shop_code}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
# app/api/v1/stats.py
|
# app/api/v1/stats.py
|
||||||
"""Summary description ....
|
"""
|
||||||
|
Statistics endpoints - simplified with service-level exception handling.
|
||||||
|
|
||||||
This module provides classes and functions for:
|
This module provides classes and functions for:
|
||||||
- ....
|
- Comprehensive system statistics
|
||||||
- ....
|
- Marketplace-specific analytics
|
||||||
- ....
|
- Performance metrics and data insights
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
@@ -23,27 +24,22 @@ router = APIRouter()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Enhanced Statistics with Marketplace Support
|
|
||||||
@router.get("/stats", response_model=StatsResponse)
|
@router.get("/stats", response_model=StatsResponse)
|
||||||
def get_stats(
|
def get_stats(
|
||||||
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Get comprehensive statistics with marketplace data (Protected)."""
|
"""Get comprehensive statistics with marketplace data (Protected)."""
|
||||||
try:
|
stats_data = stats_service.get_comprehensive_stats(db=db)
|
||||||
stats_data = stats_service.get_comprehensive_stats(db=db)
|
|
||||||
|
|
||||||
return StatsResponse(
|
return StatsResponse(
|
||||||
total_products=stats_data["total_products"],
|
total_products=stats_data["total_products"],
|
||||||
unique_brands=stats_data["unique_brands"],
|
unique_brands=stats_data["unique_brands"],
|
||||||
unique_categories=stats_data["unique_categories"],
|
unique_categories=stats_data["unique_categories"],
|
||||||
unique_marketplaces=stats_data["unique_marketplaces"],
|
unique_marketplaces=stats_data["unique_marketplaces"],
|
||||||
unique_shops=stats_data["unique_shops"],
|
unique_shops=stats_data["unique_shops"],
|
||||||
total_stock_entries=stats_data["total_stock_entries"],
|
total_stock_entries=stats_data["total_stock_entries"],
|
||||||
total_inventory_quantity=stats_data["total_inventory_quantity"],
|
total_inventory_quantity=stats_data["total_inventory_quantity"],
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting comprehensive stats: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats/marketplace", response_model=List[MarketplaceStatsResponse])
|
@router.get("/stats/marketplace", response_model=List[MarketplaceStatsResponse])
|
||||||
@@ -51,18 +47,65 @@ def get_marketplace_stats(
|
|||||||
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Get statistics broken down by marketplace (Protected)."""
|
"""Get statistics broken down by marketplace (Protected)."""
|
||||||
try:
|
marketplace_stats = stats_service.get_marketplace_breakdown_stats(db=db)
|
||||||
marketplace_stats = stats_service.get_marketplace_breakdown_stats(db=db)
|
|
||||||
|
|
||||||
return [
|
# app/api/v1/stats.py
|
||||||
MarketplaceStatsResponse(
|
"""
|
||||||
marketplace=stat["marketplace"],
|
Statistics endpoints - simplified with service-level exception handling.
|
||||||
total_products=stat["total_products"],
|
|
||||||
unique_shops=stat["unique_shops"],
|
This module provides classes and functions for:
|
||||||
unique_brands=stat["unique_brands"],
|
- Comprehensive system statistics
|
||||||
)
|
- Marketplace-specific analytics
|
||||||
for stat in marketplace_stats
|
- Performance metrics and data insights
|
||||||
]
|
"""
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting marketplace stats: {str(e)}")
|
import logging
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.services.stats_service import stats_service
|
||||||
|
from models.schemas.stats import MarketplaceStatsResponse, StatsResponse
|
||||||
|
from models.database.user import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=StatsResponse)
|
||||||
|
def get_stats(
|
||||||
|
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get comprehensive statistics with marketplace data (Protected)."""
|
||||||
|
stats_data = stats_service.get_comprehensive_stats(db=db)
|
||||||
|
|
||||||
|
return StatsResponse(
|
||||||
|
total_products=stats_data["total_products"],
|
||||||
|
unique_brands=stats_data["unique_brands"],
|
||||||
|
unique_categories=stats_data["unique_categories"],
|
||||||
|
unique_marketplaces=stats_data["unique_marketplaces"],
|
||||||
|
unique_shops=stats_data["unique_shops"],
|
||||||
|
total_stock_entries=stats_data["total_stock_entries"],
|
||||||
|
total_inventory_quantity=stats_data["total_inventory_quantity"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/marketplace", response_model=List[MarketplaceStatsResponse])
|
||||||
|
def get_marketplace_stats(
|
||||||
|
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get statistics broken down by marketplace (Protected)."""
|
||||||
|
marketplace_stats = stats_service.get_marketplace_breakdown_stats(db=db)
|
||||||
|
|
||||||
|
return [
|
||||||
|
MarketplaceStatsResponse(
|
||||||
|
marketplace=stat["marketplace"],
|
||||||
|
total_products=stat["total_products"],
|
||||||
|
unique_shops=stat["unique_shops"],
|
||||||
|
unique_brands=stat["unique_brands"],
|
||||||
|
)
|
||||||
|
for stat in marketplace_stats
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
# app/api/v1/stock.py
|
# app/api/v1/stock.py
|
||||||
"""Summary description ....
|
"""
|
||||||
|
Stock endpoints - simplified with service-level exception handling.
|
||||||
|
|
||||||
This module provides classes and functions for:
|
This module provides classes and functions for:
|
||||||
- ....
|
- Stock quantity management (set, add, remove)
|
||||||
- ....
|
- Stock information retrieval and filtering
|
||||||
- ....
|
- Location-based stock tracking
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
@@ -24,9 +25,6 @@ router = APIRouter()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Stock Management Routes (Protected)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stock", response_model=StockResponse)
|
@router.post("/stock", response_model=StockResponse)
|
||||||
def set_stock(
|
def set_stock(
|
||||||
stock: StockCreate,
|
stock: StockCreate,
|
||||||
@@ -34,14 +32,7 @@ def set_stock(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Set exact stock quantity for a GTIN at a specific location (replaces existing quantity)."""
|
"""Set exact stock quantity for a GTIN at a specific location (replaces existing quantity)."""
|
||||||
try:
|
return stock_service.set_stock(db, stock)
|
||||||
result = stock_service.set_stock(db, stock)
|
|
||||||
return result
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error setting stock: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stock/add", response_model=StockResponse)
|
@router.post("/stock/add", response_model=StockResponse)
|
||||||
@@ -51,14 +42,7 @@ def add_stock(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Add quantity to existing stock for a GTIN at a specific location (adds to existing quantity)."""
|
"""Add quantity to existing stock for a GTIN at a specific location (adds to existing quantity)."""
|
||||||
try:
|
return stock_service.add_stock(db, stock)
|
||||||
result = stock_service.add_stock(db, stock)
|
|
||||||
return result
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error adding stock: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stock/remove", response_model=StockResponse)
|
@router.post("/stock/remove", response_model=StockResponse)
|
||||||
@@ -68,14 +52,7 @@ def remove_stock(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Remove quantity from existing stock for a GTIN at a specific location."""
|
"""Remove quantity from existing stock for a GTIN at a specific location."""
|
||||||
try:
|
return stock_service.remove_stock(db, stock)
|
||||||
result = stock_service.remove_stock(db, stock)
|
|
||||||
return result
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error removing stock: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{gtin}", response_model=StockSummaryResponse)
|
@router.get("/stock/{gtin}", response_model=StockSummaryResponse)
|
||||||
@@ -85,14 +62,7 @@ def get_stock_by_gtin(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Get all stock locations and total quantity for a specific GTIN."""
|
"""Get all stock locations and total quantity for a specific GTIN."""
|
||||||
try:
|
return stock_service.get_stock_by_gtin(db, gtin)
|
||||||
result = stock_service.get_stock_by_gtin(db, gtin)
|
|
||||||
return result
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting stock for GTIN {gtin}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{gtin}/total")
|
@router.get("/stock/{gtin}/total")
|
||||||
@@ -102,14 +72,7 @@ def get_total_stock(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Get total quantity in stock for a specific GTIN."""
|
"""Get total quantity in stock for a specific GTIN."""
|
||||||
try:
|
return stock_service.get_total_stock(db, gtin)
|
||||||
result = stock_service.get_total_stock(db, gtin)
|
|
||||||
return result
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting total stock for GTIN {gtin}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock", response_model=List[StockResponse])
|
@router.get("/stock", response_model=List[StockResponse])
|
||||||
@@ -122,14 +85,9 @@ def get_all_stock(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Get all stock entries with optional filtering."""
|
"""Get all stock entries with optional filtering."""
|
||||||
try:
|
return stock_service.get_all_stock(
|
||||||
result = stock_service.get_all_stock(
|
db=db, skip=skip, limit=limit, location=location, gtin=gtin
|
||||||
db=db, skip=skip, limit=limit, location=location, gtin=gtin
|
)
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting all stock: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/stock/{stock_id}", response_model=StockResponse)
|
@router.put("/stock/{stock_id}", response_model=StockResponse)
|
||||||
@@ -140,14 +98,7 @@ def update_stock(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Update stock quantity for a specific stock entry."""
|
"""Update stock quantity for a specific stock entry."""
|
||||||
try:
|
return stock_service.update_stock(db, stock_id, stock_update)
|
||||||
result = stock_service.update_stock(db, stock_id, stock_update)
|
|
||||||
return result
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating stock {stock_id}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/stock/{stock_id}")
|
@router.delete("/stock/{stock_id}")
|
||||||
@@ -157,11 +108,5 @@ def delete_stock(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Delete a stock entry."""
|
"""Delete a stock entry."""
|
||||||
try:
|
stock_service.delete_stock(db, stock_id)
|
||||||
stock_service.delete_stock(db, stock_id)
|
return {"message": "Stock entry deleted successfully"}
|
||||||
return {"message": "Stock entry deleted successfully"}
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deleting stock {stock_id}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|||||||
@@ -16,14 +16,17 @@ from .base import (
|
|||||||
BusinessLogicException,
|
BusinessLogicException,
|
||||||
ExternalServiceException,
|
ExternalServiceException,
|
||||||
RateLimitException,
|
RateLimitException,
|
||||||
|
ServiceUnavailableException,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .auth import (
|
from .auth import (
|
||||||
InvalidCredentialsException,
|
InvalidCredentialsException,
|
||||||
TokenExpiredException,
|
TokenExpiredException,
|
||||||
|
InvalidTokenException,
|
||||||
InsufficientPermissionsException,
|
InsufficientPermissionsException,
|
||||||
UserNotActiveException,
|
UserNotActiveException,
|
||||||
AdminRequiredException,
|
AdminRequiredException,
|
||||||
|
UserAlreadyExistsException
|
||||||
)
|
)
|
||||||
|
|
||||||
from .product import (
|
from .product import (
|
||||||
@@ -31,6 +34,8 @@ from .product import (
|
|||||||
ProductAlreadyExistsException,
|
ProductAlreadyExistsException,
|
||||||
InvalidProductDataException,
|
InvalidProductDataException,
|
||||||
ProductValidationException,
|
ProductValidationException,
|
||||||
|
InvalidGTINException,
|
||||||
|
ProductCSVImportException,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .stock import (
|
from .stock import (
|
||||||
@@ -38,6 +43,9 @@ from .stock import (
|
|||||||
InsufficientStockException,
|
InsufficientStockException,
|
||||||
InvalidStockOperationException,
|
InvalidStockOperationException,
|
||||||
StockValidationException,
|
StockValidationException,
|
||||||
|
NegativeStockException,
|
||||||
|
InvalidQuantityException,
|
||||||
|
LocationNotFoundException
|
||||||
)
|
)
|
||||||
|
|
||||||
from .shop import (
|
from .shop import (
|
||||||
@@ -47,6 +55,10 @@ from .shop import (
|
|||||||
ShopNotVerifiedException,
|
ShopNotVerifiedException,
|
||||||
UnauthorizedShopAccessException,
|
UnauthorizedShopAccessException,
|
||||||
InvalidShopDataException,
|
InvalidShopDataException,
|
||||||
|
ShopProductAlreadyExistsException,
|
||||||
|
ShopProductNotFoundException,
|
||||||
|
MaxShopsReachedException,
|
||||||
|
ShopValidationException,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .marketplace import (
|
from .marketplace import (
|
||||||
@@ -56,6 +68,11 @@ from .marketplace import (
|
|||||||
InvalidImportDataException,
|
InvalidImportDataException,
|
||||||
ImportJobCannotBeCancelledException,
|
ImportJobCannotBeCancelledException,
|
||||||
ImportJobCannotBeDeletedException,
|
ImportJobCannotBeDeletedException,
|
||||||
|
MarketplaceConnectionException,
|
||||||
|
MarketplaceDataParsingException,
|
||||||
|
ImportRateLimitException,
|
||||||
|
InvalidMarketplaceException,
|
||||||
|
ImportJobAlreadyProcessingException,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .admin import (
|
from .admin import (
|
||||||
@@ -63,6 +80,10 @@ from .admin import (
|
|||||||
UserStatusChangeException,
|
UserStatusChangeException,
|
||||||
ShopVerificationException,
|
ShopVerificationException,
|
||||||
AdminOperationException,
|
AdminOperationException,
|
||||||
|
CannotModifyAdminException,
|
||||||
|
CannotModifySelfException,
|
||||||
|
InvalidAdminActionException,
|
||||||
|
BulkOperationException,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -80,21 +101,28 @@ __all__ = [
|
|||||||
# Auth exceptions
|
# Auth exceptions
|
||||||
"InvalidCredentialsException",
|
"InvalidCredentialsException",
|
||||||
"TokenExpiredException",
|
"TokenExpiredException",
|
||||||
|
"InvalidTokenException",
|
||||||
"InsufficientPermissionsException",
|
"InsufficientPermissionsException",
|
||||||
"UserNotActiveException",
|
"UserNotActiveException",
|
||||||
"AdminRequiredException",
|
"AdminRequiredException",
|
||||||
|
"UserAlreadyExistsException",
|
||||||
|
|
||||||
# Product exceptions
|
# Product exceptions
|
||||||
"ProductNotFoundException",
|
"ProductNotFoundException",
|
||||||
"ProductAlreadyExistsException",
|
"ProductAlreadyExistsException",
|
||||||
"InvalidProductDataException",
|
"InvalidProductDataException",
|
||||||
"ProductValidationException",
|
"ProductValidationException",
|
||||||
|
"InvalidGTINException",
|
||||||
|
"ProductCSVImportException",
|
||||||
|
|
||||||
# Stock exceptions
|
# Stock exceptions
|
||||||
"StockNotFoundException",
|
"StockNotFoundException",
|
||||||
"InsufficientStockException",
|
"InsufficientStockException",
|
||||||
"InvalidStockOperationException",
|
"InvalidStockOperationException",
|
||||||
"StockValidationException",
|
"StockValidationException",
|
||||||
|
"NegativeStockException",
|
||||||
|
"InvalidQuantityException",
|
||||||
|
"LocationNotFoundException",
|
||||||
|
|
||||||
# Shop exceptions
|
# Shop exceptions
|
||||||
"ShopNotFoundException",
|
"ShopNotFoundException",
|
||||||
@@ -103,6 +131,10 @@ __all__ = [
|
|||||||
"ShopNotVerifiedException",
|
"ShopNotVerifiedException",
|
||||||
"UnauthorizedShopAccessException",
|
"UnauthorizedShopAccessException",
|
||||||
"InvalidShopDataException",
|
"InvalidShopDataException",
|
||||||
|
"ShopProductAlreadyExistsException",
|
||||||
|
"ShopProductNotFoundException",
|
||||||
|
"MaxShopsReachedException",
|
||||||
|
"ShopValidationException",
|
||||||
|
|
||||||
# Marketplace exceptions
|
# Marketplace exceptions
|
||||||
"MarketplaceImportException",
|
"MarketplaceImportException",
|
||||||
@@ -111,10 +143,19 @@ __all__ = [
|
|||||||
"InvalidImportDataException",
|
"InvalidImportDataException",
|
||||||
"ImportJobCannotBeCancelledException",
|
"ImportJobCannotBeCancelledException",
|
||||||
"ImportJobCannotBeDeletedException",
|
"ImportJobCannotBeDeletedException",
|
||||||
|
"MarketplaceConnectionException",
|
||||||
|
"MarketplaceDataParsingException",
|
||||||
|
"ImportRateLimitException",
|
||||||
|
"InvalidMarketplaceException",
|
||||||
|
"ImportJobAlreadyProcessingException",
|
||||||
|
|
||||||
# Admin exceptions
|
# Admin exceptions
|
||||||
"UserNotFoundException",
|
"UserNotFoundException",
|
||||||
"UserStatusChangeException",
|
"UserStatusChangeException",
|
||||||
"ShopVerificationException",
|
"ShopVerificationException",
|
||||||
"AdminOperationException",
|
"AdminOperationException",
|
||||||
|
"CannotModifyAdminException",
|
||||||
|
"CannotModifySelfException",
|
||||||
|
"InvalidAdminActionException",
|
||||||
|
"BulkOperationException",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,37 +1,30 @@
|
|||||||
# app/exceptions/base.py
|
# app/exceptions/base.py
|
||||||
"""
|
"""
|
||||||
Base exception classes for the LetzShop API.
|
Base exception classes for the LetzShop application.
|
||||||
|
|
||||||
Provides consistent error structure for frontend consumption with:
|
This module provides classes and functions for:
|
||||||
- Error codes for programmatic handling
|
- Base exception class with consistent error formatting
|
||||||
- User-friendly messages
|
- Common exception types for different error categories
|
||||||
- HTTP status code mapping
|
- Standardized error response structure
|
||||||
- Additional context for debugging
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
class LetzShopException(Exception):
|
class LetzShopException(Exception):
|
||||||
"""
|
"""Base exception class for all LetzShop custom exceptions."""
|
||||||
Base exception for all LetzShop API errors.
|
|
||||||
|
|
||||||
Provides consistent structure for frontend error handling.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
message: str,
|
message: str,
|
||||||
error_code: str,
|
error_code: str,
|
||||||
status_code: int = 500,
|
status_code: int = 400,
|
||||||
details: Optional[Dict[str, Any]] = None,
|
details: Optional[Dict[str, Any]] = None,
|
||||||
field: Optional[str] = None,
|
|
||||||
):
|
):
|
||||||
self.message = message
|
self.message = message
|
||||||
self.error_code = error_code
|
self.error_code = error_code
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
self.details = details or {}
|
self.details = details or {}
|
||||||
self.field = field
|
|
||||||
super().__init__(self.message)
|
super().__init__(self.message)
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
@@ -41,42 +34,44 @@ class LetzShopException(Exception):
|
|||||||
"message": self.message,
|
"message": self.message,
|
||||||
"status_code": self.status_code,
|
"status_code": self.status_code,
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.field:
|
|
||||||
result["field"] = self.field
|
|
||||||
|
|
||||||
if self.details:
|
if self.details:
|
||||||
result["details"] = self.details
|
result["details"] = self.details
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationException(LetzShopException):
|
class ValidationException(LetzShopException):
|
||||||
"""Raised when input validation fails."""
|
"""Raised when request validation fails."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
message: str = "Validation failed",
|
message: str,
|
||||||
field: Optional[str] = None,
|
field: Optional[str] = None,
|
||||||
details: Optional[Dict[str, Any]] = None,
|
details: Optional[Dict[str, Any]] = None,
|
||||||
):
|
):
|
||||||
|
validation_details = details or {}
|
||||||
|
if field:
|
||||||
|
validation_details["field"] = field
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
message=message,
|
message=message,
|
||||||
error_code="VALIDATION_ERROR",
|
error_code="VALIDATION_ERROR",
|
||||||
status_code=400,
|
status_code=422,
|
||||||
details=details,
|
details=validation_details,
|
||||||
field=field,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationException(LetzShopException):
|
class AuthenticationException(LetzShopException):
|
||||||
"""Raised when authentication fails."""
|
"""Raised when authentication fails."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
message: str = "Authentication failed",
|
message: str = "Authentication failed",
|
||||||
error_code: str = "AUTHENTICATION_FAILED",
|
error_code: str = "AUTHENTICATION_FAILED",
|
||||||
details: Optional[Dict[str, Any]] = None,
|
details: Optional[Dict[str, Any]] = None,
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
message=message,
|
message=message,
|
||||||
@@ -87,13 +82,13 @@ class AuthenticationException(LetzShopException):
|
|||||||
|
|
||||||
|
|
||||||
class AuthorizationException(LetzShopException):
|
class AuthorizationException(LetzShopException):
|
||||||
"""Raised when user lacks required permissions."""
|
"""Raised when user lacks permission for an operation."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
message: str = "Access denied",
|
message: str = "Access denied",
|
||||||
error_code: str = "ACCESS_DENIED",
|
error_code: str = "AUTHORIZATION_FAILED",
|
||||||
details: Optional[Dict[str, Any]] = None,
|
details: Optional[Dict[str, Any]] = None,
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
message=message,
|
message=message,
|
||||||
@@ -102,20 +97,18 @@ class AuthorizationException(LetzShopException):
|
|||||||
details=details,
|
details=details,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ResourceNotFoundException(LetzShopException):
|
class ResourceNotFoundException(LetzShopException):
|
||||||
"""Raised when a requested resource is not found."""
|
"""Raised when a requested resource is not found."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
resource_type: str,
|
resource_type: str,
|
||||||
identifier: str,
|
identifier: str,
|
||||||
message: Optional[str] = None,
|
message: Optional[str] = None,
|
||||||
error_code: Optional[str] = None,
|
error_code: Optional[str] = None,
|
||||||
):
|
):
|
||||||
if not message:
|
if not message:
|
||||||
message = f"{resource_type} not found"
|
message = f"{resource_type} with identifier '{identifier}' not found"
|
||||||
|
|
||||||
if not error_code:
|
if not error_code:
|
||||||
error_code = f"{resource_type.upper()}_NOT_FOUND"
|
error_code = f"{resource_type.upper()}_NOT_FOUND"
|
||||||
|
|
||||||
@@ -123,18 +116,20 @@ class ResourceNotFoundException(LetzShopException):
|
|||||||
message=message,
|
message=message,
|
||||||
error_code=error_code,
|
error_code=error_code,
|
||||||
status_code=404,
|
status_code=404,
|
||||||
details={"resource_type": resource_type, "identifier": identifier},
|
details={
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"identifier": identifier,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConflictException(LetzShopException):
|
class ConflictException(LetzShopException):
|
||||||
"""Raised when a resource conflict occurs (e.g., duplicate)."""
|
"""Raised when a resource conflict occurs."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
message: str,
|
message: str,
|
||||||
error_code: str,
|
error_code: str = "RESOURCE_CONFLICT",
|
||||||
details: Optional[Dict[str, Any]] = None,
|
details: Optional[Dict[str, Any]] = None,
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
message=message,
|
message=message,
|
||||||
@@ -143,43 +138,41 @@ class ConflictException(LetzShopException):
|
|||||||
details=details,
|
details=details,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BusinessLogicException(LetzShopException):
|
class BusinessLogicException(LetzShopException):
|
||||||
"""Raised when business logic validation fails."""
|
"""Raised when business logic rules are violated."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
message: str,
|
message: str,
|
||||||
error_code: str,
|
error_code: str,
|
||||||
details: Optional[Dict[str, Any]] = None,
|
details: Optional[Dict[str, Any]] = None,
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
message=message,
|
message=message,
|
||||||
error_code=error_code,
|
error_code=error_code,
|
||||||
status_code=422,
|
status_code=400,
|
||||||
details=details,
|
details=details,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExternalServiceException(LetzShopException):
|
class ExternalServiceException(LetzShopException):
|
||||||
"""Raised when external service calls fail."""
|
"""Raised when an external service fails."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
service: str,
|
message: str,
|
||||||
message: str = "External service unavailable",
|
service_name: str,
|
||||||
error_code: str = "EXTERNAL_SERVICE_ERROR",
|
error_code: str = "EXTERNAL_SERVICE_ERROR",
|
||||||
details: Optional[Dict[str, Any]] = None,
|
details: Optional[Dict[str, Any]] = None,
|
||||||
):
|
):
|
||||||
if not details:
|
service_details = details or {}
|
||||||
details = {}
|
service_details["service_name"] = service_name
|
||||||
details["service"] = service
|
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
message=message,
|
message=message,
|
||||||
error_code=error_code,
|
error_code=error_code,
|
||||||
status_code=503,
|
status_code=502,
|
||||||
details=details,
|
details=service_details,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -187,20 +180,32 @@ class RateLimitException(LetzShopException):
|
|||||||
"""Raised when rate limit is exceeded."""
|
"""Raised when rate limit is exceeded."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
message: str = "Rate limit exceeded",
|
message: str = "Rate limit exceeded",
|
||||||
retry_after: Optional[int] = None,
|
retry_after: Optional[int] = None,
|
||||||
details: Optional[Dict[str, Any]] = None,
|
details: Optional[Dict[str, Any]] = None,
|
||||||
):
|
):
|
||||||
if not details:
|
rate_limit_details = details or {}
|
||||||
details = {}
|
|
||||||
|
|
||||||
if retry_after:
|
if retry_after:
|
||||||
details["retry_after"] = retry_after
|
rate_limit_details["retry_after"] = retry_after
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
message=message,
|
message=message,
|
||||||
error_code="RATE_LIMIT_EXCEEDED",
|
error_code="RATE_LIMIT_EXCEEDED",
|
||||||
status_code=429,
|
status_code=429,
|
||||||
details=details,
|
details=rate_limit_details,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class ServiceUnavailableException(LetzShopException):
|
||||||
|
"""Raised when service is unavailable."""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Service temporarily unavailable"):
|
||||||
|
super().__init__(
|
||||||
|
message=message,
|
||||||
|
error_code="SERVICE_UNAVAILABLE",
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Note: Domain-specific exceptions like ShopNotFoundException, UserNotFoundException, etc.
|
||||||
|
# are defined in their respective domain modules (shop.py, admin.py, etc.)
|
||||||
|
# to keep domain-specific logic separate from base exceptions.
|
||||||
|
|||||||
@@ -3,113 +3,24 @@
|
|||||||
Exception handler for FastAPI application.
|
Exception handler for FastAPI application.
|
||||||
|
|
||||||
Provides consistent error responses and logging for all custom exceptions.
|
Provides consistent error responses and logging for all custom exceptions.
|
||||||
|
This module provides classes and functions for:
|
||||||
|
- Unified exception handling for all application exceptions
|
||||||
|
- Consistent error response formatting
|
||||||
|
- Comprehensive logging with structured data
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
from fastapi import Request, HTTPException
|
from fastapi import Request, HTTPException
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
|
||||||
|
|
||||||
from .base import LetzShopException
|
from .base import LetzShopException
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ExceptionHandlerMiddleware(BaseHTTPMiddleware):
|
|
||||||
"""Middleware to handle custom exceptions and convert them to JSON responses."""
|
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
|
||||||
try:
|
|
||||||
response = await call_next(request)
|
|
||||||
return response
|
|
||||||
except LetzShopException as exc:
|
|
||||||
return await self.handle_custom_exception(request, exc)
|
|
||||||
except HTTPException as exc:
|
|
||||||
return await self.handle_http_exception(request, exc)
|
|
||||||
except Exception as exc:
|
|
||||||
return await self.handle_generic_exception(request, exc)
|
|
||||||
|
|
||||||
async def handle_custom_exception(
|
|
||||||
self,
|
|
||||||
request: Request,
|
|
||||||
exc: LetzShopException
|
|
||||||
) -> JSONResponse:
|
|
||||||
"""Handle custom LetzShop exceptions."""
|
|
||||||
|
|
||||||
# Log the exception
|
|
||||||
logger.error(
|
|
||||||
f"Custom exception in {request.method} {request.url}: "
|
|
||||||
f"{exc.error_code} - {exc.message}",
|
|
||||||
extra={
|
|
||||||
"error_code": exc.error_code,
|
|
||||||
"status_code": exc.status_code,
|
|
||||||
"details": exc.details,
|
|
||||||
"url": str(request.url),
|
|
||||||
"method": request.method,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=exc.status_code,
|
|
||||||
content=exc.to_dict()
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_http_exception(
|
|
||||||
self,
|
|
||||||
request: Request,
|
|
||||||
exc: HTTPException
|
|
||||||
) -> JSONResponse:
|
|
||||||
"""Handle FastAPI HTTPExceptions."""
|
|
||||||
|
|
||||||
logger.error(
|
|
||||||
f"HTTP exception in {request.method} {request.url}: "
|
|
||||||
f"{exc.status_code} - {exc.detail}",
|
|
||||||
extra={
|
|
||||||
"status_code": exc.status_code,
|
|
||||||
"detail": exc.detail,
|
|
||||||
"url": str(request.url),
|
|
||||||
"method": request.method,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=exc.status_code,
|
|
||||||
content={
|
|
||||||
"error_code": f"HTTP_{exc.status_code}",
|
|
||||||
"message": exc.detail,
|
|
||||||
"status_code": exc.status_code,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_generic_exception(
|
|
||||||
self,
|
|
||||||
request: Request,
|
|
||||||
exc: Exception
|
|
||||||
) -> JSONResponse:
|
|
||||||
"""Handle unexpected exceptions."""
|
|
||||||
|
|
||||||
logger.error(
|
|
||||||
f"Unexpected exception in {request.method} {request.url}: {str(exc)}",
|
|
||||||
exc_info=True,
|
|
||||||
extra={
|
|
||||||
"url": str(request.url),
|
|
||||||
"method": request.method,
|
|
||||||
"exception_type": type(exc).__name__,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={
|
|
||||||
"error_code": "INTERNAL_SERVER_ERROR",
|
|
||||||
"message": "Internal server error",
|
|
||||||
"status_code": 500,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_exception_handlers(app):
|
def setup_exception_handlers(app):
|
||||||
"""Setup exception handlers for the FastAPI app."""
|
"""Setup exception handlers for the FastAPI app."""
|
||||||
|
|
||||||
@@ -126,6 +37,7 @@ def setup_exception_handlers(app):
|
|||||||
"details": exc.details,
|
"details": exc.details,
|
||||||
"url": str(request.url),
|
"url": str(request.url),
|
||||||
"method": request.method,
|
"method": request.method,
|
||||||
|
"exception_type": type(exc).__name__,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -146,6 +58,7 @@ def setup_exception_handlers(app):
|
|||||||
"detail": exc.detail,
|
"detail": exc.detail,
|
||||||
"url": str(request.url),
|
"url": str(request.url),
|
||||||
"method": request.method,
|
"method": request.method,
|
||||||
|
"exception_type": "HTTPException",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -158,6 +71,32 @@ def setup_exception_handlers(app):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
|
"""Handle Pydantic validation errors with consistent format."""
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
f"Validation error in {request.method} {request.url}: {exc.errors()}",
|
||||||
|
extra={
|
||||||
|
"validation_errors": exc.errors(),
|
||||||
|
"url": str(request.url),
|
||||||
|
"method": request.method,
|
||||||
|
"exception_type": "RequestValidationError",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={
|
||||||
|
"error_code": "VALIDATION_ERROR",
|
||||||
|
"message": "Request validation failed",
|
||||||
|
"status_code": 422,
|
||||||
|
"details": {
|
||||||
|
"validation_errors": exc.errors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def generic_exception_handler(request: Request, exc: Exception):
|
async def generic_exception_handler(request: Request, exc: Exception):
|
||||||
"""Handle unexpected exceptions."""
|
"""Handle unexpected exceptions."""
|
||||||
@@ -204,4 +143,4 @@ def raise_auth_error(message: str = "Authentication failed") -> None:
|
|||||||
def raise_permission_error(message: str = "Access denied") -> None:
|
def raise_permission_error(message: str = "Access denied") -> None:
|
||||||
"""Convenience function to raise AuthorizationException."""
|
"""Convenience function to raise AuthorizationException."""
|
||||||
from .base import AuthorizationException
|
from .base import AuthorizationException
|
||||||
raise AuthorizationException(message)
|
raise AuthorizationException(message)
|
||||||
@@ -1,19 +1,27 @@
|
|||||||
# app/services/admin_service.py
|
# app/services/admin_service.py
|
||||||
"""Summary description ....
|
"""
|
||||||
|
Admin service for managing users, shops, and import jobs.
|
||||||
|
|
||||||
This module provides classes and functions for:
|
This module provides classes and functions for:
|
||||||
- ....
|
- User management and status control
|
||||||
- ....
|
- Shop verification and activation
|
||||||
- ....
|
- Marketplace import job monitoring
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.exceptions import (
|
||||||
|
UserNotFoundException,
|
||||||
|
UserStatusChangeException,
|
||||||
|
CannotModifySelfException,
|
||||||
|
ShopNotFoundException,
|
||||||
|
ShopVerificationException,
|
||||||
|
AdminOperationException,
|
||||||
|
)
|
||||||
from models.schemas.marketplace import MarketplaceImportJobResponse
|
from models.schemas.marketplace import MarketplaceImportJobResponse
|
||||||
from models.database.marketplace import MarketplaceImportJob
|
from models.database.marketplace import MarketplaceImportJob
|
||||||
from models.database.shop import Shop
|
from models.database.shop import Shop
|
||||||
@@ -27,10 +35,17 @@ class AdminService:
|
|||||||
|
|
||||||
def get_all_users(self, db: Session, skip: int = 0, limit: int = 100) -> List[User]:
|
def get_all_users(self, db: Session, skip: int = 0, limit: int = 100) -> List[User]:
|
||||||
"""Get paginated list of all users."""
|
"""Get paginated list of all users."""
|
||||||
return db.query(User).offset(skip).limit(limit).all()
|
try:
|
||||||
|
return db.query(User).offset(skip).limit(limit).all()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to retrieve users: {str(e)}")
|
||||||
|
raise AdminOperationException(
|
||||||
|
operation="get_all_users",
|
||||||
|
reason="Database query failed"
|
||||||
|
)
|
||||||
|
|
||||||
def toggle_user_status(
|
def toggle_user_status(
|
||||||
self, db: Session, user_id: int, current_admin_id: int
|
self, db: Session, user_id: int, current_admin_id: int
|
||||||
) -> Tuple[User, str]:
|
) -> Tuple[User, str]:
|
||||||
"""
|
"""
|
||||||
Toggle user active status.
|
Toggle user active status.
|
||||||
@@ -44,30 +59,50 @@ class AdminService:
|
|||||||
Tuple of (updated_user, status_message)
|
Tuple of (updated_user, status_message)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If user not found or trying to deactivate own account
|
UserNotFoundException: If user not found
|
||||||
|
CannotModifySelfException: If trying to modify own account
|
||||||
|
UserStatusChangeException: If status change is not allowed
|
||||||
"""
|
"""
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
user = self._get_user_by_id_or_raise(db, user_id)
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
|
# Prevent self-modification
|
||||||
if user.id == current_admin_id:
|
if user.id == current_admin_id:
|
||||||
raise HTTPException(
|
raise CannotModifySelfException(user_id, "deactivate account")
|
||||||
status_code=400, detail="Cannot deactivate your own account"
|
|
||||||
|
# Check if user is another admin - FIXED LOGIC
|
||||||
|
if user.role == "admin" and user.id != current_admin_id:
|
||||||
|
raise UserStatusChangeException(
|
||||||
|
user_id=user_id,
|
||||||
|
current_status="admin",
|
||||||
|
attempted_action="toggle status",
|
||||||
|
reason="Cannot modify another admin user"
|
||||||
)
|
)
|
||||||
|
|
||||||
user.is_active = not user.is_active
|
try:
|
||||||
user.updated_at = datetime.utcnow()
|
original_status = user.is_active
|
||||||
db.commit()
|
user.is_active = not user.is_active
|
||||||
db.refresh(user)
|
user.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
status = "activated" if user.is_active else "deactivated"
|
status_action = "activated" if user.is_active else "deactivated"
|
||||||
logger.info(
|
message = f"User {user.username} has been {status_action}"
|
||||||
f"User {user.username} has been {status} by admin {current_admin_id}"
|
|
||||||
)
|
logger.info(f"{message} by admin {current_admin_id}")
|
||||||
return user, f"User {user.username} has been {status}"
|
return user, message
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to toggle user {user_id} status: {str(e)}")
|
||||||
|
raise UserStatusChangeException(
|
||||||
|
user_id=user_id,
|
||||||
|
current_status="active" if original_status else "inactive",
|
||||||
|
attempted_action="toggle status",
|
||||||
|
reason="Database update failed"
|
||||||
|
)
|
||||||
|
|
||||||
def get_all_shops(
|
def get_all_shops(
|
||||||
self, db: Session, skip: int = 0, limit: int = 100
|
self, db: Session, skip: int = 0, limit: int = 100
|
||||||
) -> Tuple[List[Shop], int]:
|
) -> Tuple[List[Shop], int]:
|
||||||
"""
|
"""
|
||||||
Get paginated list of all shops with total count.
|
Get paginated list of all shops with total count.
|
||||||
@@ -80,9 +115,16 @@ class AdminService:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (shops_list, total_count)
|
Tuple of (shops_list, total_count)
|
||||||
"""
|
"""
|
||||||
total = db.query(Shop).count()
|
try:
|
||||||
shops = db.query(Shop).offset(skip).limit(limit).all()
|
total = db.query(Shop).count()
|
||||||
return shops, total
|
shops = db.query(Shop).offset(skip).limit(limit).all()
|
||||||
|
return shops, total
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to retrieve shops: {str(e)}")
|
||||||
|
raise AdminOperationException(
|
||||||
|
operation="get_all_shops",
|
||||||
|
reason="Database query failed"
|
||||||
|
)
|
||||||
|
|
||||||
def verify_shop(self, db: Session, shop_id: int) -> Tuple[Shop, str]:
|
def verify_shop(self, db: Session, shop_id: int) -> Tuple[Shop, str]:
|
||||||
"""
|
"""
|
||||||
@@ -96,20 +138,37 @@ class AdminService:
|
|||||||
Tuple of (updated_shop, status_message)
|
Tuple of (updated_shop, status_message)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If shop not found
|
ShopNotFoundException: If shop not found
|
||||||
|
ShopVerificationException: If verification fails
|
||||||
"""
|
"""
|
||||||
shop = db.query(Shop).filter(Shop.id == shop_id).first()
|
shop = self._get_shop_by_id_or_raise(db, shop_id)
|
||||||
if not shop:
|
|
||||||
raise HTTPException(status_code=404, detail="Shop not found")
|
|
||||||
|
|
||||||
shop.is_verified = not shop.is_verified
|
try:
|
||||||
shop.updated_at = datetime.utcnow()
|
original_status = shop.is_verified
|
||||||
db.commit()
|
shop.is_verified = not shop.is_verified
|
||||||
db.refresh(shop)
|
shop.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
status = "verified" if shop.is_verified else "unverified"
|
# Add verification timestamp if implementing audit trail
|
||||||
logger.info(f"Shop {shop.shop_code} has been {status}")
|
if shop.is_verified:
|
||||||
return shop, f"Shop {shop.shop_code} has been {status}"
|
shop.verified_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shop)
|
||||||
|
|
||||||
|
status_action = "verified" if shop.is_verified else "unverified"
|
||||||
|
message = f"Shop {shop.shop_code} has been {status_action}"
|
||||||
|
|
||||||
|
logger.info(message)
|
||||||
|
return shop, message
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to verify shop {shop_id}: {str(e)}")
|
||||||
|
raise ShopVerificationException(
|
||||||
|
shop_id=shop_id,
|
||||||
|
reason="Database update failed",
|
||||||
|
current_verification_status=original_status
|
||||||
|
)
|
||||||
|
|
||||||
def toggle_shop_status(self, db: Session, shop_id: int) -> Tuple[Shop, str]:
|
def toggle_shop_status(self, db: Session, shop_id: int) -> Tuple[Shop, str]:
|
||||||
"""
|
"""
|
||||||
@@ -123,29 +182,42 @@ class AdminService:
|
|||||||
Tuple of (updated_shop, status_message)
|
Tuple of (updated_shop, status_message)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If shop not found
|
ShopNotFoundException: If shop not found
|
||||||
|
AdminOperationException: If status change fails
|
||||||
"""
|
"""
|
||||||
shop = db.query(Shop).filter(Shop.id == shop_id).first()
|
shop = self._get_shop_by_id_or_raise(db, shop_id)
|
||||||
if not shop:
|
|
||||||
raise HTTPException(status_code=404, detail="Shop not found")
|
|
||||||
|
|
||||||
shop.is_active = not shop.is_active
|
try:
|
||||||
shop.updated_at = datetime.utcnow()
|
original_status = shop.is_active
|
||||||
db.commit()
|
shop.is_active = not shop.is_active
|
||||||
db.refresh(shop)
|
shop.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shop)
|
||||||
|
|
||||||
status = "activated" if shop.is_active else "deactivated"
|
status_action = "activated" if shop.is_active else "deactivated"
|
||||||
logger.info(f"Shop {shop.shop_code} has been {status}")
|
message = f"Shop {shop.shop_code} has been {status_action}"
|
||||||
return shop, f"Shop {shop.shop_code} has been {status}"
|
|
||||||
|
logger.info(message)
|
||||||
|
return shop, message
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to toggle shop {shop_id} status: {str(e)}")
|
||||||
|
raise AdminOperationException(
|
||||||
|
operation="toggle_shop_status",
|
||||||
|
reason="Database update failed",
|
||||||
|
target_type="shop",
|
||||||
|
target_id=str(shop_id)
|
||||||
|
)
|
||||||
|
|
||||||
def get_marketplace_import_jobs(
|
def get_marketplace_import_jobs(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
marketplace: Optional[str] = None,
|
marketplace: Optional[str] = None,
|
||||||
shop_name: Optional[str] = None,
|
shop_name: Optional[str] = None,
|
||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> List[MarketplaceImportJobResponse]:
|
) -> List[MarketplaceImportJobResponse]:
|
||||||
"""
|
"""
|
||||||
Get filtered and paginated marketplace import jobs.
|
Get filtered and paginated marketplace import jobs.
|
||||||
@@ -161,59 +233,129 @@ class AdminService:
|
|||||||
Returns:
|
Returns:
|
||||||
List of MarketplaceImportJobResponse objects
|
List of MarketplaceImportJobResponse objects
|
||||||
"""
|
"""
|
||||||
query = db.query(MarketplaceImportJob)
|
try:
|
||||||
|
query = db.query(MarketplaceImportJob)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
if marketplace:
|
if marketplace:
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
|
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
|
||||||
|
)
|
||||||
|
if shop_name:
|
||||||
|
query = query.filter(MarketplaceImportJob.shop_name.ilike(f"%{shop_name}%"))
|
||||||
|
if status:
|
||||||
|
query = query.filter(MarketplaceImportJob.status == status)
|
||||||
|
|
||||||
|
# Order by creation date and apply pagination
|
||||||
|
jobs = (
|
||||||
|
query.order_by(MarketplaceImportJob.created_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
if shop_name:
|
|
||||||
query = query.filter(MarketplaceImportJob.shop_name.ilike(f"%{shop_name}%"))
|
|
||||||
if status:
|
|
||||||
query = query.filter(MarketplaceImportJob.status == status)
|
|
||||||
|
|
||||||
# Order by creation date and apply pagination
|
return [self._convert_job_to_response(job) for job in jobs]
|
||||||
jobs = (
|
|
||||||
query.order_by(MarketplaceImportJob.created_at.desc())
|
except Exception as e:
|
||||||
.offset(skip)
|
logger.error(f"Failed to retrieve marketplace import jobs: {str(e)}")
|
||||||
.limit(limit)
|
raise AdminOperationException(
|
||||||
.all()
|
operation="get_marketplace_import_jobs",
|
||||||
|
reason="Database query failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_user_statistics(self, db: Session) -> dict:
|
||||||
|
"""Get user statistics for admin dashboard."""
|
||||||
|
try:
|
||||||
|
total_users = db.query(User).count()
|
||||||
|
active_users = db.query(User).filter(User.is_active == True).count()
|
||||||
|
inactive_users = total_users - active_users
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_users": total_users,
|
||||||
|
"active_users": active_users,
|
||||||
|
"inactive_users": inactive_users,
|
||||||
|
"activation_rate": (active_users / total_users * 100) if total_users > 0 else 0
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get user statistics: {str(e)}")
|
||||||
|
raise AdminOperationException(
|
||||||
|
operation="get_user_statistics",
|
||||||
|
reason="Database query failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_shop_statistics(self, db: Session) -> dict:
|
||||||
|
"""Get shop statistics for admin dashboard."""
|
||||||
|
try:
|
||||||
|
total_shops = db.query(Shop).count()
|
||||||
|
active_shops = db.query(Shop).filter(Shop.is_active == True).count()
|
||||||
|
verified_shops = db.query(Shop).filter(Shop.is_verified == True).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_shops": total_shops,
|
||||||
|
"active_shops": active_shops,
|
||||||
|
"verified_shops": verified_shops,
|
||||||
|
"verification_rate": (verified_shops / total_shops * 100) if total_shops > 0 else 0
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get shop statistics: {str(e)}")
|
||||||
|
raise AdminOperationException(
|
||||||
|
operation="get_shop_statistics",
|
||||||
|
reason="Database query failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Private helper methods
|
||||||
|
def _get_user_by_id_or_raise(self, db: Session, user_id: int) -> User:
|
||||||
|
"""Get user by ID or raise UserNotFoundException."""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise UserNotFoundException(str(user_id))
|
||||||
|
return user
|
||||||
|
|
||||||
|
def _get_shop_by_id_or_raise(self, db: Session, shop_id: int) -> Shop:
|
||||||
|
"""Get shop by ID or raise ShopNotFoundException."""
|
||||||
|
shop = db.query(Shop).filter(Shop.id == shop_id).first()
|
||||||
|
if not shop:
|
||||||
|
raise ShopNotFoundException(str(shop_id), identifier_type="id")
|
||||||
|
return shop
|
||||||
|
|
||||||
|
def _convert_job_to_response(self, job: MarketplaceImportJob) -> MarketplaceImportJobResponse:
|
||||||
|
"""Convert database model to response schema."""
|
||||||
|
return MarketplaceImportJobResponse(
|
||||||
|
job_id=job.id,
|
||||||
|
status=job.status,
|
||||||
|
marketplace=job.marketplace,
|
||||||
|
shop_id=job.shop.id if job.shop else None,
|
||||||
|
shop_code=job.shop.shop_code if job.shop else None,
|
||||||
|
shop_name=job.shop_name,
|
||||||
|
imported=job.imported_count or 0,
|
||||||
|
updated=job.updated_count or 0,
|
||||||
|
total_processed=job.total_processed or 0,
|
||||||
|
error_count=job.error_count or 0,
|
||||||
|
error_message=job.error_message,
|
||||||
|
created_at=job.created_at,
|
||||||
|
started_at=job.started_at,
|
||||||
|
completed_at=job.completed_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
# Legacy methods for backward compatibility (mark as deprecated)
|
||||||
MarketplaceImportJobResponse(
|
|
||||||
job_id=job.id,
|
|
||||||
status=job.status,
|
|
||||||
marketplace=job.marketplace,
|
|
||||||
shop_id=job.shop.id,
|
|
||||||
shop_name=job.shop_name,
|
|
||||||
imported=job.imported_count or 0,
|
|
||||||
updated=job.updated_count or 0,
|
|
||||||
total_processed=job.total_processed or 0,
|
|
||||||
error_count=job.error_count or 0,
|
|
||||||
error_message=job.error_message,
|
|
||||||
created_at=job.created_at,
|
|
||||||
started_at=job.started_at,
|
|
||||||
completed_at=job.completed_at,
|
|
||||||
)
|
|
||||||
for job in jobs
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_user_by_id(self, db: Session, user_id: int) -> Optional[User]:
|
def get_user_by_id(self, db: Session, user_id: int) -> Optional[User]:
|
||||||
"""Get user by ID."""
|
"""Get user by ID. DEPRECATED: Use _get_user_by_id_or_raise instead."""
|
||||||
|
logger.warning("get_user_by_id is deprecated, use proper exception handling")
|
||||||
return db.query(User).filter(User.id == user_id).first()
|
return db.query(User).filter(User.id == user_id).first()
|
||||||
|
|
||||||
def get_shop_by_id(self, db: Session, shop_id: int) -> Optional[Shop]:
|
def get_shop_by_id(self, db: Session, shop_id: int) -> Optional[Shop]:
|
||||||
"""Get shop by ID."""
|
"""Get shop by ID. DEPRECATED: Use _get_shop_by_id_or_raise instead."""
|
||||||
|
logger.warning("get_shop_by_id is deprecated, use proper exception handling")
|
||||||
return db.query(Shop).filter(Shop.id == shop_id).first()
|
return db.query(Shop).filter(Shop.id == shop_id).first()
|
||||||
|
|
||||||
def user_exists(self, db: Session, user_id: int) -> bool:
|
def user_exists(self, db: Session, user_id: int) -> bool:
|
||||||
"""Check if user exists by ID."""
|
"""Check if user exists by ID. DEPRECATED: Use proper exception handling."""
|
||||||
|
logger.warning("user_exists is deprecated, use proper exception handling")
|
||||||
return db.query(User).filter(User.id == user_id).first() is not None
|
return db.query(User).filter(User.id == user_id).first() is not None
|
||||||
|
|
||||||
def shop_exists(self, db: Session, shop_id: int) -> bool:
|
def shop_exists(self, db: Session, shop_id: int) -> bool:
|
||||||
"""Check if shop exists by ID."""
|
"""Check if shop exists by ID. DEPRECATED: Use proper exception handling."""
|
||||||
|
logger.warning("shop_exists is deprecated, use proper exception handling")
|
||||||
return db.query(Shop).filter(Shop.id == shop_id).first() is not None
|
return db.query(Shop).filter(Shop.id == shop_id).first() is not None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
# app/services/auth_service.py
|
# app/services/auth_service.py
|
||||||
"""Summary description ....
|
"""
|
||||||
|
Authentication service for user registration and login.
|
||||||
|
|
||||||
This module provides classes and functions for:
|
This module provides classes and functions for:
|
||||||
- ....
|
- User registration with validation
|
||||||
- ....
|
- User authentication and JWT token generation
|
||||||
- ....
|
- Password management and security
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.exceptions import (
|
||||||
|
UserAlreadyExistsException,
|
||||||
|
InvalidCredentialsException,
|
||||||
|
UserNotActiveException,
|
||||||
|
ValidationException,
|
||||||
|
)
|
||||||
from middleware.auth import AuthManager
|
from middleware.auth import AuthManager
|
||||||
from models.schemas.auth import UserLogin, UserRegister
|
from models.schemas.auth import UserLogin, UserRegister
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
@@ -39,36 +45,41 @@ class AuthService:
|
|||||||
Created user object
|
Created user object
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If email or username already exists
|
UserAlreadyExistsException: If email or username already exists
|
||||||
|
ValidationException: If user data is invalid
|
||||||
"""
|
"""
|
||||||
# Check if email already exists
|
try:
|
||||||
existing_email = db.query(User).filter(User.email == user_data.email).first()
|
# Check if email already exists
|
||||||
if existing_email:
|
if self._email_exists(db, user_data.email):
|
||||||
raise HTTPException(status_code=400, detail="Email already registered")
|
raise UserAlreadyExistsException("Email already registered", field="email")
|
||||||
|
|
||||||
# Check if username already exists
|
# Check if username already exists
|
||||||
existing_username = (
|
if self._username_exists(db, user_data.username):
|
||||||
db.query(User).filter(User.username == user_data.username).first()
|
raise UserAlreadyExistsException("Username already taken", field="username")
|
||||||
)
|
|
||||||
if existing_username:
|
|
||||||
raise HTTPException(status_code=400, detail="Username already taken")
|
|
||||||
|
|
||||||
# Hash password and create user
|
# Hash password and create user
|
||||||
hashed_password = self.auth_manager.hash_password(user_data.password)
|
hashed_password = self.auth_manager.hash_password(user_data.password)
|
||||||
new_user = User(
|
new_user = User(
|
||||||
email=user_data.email,
|
email=user_data.email,
|
||||||
username=user_data.username,
|
username=user_data.username,
|
||||||
hashed_password=hashed_password,
|
hashed_password=hashed_password,
|
||||||
role="user",
|
role="user",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_user)
|
db.refresh(new_user)
|
||||||
|
|
||||||
logger.info(f"New user registered: {new_user.username}")
|
logger.info(f"New user registered: {new_user.username}")
|
||||||
return new_user
|
return new_user
|
||||||
|
|
||||||
|
except UserAlreadyExistsException:
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error registering user: {str(e)}")
|
||||||
|
raise ValidationException("Registration failed")
|
||||||
|
|
||||||
def login_user(self, db: Session, user_credentials: UserLogin) -> Dict[str, Any]:
|
def login_user(self, db: Session, user_credentials: UserLogin) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -82,53 +93,94 @@ class AuthService:
|
|||||||
Dictionary containing access token data and user object
|
Dictionary containing access token data and user object
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If authentication fails
|
InvalidCredentialsException: If authentication fails
|
||||||
|
UserNotActiveException: If user account is not active
|
||||||
"""
|
"""
|
||||||
user = self.auth_manager.authenticate_user(
|
try:
|
||||||
db, user_credentials.username, user_credentials.password
|
user = self.auth_manager.authenticate_user(
|
||||||
)
|
db, user_credentials.username, user_credentials.password
|
||||||
if not user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401, detail="Incorrect username or password"
|
|
||||||
)
|
)
|
||||||
|
if not user:
|
||||||
|
raise InvalidCredentialsException("Incorrect username or password")
|
||||||
|
|
||||||
# Create access token
|
# Check if user is active
|
||||||
token_data = self.auth_manager.create_access_token(user)
|
if not user.is_active:
|
||||||
|
raise UserNotActiveException("User account is not active")
|
||||||
|
|
||||||
logger.info(f"User logged in: {user.username}")
|
# Create access token
|
||||||
|
token_data = self.auth_manager.create_access_token(user)
|
||||||
|
|
||||||
return {"token_data": token_data, "user": user}
|
logger.info(f"User logged in: {user.username}")
|
||||||
|
return {"token_data": token_data, "user": user}
|
||||||
|
|
||||||
|
except (InvalidCredentialsException, UserNotActiveException):
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during login: {str(e)}")
|
||||||
|
raise InvalidCredentialsException()
|
||||||
|
|
||||||
def get_user_by_email(self, db: Session, email: str) -> Optional[User]:
|
def get_user_by_email(self, db: Session, email: str) -> Optional[User]:
|
||||||
"""Get user by email."""
|
"""Get user by email."""
|
||||||
return db.query(User).filter(User.email == email).first()
|
try:
|
||||||
|
return db.query(User).filter(User.email == email).first()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting user by email: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
def get_user_by_username(self, db: Session, username: str) -> Optional[User]:
|
def get_user_by_username(self, db: Session, username: str) -> Optional[User]:
|
||||||
"""Get user by username."""
|
"""Get user by username."""
|
||||||
return db.query(User).filter(User.username == username).first()
|
try:
|
||||||
|
return db.query(User).filter(User.username == username).first()
|
||||||
def email_exists(self, db: Session, email: str) -> bool:
|
except Exception as e:
|
||||||
"""Check if email already exists."""
|
logger.error(f"Error getting user by username: {str(e)}")
|
||||||
return db.query(User).filter(User.email == email).first() is not None
|
return None
|
||||||
|
|
||||||
def username_exists(self, db: Session, username: str) -> bool:
|
|
||||||
"""Check if username already exists."""
|
|
||||||
return db.query(User).filter(User.username == username).first() is not None
|
|
||||||
|
|
||||||
def authenticate_user(
|
def authenticate_user(
|
||||||
self, db: Session, username: str, password: str
|
self, db: Session, username: str, password: str
|
||||||
) -> Optional[User]:
|
) -> Optional[User]:
|
||||||
"""Authenticate user with username/password."""
|
"""Authenticate user with username/password."""
|
||||||
return self.auth_manager.authenticate_user(db, username, password)
|
try:
|
||||||
|
return self.auth_manager.authenticate_user(db, username, password)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error authenticating user: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
def create_access_token(self, user: User) -> Dict[str, Any]:
|
def create_access_token(self, user: User) -> Dict[str, Any]:
|
||||||
"""Create access token for user."""
|
"""Create access token for user."""
|
||||||
return self.auth_manager.create_access_token(user)
|
try:
|
||||||
|
return self.auth_manager.create_access_token(user)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating access token: {str(e)}")
|
||||||
|
raise ValidationException("Failed to create access token")
|
||||||
|
|
||||||
def hash_password(self, password: str) -> str:
|
def hash_password(self, password: str) -> str:
|
||||||
"""Hash password."""
|
"""Hash password."""
|
||||||
return self.auth_manager.hash_password(password)
|
try:
|
||||||
|
return self.auth_manager.hash_password(password)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error hashing password: {str(e)}")
|
||||||
|
raise ValidationException("Failed to hash password")
|
||||||
|
|
||||||
|
# Private helper methods
|
||||||
|
def _email_exists(self, db: Session, email: str) -> bool:
|
||||||
|
"""Check if email already exists."""
|
||||||
|
return db.query(User).filter(User.email == email).first() is not None
|
||||||
|
|
||||||
|
def _username_exists(self, db: Session, username: str) -> bool:
|
||||||
|
"""Check if username already exists."""
|
||||||
|
return db.query(User).filter(User.username == username).first() is not None
|
||||||
|
|
||||||
|
# Legacy methods for backward compatibility (deprecated)
|
||||||
|
def email_exists(self, db: Session, email: str) -> bool:
|
||||||
|
"""Check if email already exists. DEPRECATED: Use proper exception handling."""
|
||||||
|
logger.warning("email_exists is deprecated, use proper exception handling")
|
||||||
|
return self._email_exists(db, email)
|
||||||
|
|
||||||
|
def username_exists(self, db: Session, username: str) -> bool:
|
||||||
|
"""Check if username already exists. DEPRECATED: Use proper exception handling."""
|
||||||
|
logger.warning("username_exists is deprecated, use proper exception handling")
|
||||||
|
return self._username_exists(db, username)
|
||||||
|
|
||||||
|
|
||||||
# Create service instance following the same pattern as admin_service
|
# Create service instance following the same pattern as other services
|
||||||
auth_service = AuthService()
|
auth_service = AuthService()
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# app/services/marketplace_service.py
|
# app/services/marketplace_service.py
|
||||||
"""Summary description ....
|
"""
|
||||||
|
Marketplace service for managing import jobs and marketplace integrations.
|
||||||
|
|
||||||
This module provides classes and functions for:
|
This module provides classes and functions for:
|
||||||
- ....
|
- Import job creation and management
|
||||||
- ....
|
- Shop access validation
|
||||||
- ....
|
- Import job status tracking and updates
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -14,6 +15,15 @@ from typing import List, Optional
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.exceptions import (
|
||||||
|
ShopNotFoundException,
|
||||||
|
UnauthorizedShopAccessException,
|
||||||
|
ImportJobNotFoundException,
|
||||||
|
ImportJobNotOwnedException,
|
||||||
|
ImportJobCannotBeCancelledException,
|
||||||
|
ImportJobCannotBeDeletedException,
|
||||||
|
ValidationException,
|
||||||
|
)
|
||||||
from models.schemas.marketplace import (MarketplaceImportJobResponse,
|
from models.schemas.marketplace import (MarketplaceImportJobResponse,
|
||||||
MarketplaceImportRequest)
|
MarketplaceImportRequest)
|
||||||
from models.database.marketplace import MarketplaceImportJob
|
from models.database.marketplace import MarketplaceImportJob
|
||||||
@@ -26,171 +36,283 @@ logger = logging.getLogger(__name__)
|
|||||||
class MarketplaceService:
|
class MarketplaceService:
|
||||||
"""Service class for Marketplace operations following the application's service pattern."""
|
"""Service class for Marketplace operations following the application's service pattern."""
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Class constructor."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def validate_shop_access(self, db: Session, shop_code: str, user: User) -> Shop:
|
def validate_shop_access(self, db: Session, shop_code: str, user: User) -> Shop:
|
||||||
"""Validate that the shop exists and user has access to it."""
|
"""
|
||||||
# Explicit type hint to help type checker shop: Optional[Shop]
|
Validate that the shop exists and user has access to it.
|
||||||
# Use case-insensitive query to handle both uppercase and lowercase codes
|
|
||||||
shop: Optional[Shop] = (
|
|
||||||
db.query(Shop)
|
|
||||||
.filter(func.upper(Shop.shop_code) == shop_code.upper())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if not shop:
|
|
||||||
raise ValueError("Shop not found")
|
|
||||||
|
|
||||||
# Check permissions: admin can import for any shop, others only for their own
|
Args:
|
||||||
if user.role != "admin" and shop.owner_id != user.id:
|
db: Database session
|
||||||
raise PermissionError("Access denied to this shop")
|
shop_code: Shop code to validate
|
||||||
|
user: User requesting access
|
||||||
|
|
||||||
return shop
|
Returns:
|
||||||
|
Shop object if access is valid
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ShopNotFoundException: If shop doesn't exist
|
||||||
|
UnauthorizedShopAccessException: If user lacks access
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Use case-insensitive query to handle both uppercase and lowercase codes
|
||||||
|
shop = (
|
||||||
|
db.query(Shop)
|
||||||
|
.filter(func.upper(Shop.shop_code) == shop_code.upper())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not shop:
|
||||||
|
raise ShopNotFoundException(shop_code)
|
||||||
|
|
||||||
|
# Check permissions: admin can import for any shop, others only for their own
|
||||||
|
if user.role != "admin" and shop.owner_id != user.id:
|
||||||
|
raise UnauthorizedShopAccessException(shop_code, user.id)
|
||||||
|
|
||||||
|
return shop
|
||||||
|
|
||||||
|
except (ShopNotFoundException, UnauthorizedShopAccessException):
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error validating shop access: {str(e)}")
|
||||||
|
raise ValidationException("Failed to validate shop access")
|
||||||
|
|
||||||
def create_import_job(
|
def create_import_job(
|
||||||
self, db: Session, request: MarketplaceImportRequest, user: User
|
self, db: Session, request: MarketplaceImportRequest, user: User
|
||||||
) -> MarketplaceImportJob:
|
) -> MarketplaceImportJob:
|
||||||
"""Create a new marketplace import job."""
|
"""
|
||||||
# Validate shop access first
|
Create a new marketplace import job.
|
||||||
shop = self.validate_shop_access(db, request.shop_code, user)
|
|
||||||
|
|
||||||
# Create marketplace import job record
|
Args:
|
||||||
import_job = MarketplaceImportJob(
|
db: Database session
|
||||||
status="pending",
|
request: Import request data
|
||||||
source_url=request.url,
|
user: User creating the job
|
||||||
marketplace=request.marketplace,
|
|
||||||
shop_id=shop.id, # Foreign key to shops table
|
|
||||||
shop_name=shop.shop_name, # Use shop.shop_name (the display name)
|
|
||||||
user_id=user.id,
|
|
||||||
created_at=datetime.utcnow(),
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(import_job)
|
Returns:
|
||||||
db.commit()
|
Created MarketplaceImportJob object
|
||||||
db.refresh(import_job)
|
|
||||||
|
|
||||||
logger.info(
|
Raises:
|
||||||
f"Created marketplace import job {import_job.id}: "
|
ShopNotFoundException: If shop doesn't exist
|
||||||
f"{request.marketplace} -> {shop.shop_name} (shop_code: {shop.shop_code}) by user {user.username}"
|
UnauthorizedShopAccessException: If user lacks shop access
|
||||||
)
|
ValidationException: If job creation fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate shop access first
|
||||||
|
shop = self.validate_shop_access(db, request.shop_code, user)
|
||||||
|
|
||||||
return import_job
|
# Create marketplace import job record
|
||||||
|
import_job = MarketplaceImportJob(
|
||||||
|
status="pending",
|
||||||
|
source_url=request.url,
|
||||||
|
marketplace=request.marketplace,
|
||||||
|
shop_id=shop.id, # Foreign key to shops table
|
||||||
|
shop_name=shop.shop_name, # Use shop.shop_name (the display name)
|
||||||
|
user_id=user.id,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(import_job)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(import_job)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created marketplace import job {import_job.id}: "
|
||||||
|
f"{request.marketplace} -> {shop.shop_name} (shop_code: {shop.shop_code}) by user {user.username}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return import_job
|
||||||
|
|
||||||
|
except (ShopNotFoundException, UnauthorizedShopAccessException):
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error creating import job: {str(e)}")
|
||||||
|
raise ValidationException("Failed to create import job")
|
||||||
|
|
||||||
def get_import_job_by_id(
|
def get_import_job_by_id(
|
||||||
self, db: Session, job_id: int, user: User
|
self, db: Session, job_id: int, user: User
|
||||||
) -> MarketplaceImportJob:
|
) -> MarketplaceImportJob:
|
||||||
"""Get a marketplace import job by ID with access control."""
|
"""
|
||||||
job = (
|
Get a marketplace import job by ID with access control.
|
||||||
db.query(MarketplaceImportJob)
|
|
||||||
.filter(MarketplaceImportJob.id == job_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if not job:
|
|
||||||
raise ValueError("Marketplace import job not found")
|
|
||||||
|
|
||||||
# Users can only see their own jobs, admins can see all
|
Args:
|
||||||
if user.role != "admin" and job.user_id != user.id:
|
db: Database session
|
||||||
raise PermissionError("Access denied to this import job")
|
job_id: Import job ID
|
||||||
|
user: User requesting the job
|
||||||
|
|
||||||
return job
|
Returns:
|
||||||
|
MarketplaceImportJob object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImportJobNotFoundException: If job doesn't exist
|
||||||
|
ImportJobNotOwnedException: If user lacks access to job
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
job = (
|
||||||
|
db.query(MarketplaceImportJob)
|
||||||
|
.filter(MarketplaceImportJob.id == job_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not job:
|
||||||
|
raise ImportJobNotFoundException(job_id)
|
||||||
|
|
||||||
|
# Users can only see their own jobs, admins can see all
|
||||||
|
if user.role != "admin" and job.user_id != user.id:
|
||||||
|
raise ImportJobNotOwnedException(job_id, user.id)
|
||||||
|
|
||||||
|
return job
|
||||||
|
|
||||||
|
except (ImportJobNotFoundException, ImportJobNotOwnedException):
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting import job {job_id}: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve import job")
|
||||||
|
|
||||||
def get_import_jobs(
|
def get_import_jobs(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
user: User,
|
user: User,
|
||||||
marketplace: Optional[str] = None,
|
marketplace: Optional[str] = None,
|
||||||
shop_name: Optional[str] = None,
|
shop_name: Optional[str] = None,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> List[MarketplaceImportJob]:
|
) -> List[MarketplaceImportJob]:
|
||||||
"""Get marketplace import jobs with filtering and access control."""
|
"""
|
||||||
query = db.query(MarketplaceImportJob)
|
Get marketplace import jobs with filtering and access control.
|
||||||
|
|
||||||
# Users can only see their own jobs, admins can see all
|
Args:
|
||||||
if user.role != "admin":
|
db: Database session
|
||||||
query = query.filter(MarketplaceImportJob.user_id == user.id)
|
user: User requesting jobs
|
||||||
|
marketplace: Optional marketplace filter
|
||||||
|
shop_name: Optional shop name filter
|
||||||
|
skip: Number of records to skip
|
||||||
|
limit: Maximum records to return
|
||||||
|
|
||||||
# Apply filters
|
Returns:
|
||||||
if marketplace:
|
List of MarketplaceImportJob objects
|
||||||
query = query.filter(
|
"""
|
||||||
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
|
try:
|
||||||
|
query = db.query(MarketplaceImportJob)
|
||||||
|
|
||||||
|
# Users can only see their own jobs, admins can see all
|
||||||
|
if user.role != "admin":
|
||||||
|
query = query.filter(MarketplaceImportJob.user_id == user.id)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if marketplace:
|
||||||
|
query = query.filter(
|
||||||
|
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
|
||||||
|
)
|
||||||
|
if shop_name:
|
||||||
|
query = query.filter(MarketplaceImportJob.shop_name.ilike(f"%{shop_name}%"))
|
||||||
|
|
||||||
|
# Order by creation date (newest first) and apply pagination
|
||||||
|
jobs = (
|
||||||
|
query.order_by(MarketplaceImportJob.created_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
if shop_name:
|
|
||||||
query = query.filter(MarketplaceImportJob.shop_name.ilike(f"%{shop_name}%"))
|
|
||||||
|
|
||||||
# Order by creation date (newest first) and apply pagination
|
return jobs
|
||||||
jobs = (
|
|
||||||
query.order_by(MarketplaceImportJob.created_at.desc())
|
|
||||||
.offset(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
return jobs
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting import jobs: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve import jobs")
|
||||||
|
|
||||||
def update_job_status(
|
def update_job_status(
|
||||||
self, db: Session, job_id: int, status: str, **kwargs
|
self, db: Session, job_id: int, status: str, **kwargs
|
||||||
) -> MarketplaceImportJob:
|
) -> MarketplaceImportJob:
|
||||||
"""Update marketplace import job status and other fields."""
|
"""
|
||||||
job = (
|
Update marketplace import job status and other fields.
|
||||||
db.query(MarketplaceImportJob)
|
|
||||||
.filter(MarketplaceImportJob.id == job_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if not job:
|
|
||||||
raise ValueError("Marketplace import job not found")
|
|
||||||
|
|
||||||
job.status = status
|
Args:
|
||||||
|
db: Database session
|
||||||
|
job_id: Import job ID
|
||||||
|
status: New status
|
||||||
|
**kwargs: Additional fields to update
|
||||||
|
|
||||||
# Update optional fields if provided
|
Returns:
|
||||||
if "imported_count" in kwargs:
|
Updated MarketplaceImportJob object
|
||||||
job.imported_count = kwargs["imported_count"]
|
|
||||||
if "updated_count" in kwargs:
|
|
||||||
job.updated_count = kwargs["updated_count"]
|
|
||||||
if "total_processed" in kwargs:
|
|
||||||
job.total_processed = kwargs["total_processed"]
|
|
||||||
if "error_count" in kwargs:
|
|
||||||
job.error_count = kwargs["error_count"]
|
|
||||||
if "error_message" in kwargs:
|
|
||||||
job.error_message = kwargs["error_message"]
|
|
||||||
if "started_at" in kwargs:
|
|
||||||
job.started_at = kwargs["started_at"]
|
|
||||||
if "completed_at" in kwargs:
|
|
||||||
job.completed_at = kwargs["completed_at"]
|
|
||||||
|
|
||||||
db.commit()
|
Raises:
|
||||||
db.refresh(job)
|
ImportJobNotFoundException: If job doesn't exist
|
||||||
|
ValidationException: If update fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
job = (
|
||||||
|
db.query(MarketplaceImportJob)
|
||||||
|
.filter(MarketplaceImportJob.id == job_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Updated marketplace import job {job_id} status to {status}")
|
if not job:
|
||||||
return job
|
raise ImportJobNotFoundException(job_id)
|
||||||
|
|
||||||
|
job.status = status
|
||||||
|
|
||||||
|
# Update optional fields if provided
|
||||||
|
allowed_fields = [
|
||||||
|
'imported_count', 'updated_count', 'total_processed',
|
||||||
|
'error_count', 'error_message', 'started_at', 'completed_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
for field in allowed_fields:
|
||||||
|
if field in kwargs:
|
||||||
|
setattr(job, field, kwargs[field])
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
|
||||||
|
logger.info(f"Updated marketplace import job {job_id} status to {status}")
|
||||||
|
return job
|
||||||
|
|
||||||
|
except ImportJobNotFoundException:
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error updating job {job_id} status: {str(e)}")
|
||||||
|
raise ValidationException("Failed to update job status")
|
||||||
|
|
||||||
def get_job_stats(self, db: Session, user: User) -> dict:
|
def get_job_stats(self, db: Session, user: User) -> dict:
|
||||||
"""Get statistics about marketplace import jobs for a user."""
|
"""
|
||||||
query = db.query(MarketplaceImportJob)
|
Get statistics about marketplace import jobs for a user.
|
||||||
|
|
||||||
# Users can only see their own jobs, admins can see all
|
Args:
|
||||||
if user.role != "admin":
|
db: Database session
|
||||||
query = query.filter(MarketplaceImportJob.user_id == user.id)
|
user: User to get stats for
|
||||||
|
|
||||||
total_jobs = query.count()
|
Returns:
|
||||||
pending_jobs = query.filter(MarketplaceImportJob.status == "pending").count()
|
Dictionary containing job statistics
|
||||||
running_jobs = query.filter(MarketplaceImportJob.status == "running").count()
|
"""
|
||||||
completed_jobs = query.filter(
|
try:
|
||||||
MarketplaceImportJob.status == "completed"
|
query = db.query(MarketplaceImportJob)
|
||||||
).count()
|
|
||||||
failed_jobs = query.filter(MarketplaceImportJob.status == "failed").count()
|
|
||||||
|
|
||||||
return {
|
# Users can only see their own jobs, admins can see all
|
||||||
"total_jobs": total_jobs,
|
if user.role != "admin":
|
||||||
"pending_jobs": pending_jobs,
|
query = query.filter(MarketplaceImportJob.user_id == user.id)
|
||||||
"running_jobs": running_jobs,
|
|
||||||
"completed_jobs": completed_jobs,
|
total_jobs = query.count()
|
||||||
"failed_jobs": failed_jobs,
|
pending_jobs = query.filter(MarketplaceImportJob.status == "pending").count()
|
||||||
}
|
running_jobs = query.filter(MarketplaceImportJob.status == "running").count()
|
||||||
|
completed_jobs = query.filter(
|
||||||
|
MarketplaceImportJob.status == "completed"
|
||||||
|
).count()
|
||||||
|
failed_jobs = query.filter(MarketplaceImportJob.status == "failed").count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_jobs": total_jobs,
|
||||||
|
"pending_jobs": pending_jobs,
|
||||||
|
"running_jobs": running_jobs,
|
||||||
|
"completed_jobs": completed_jobs,
|
||||||
|
"failed_jobs": failed_jobs,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting job stats: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve job statistics")
|
||||||
|
|
||||||
def convert_to_response_model(
|
def convert_to_response_model(
|
||||||
self, job: MarketplaceImportJob
|
self, job: MarketplaceImportJob
|
||||||
) -> MarketplaceImportJobResponse:
|
) -> MarketplaceImportJobResponse:
|
||||||
"""Convert database model to API response model."""
|
"""Convert database model to API response model."""
|
||||||
return MarketplaceImportJobResponse(
|
return MarketplaceImportJobResponse(
|
||||||
@@ -213,38 +335,82 @@ class MarketplaceService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def cancel_import_job(
|
def cancel_import_job(
|
||||||
self, db: Session, job_id: int, user: User
|
self, db: Session, job_id: int, user: User
|
||||||
) -> MarketplaceImportJob:
|
) -> MarketplaceImportJob:
|
||||||
"""Cancel a pending or running import job."""
|
"""
|
||||||
job = self.get_import_job_by_id(db, job_id, user)
|
Cancel a pending or running import job.
|
||||||
|
|
||||||
if job.status not in ["pending", "running"]:
|
Args:
|
||||||
raise ValueError(f"Cannot cancel job with status: {job.status}")
|
db: Database session
|
||||||
|
job_id: Import job ID
|
||||||
|
user: User requesting cancellation
|
||||||
|
|
||||||
job.status = "cancelled"
|
Returns:
|
||||||
job.completed_at = datetime.utcnow()
|
Updated MarketplaceImportJob object
|
||||||
|
|
||||||
db.commit()
|
Raises:
|
||||||
db.refresh(job)
|
ImportJobNotFoundException: If job doesn't exist
|
||||||
|
ImportJobNotOwnedException: If user lacks access
|
||||||
|
ImportJobCannotBeCancelledException: If job can't be cancelled
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
job = self.get_import_job_by_id(db, job_id, user)
|
||||||
|
|
||||||
logger.info(f"Cancelled marketplace import job {job_id}")
|
if job.status not in ["pending", "running"]:
|
||||||
return job
|
raise ImportJobCannotBeCancelledException(job_id, job.status)
|
||||||
|
|
||||||
|
job.status = "cancelled"
|
||||||
|
job.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
|
||||||
|
logger.info(f"Cancelled marketplace import job {job_id}")
|
||||||
|
return job
|
||||||
|
|
||||||
|
except (ImportJobNotFoundException, ImportJobNotOwnedException, ImportJobCannotBeCancelledException):
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error cancelling job {job_id}: {str(e)}")
|
||||||
|
raise ValidationException("Failed to cancel import job")
|
||||||
|
|
||||||
def delete_import_job(self, db: Session, job_id: int, user: User) -> bool:
|
def delete_import_job(self, db: Session, job_id: int, user: User) -> bool:
|
||||||
"""Delete a marketplace import job."""
|
"""
|
||||||
job = self.get_import_job_by_id(db, job_id, user)
|
Delete a marketplace import job.
|
||||||
|
|
||||||
# Only allow deletion of completed, failed, or cancelled jobs
|
Args:
|
||||||
if job.status in ["pending", "running"]:
|
db: Database session
|
||||||
raise ValueError(
|
job_id: Import job ID
|
||||||
f"Cannot delete job with status: {job.status}. Cancel it first."
|
user: User requesting deletion
|
||||||
)
|
|
||||||
|
|
||||||
db.delete(job)
|
Returns:
|
||||||
db.commit()
|
True if deletion successful
|
||||||
|
|
||||||
logger.info(f"Deleted marketplace import job {job_id}")
|
Raises:
|
||||||
return True
|
ImportJobNotFoundException: If job doesn't exist
|
||||||
|
ImportJobNotOwnedException: If user lacks access
|
||||||
|
ImportJobCannotBeDeletedException: If job can't be deleted
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
job = self.get_import_job_by_id(db, job_id, user)
|
||||||
|
|
||||||
|
# Only allow deletion of completed, failed, or cancelled jobs
|
||||||
|
if job.status in ["pending", "running"]:
|
||||||
|
raise ImportJobCannotBeDeletedException(job_id, job.status)
|
||||||
|
|
||||||
|
db.delete(job)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Deleted marketplace import job {job_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except (ImportJobNotFoundException, ImportJobNotOwnedException, ImportJobCannotBeDeletedException):
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error deleting job {job_id}: {str(e)}")
|
||||||
|
raise ValidationException("Failed to delete import job")
|
||||||
|
|
||||||
|
|
||||||
# Create service instance
|
# Create service instance
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
# app/services/product_service.py
|
# app/services/product_service.py
|
||||||
"""Summary description ....
|
"""
|
||||||
|
Product service for managing product operations and data processing.
|
||||||
|
|
||||||
This module provides classes and functions for:
|
This module provides classes and functions for:
|
||||||
- ....
|
- Product CRUD operations with validation
|
||||||
- ....
|
- Advanced product filtering and search
|
||||||
- ....
|
- Stock information integration
|
||||||
|
- CSV export functionality
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Generator, List, Optional
|
from typing import Generator, List, Optional, Tuple
|
||||||
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.exceptions import (
|
||||||
|
ProductNotFoundException,
|
||||||
|
ProductAlreadyExistsException,
|
||||||
|
InvalidProductDataException,
|
||||||
|
ProductValidationException,
|
||||||
|
ValidationException,
|
||||||
|
)
|
||||||
from models.schemas.product import ProductCreate, ProductUpdate
|
from models.schemas.product import ProductCreate, ProductUpdate
|
||||||
from models.schemas.stock import StockLocationResponse, StockSummaryResponse
|
from models.schemas.stock import StockLocationResponse, StockSummaryResponse
|
||||||
from models.database.product import Product
|
from models.database.product import Product
|
||||||
@@ -32,28 +41,52 @@ class ProductService:
|
|||||||
self.price_processor = PriceProcessor()
|
self.price_processor = PriceProcessor()
|
||||||
|
|
||||||
def create_product(self, db: Session, product_data: ProductCreate) -> Product:
|
def create_product(self, db: Session, product_data: ProductCreate) -> Product:
|
||||||
"""Create a new product with validation."""
|
"""
|
||||||
|
Create a new product with validation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
product_data: Product creation data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Product object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProductAlreadyExistsException: If product with ID already exists
|
||||||
|
InvalidProductDataException: If product data is invalid
|
||||||
|
ProductValidationException: If validation fails
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Process and validate GTIN if provided
|
# Process and validate GTIN if provided
|
||||||
if product_data.gtin:
|
if product_data.gtin:
|
||||||
normalized_gtin = self.gtin_processor.normalize(product_data.gtin)
|
normalized_gtin = self.gtin_processor.normalize(product_data.gtin)
|
||||||
if not normalized_gtin:
|
if not normalized_gtin:
|
||||||
raise ValueError("Invalid GTIN format")
|
raise InvalidProductDataException("Invalid GTIN format", field="gtin")
|
||||||
product_data.gtin = normalized_gtin
|
product_data.gtin = normalized_gtin
|
||||||
|
|
||||||
# Process price if provided
|
# Process price if provided
|
||||||
if product_data.price:
|
if product_data.price:
|
||||||
parsed_price, currency = self.price_processor.parse_price_currency(
|
try:
|
||||||
product_data.price
|
parsed_price, currency = self.price_processor.parse_price_currency(
|
||||||
)
|
product_data.price
|
||||||
if parsed_price:
|
)
|
||||||
product_data.price = parsed_price
|
if parsed_price:
|
||||||
product_data.currency = currency
|
product_data.price = parsed_price
|
||||||
|
product_data.currency = currency
|
||||||
|
except Exception as e:
|
||||||
|
raise InvalidProductDataException(f"Invalid price format: {str(e)}", field="price")
|
||||||
|
|
||||||
# Set default marketplace if not provided
|
# Set default marketplace if not provided
|
||||||
if not product_data.marketplace:
|
if not product_data.marketplace:
|
||||||
product_data.marketplace = "Letzshop"
|
product_data.marketplace = "Letzshop"
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
if not product_data.product_id or not product_data.product_id.strip():
|
||||||
|
raise ProductValidationException("Product ID is required", field="product_id")
|
||||||
|
|
||||||
|
if not product_data.title or not product_data.title.strip():
|
||||||
|
raise ProductValidationException("Product title is required", field="title")
|
||||||
|
|
||||||
db_product = Product(**product_data.model_dump())
|
db_product = Product(**product_data.model_dump())
|
||||||
db.add(db_product)
|
db.add(db_product)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -62,176 +95,327 @@ class ProductService:
|
|||||||
logger.info(f"Created product {db_product.product_id}")
|
logger.info(f"Created product {db_product.product_id}")
|
||||||
return db_product
|
return db_product
|
||||||
|
|
||||||
|
except (InvalidProductDataException, ProductValidationException):
|
||||||
|
db.rollback()
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
logger.error(f"Database integrity error: {str(e)}")
|
logger.error(f"Database integrity error: {str(e)}")
|
||||||
raise ValueError("Product with this ID already exists")
|
if "product_id" in str(e).lower() or "unique" in str(e).lower():
|
||||||
|
raise ProductAlreadyExistsException(product_data.product_id)
|
||||||
|
else:
|
||||||
|
raise ProductValidationException("Data integrity constraint violation")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
logger.error(f"Error creating product: {str(e)}")
|
logger.error(f"Error creating product: {str(e)}")
|
||||||
raise
|
raise ValidationException("Failed to create product")
|
||||||
|
|
||||||
def get_product_by_id(self, db: Session, product_id: str) -> Optional[Product]:
|
def get_product_by_id(self, db: Session, product_id: str) -> Optional[Product]:
|
||||||
"""Get a product by its ID."""
|
"""Get a product by its ID."""
|
||||||
return db.query(Product).filter(Product.product_id == product_id).first()
|
try:
|
||||||
|
return db.query(Product).filter(Product.product_id == product_id).first()
|
||||||
def get_products_with_filters(
|
except Exception as e:
|
||||||
self,
|
logger.error(f"Error getting product {product_id}: {str(e)}")
|
||||||
db: Session,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
brand: Optional[str] = None,
|
|
||||||
category: Optional[str] = None,
|
|
||||||
availability: Optional[str] = None,
|
|
||||||
marketplace: Optional[str] = None,
|
|
||||||
shop_name: Optional[str] = None,
|
|
||||||
search: Optional[str] = None,
|
|
||||||
) -> tuple[List[Product], int]:
|
|
||||||
"""Get products with filtering and pagination."""
|
|
||||||
query = db.query(Product)
|
|
||||||
|
|
||||||
# Apply filters
|
|
||||||
if brand:
|
|
||||||
query = query.filter(Product.brand.ilike(f"%{brand}%"))
|
|
||||||
if category:
|
|
||||||
query = query.filter(Product.google_product_category.ilike(f"%{category}%"))
|
|
||||||
if availability:
|
|
||||||
query = query.filter(Product.availability == availability)
|
|
||||||
if marketplace:
|
|
||||||
query = query.filter(Product.marketplace.ilike(f"%{marketplace}%"))
|
|
||||||
if shop_name:
|
|
||||||
query = query.filter(Product.shop_name.ilike(f"%{shop_name}%"))
|
|
||||||
if search:
|
|
||||||
# Search in title, description, marketplace, and shop_name
|
|
||||||
search_term = f"%{search}%"
|
|
||||||
query = query.filter(
|
|
||||||
(Product.title.ilike(search_term))
|
|
||||||
| (Product.description.ilike(search_term))
|
|
||||||
| (Product.marketplace.ilike(search_term))
|
|
||||||
| (Product.shop_name.ilike(search_term))
|
|
||||||
)
|
|
||||||
|
|
||||||
total = query.count()
|
|
||||||
products = query.offset(skip).limit(limit).all()
|
|
||||||
|
|
||||||
return products, total
|
|
||||||
|
|
||||||
def update_product(
|
|
||||||
self, db: Session, product_id: str, product_update: ProductUpdate
|
|
||||||
) -> Product:
|
|
||||||
"""Update product with validation."""
|
|
||||||
product = db.query(Product).filter(Product.product_id == product_id).first()
|
|
||||||
if not product:
|
|
||||||
raise ValueError("Product not found")
|
|
||||||
|
|
||||||
# Update fields
|
|
||||||
update_data = product_update.model_dump(exclude_unset=True)
|
|
||||||
|
|
||||||
# Validate GTIN if being updated
|
|
||||||
if "gtin" in update_data and update_data["gtin"]:
|
|
||||||
normalized_gtin = self.gtin_processor.normalize(update_data["gtin"])
|
|
||||||
if not normalized_gtin:
|
|
||||||
raise ValueError("Invalid GTIN format")
|
|
||||||
update_data["gtin"] = normalized_gtin
|
|
||||||
|
|
||||||
# Process price if being updated
|
|
||||||
if "price" in update_data and update_data["price"]:
|
|
||||||
parsed_price, currency = self.price_processor.parse_price_currency(
|
|
||||||
update_data["price"]
|
|
||||||
)
|
|
||||||
if parsed_price:
|
|
||||||
update_data["price"] = parsed_price
|
|
||||||
update_data["currency"] = currency
|
|
||||||
|
|
||||||
for key, value in update_data.items():
|
|
||||||
setattr(product, key, value)
|
|
||||||
|
|
||||||
product.updated_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
db.refresh(product)
|
|
||||||
|
|
||||||
logger.info(f"Updated product {product_id}")
|
|
||||||
return product
|
|
||||||
|
|
||||||
def delete_product(self, db: Session, product_id: str) -> bool:
|
|
||||||
"""Delete product and associated stock."""
|
|
||||||
product = db.query(Product).filter(Product.product_id == product_id).first()
|
|
||||||
if not product:
|
|
||||||
raise ValueError("Product not found")
|
|
||||||
|
|
||||||
# Delete associated stock entries if GTIN exists
|
|
||||||
if product.gtin:
|
|
||||||
db.query(Stock).filter(Stock.gtin == product.gtin).delete()
|
|
||||||
|
|
||||||
db.delete(product)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
logger.info(f"Deleted product {product_id}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_stock_info(self, db: Session, gtin: str) -> Optional[StockSummaryResponse]:
|
|
||||||
"""Get stock information for a product by GTIN."""
|
|
||||||
stock_entries = db.query(Stock).filter(Stock.gtin == gtin).all()
|
|
||||||
if not stock_entries:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
total_quantity = sum(entry.quantity for entry in stock_entries)
|
def get_product_by_id_or_raise(self, db: Session, product_id: str) -> Product:
|
||||||
locations = [
|
"""
|
||||||
StockLocationResponse(location=entry.location, quantity=entry.quantity)
|
Get a product by its ID or raise exception.
|
||||||
for entry in stock_entries
|
|
||||||
]
|
|
||||||
|
|
||||||
return StockSummaryResponse(
|
Args:
|
||||||
gtin=gtin, total_quantity=total_quantity, locations=locations
|
db: Database session
|
||||||
)
|
product_id: Product ID to find
|
||||||
|
|
||||||
def generate_csv_export(
|
Returns:
|
||||||
self,
|
Product object
|
||||||
db: Session,
|
|
||||||
marketplace: Optional[str] = None,
|
|
||||||
shop_name: Optional[str] = None,
|
|
||||||
) -> Generator[str, None, None]:
|
|
||||||
"""Generate CSV export with streaming for memory efficiency."""
|
|
||||||
# CSV header
|
|
||||||
yield (
|
|
||||||
"product_id,title,description,link,image_link,availability,price,currency,brand,"
|
|
||||||
"gtin,marketplace,shop_name\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
batch_size = 1000
|
Raises:
|
||||||
offset = 0
|
ProductNotFoundException: If product doesn't exist
|
||||||
|
"""
|
||||||
|
product = self.get_product_by_id(db, product_id)
|
||||||
|
if not product:
|
||||||
|
raise ProductNotFoundException(product_id)
|
||||||
|
return product
|
||||||
|
|
||||||
while True:
|
def get_products_with_filters(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
brand: Optional[str] = None,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
availability: Optional[str] = None,
|
||||||
|
marketplace: Optional[str] = None,
|
||||||
|
shop_name: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
) -> Tuple[List[Product], int]:
|
||||||
|
"""
|
||||||
|
Get products with filtering and pagination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
skip: Number of records to skip
|
||||||
|
limit: Maximum records to return
|
||||||
|
brand: Brand filter
|
||||||
|
category: Category filter
|
||||||
|
availability: Availability filter
|
||||||
|
marketplace: Marketplace filter
|
||||||
|
shop_name: Shop name filter
|
||||||
|
search: Search term
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (products_list, total_count)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
query = db.query(Product)
|
query = db.query(Product)
|
||||||
|
|
||||||
# Apply marketplace filters
|
# Apply filters
|
||||||
|
if brand:
|
||||||
|
query = query.filter(Product.brand.ilike(f"%{brand}%"))
|
||||||
|
if category:
|
||||||
|
query = query.filter(Product.google_product_category.ilike(f"%{category}%"))
|
||||||
|
if availability:
|
||||||
|
query = query.filter(Product.availability == availability)
|
||||||
if marketplace:
|
if marketplace:
|
||||||
query = query.filter(Product.marketplace.ilike(f"%{marketplace}%"))
|
query = query.filter(Product.marketplace.ilike(f"%{marketplace}%"))
|
||||||
if shop_name:
|
if shop_name:
|
||||||
query = query.filter(Product.shop_name.ilike(f"%{shop_name}%"))
|
query = query.filter(Product.shop_name.ilike(f"%{shop_name}%"))
|
||||||
|
if search:
|
||||||
products = query.offset(offset).limit(batch_size).all()
|
# Search in title, description, marketplace, and shop_name
|
||||||
if not products:
|
search_term = f"%{search}%"
|
||||||
break
|
query = query.filter(
|
||||||
|
(Product.title.ilike(search_term))
|
||||||
for product in products:
|
| (Product.description.ilike(search_term))
|
||||||
# Create CSV row with marketplace fields
|
| (Product.marketplace.ilike(search_term))
|
||||||
row = (
|
| (Product.shop_name.ilike(search_term))
|
||||||
f'"{product.product_id}","{product.title or ""}","{product.description or ""}",'
|
|
||||||
f'"{product.link or ""}","{product.image_link or ""}","{product.availability or ""}",'
|
|
||||||
f'"{product.price or ""}","{product.currency or ""}","{product.brand or ""}",'
|
|
||||||
f'"{product.gtin or ""}","{product.marketplace or ""}","{product.shop_name or ""}"\n'
|
|
||||||
)
|
)
|
||||||
yield row
|
|
||||||
|
|
||||||
offset += batch_size
|
total = query.count()
|
||||||
|
products = query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
return products, total
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting products with filters: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve products")
|
||||||
|
|
||||||
|
def update_product(
|
||||||
|
self, db: Session, product_id: str, product_update: ProductUpdate
|
||||||
|
) -> Product:
|
||||||
|
"""
|
||||||
|
Update product with validation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
product_id: Product ID to update
|
||||||
|
product_update: Update data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Product object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProductNotFoundException: If product doesn't exist
|
||||||
|
InvalidProductDataException: If update data is invalid
|
||||||
|
ProductValidationException: If validation fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
product = self.get_product_by_id_or_raise(db, product_id)
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
update_data = product_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Validate GTIN if being updated
|
||||||
|
if "gtin" in update_data and update_data["gtin"]:
|
||||||
|
normalized_gtin = self.gtin_processor.normalize(update_data["gtin"])
|
||||||
|
if not normalized_gtin:
|
||||||
|
raise InvalidProductDataException("Invalid GTIN format", field="gtin")
|
||||||
|
update_data["gtin"] = normalized_gtin
|
||||||
|
|
||||||
|
# Process price if being updated
|
||||||
|
if "price" in update_data and update_data["price"]:
|
||||||
|
try:
|
||||||
|
parsed_price, currency = self.price_processor.parse_price_currency(
|
||||||
|
update_data["price"]
|
||||||
|
)
|
||||||
|
if parsed_price:
|
||||||
|
update_data["price"] = parsed_price
|
||||||
|
update_data["currency"] = currency
|
||||||
|
except Exception as e:
|
||||||
|
raise InvalidProductDataException(f"Invalid price format: {str(e)}", field="price")
|
||||||
|
|
||||||
|
# Validate required fields if being updated
|
||||||
|
if "title" in update_data and (not update_data["title"] or not update_data["title"].strip()):
|
||||||
|
raise ProductValidationException("Product title cannot be empty", field="title")
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(product, key, value)
|
||||||
|
|
||||||
|
product.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
|
||||||
|
logger.info(f"Updated product {product_id}")
|
||||||
|
return product
|
||||||
|
|
||||||
|
except (ProductNotFoundException, InvalidProductDataException, ProductValidationException):
|
||||||
|
db.rollback()
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error updating product {product_id}: {str(e)}")
|
||||||
|
raise ValidationException("Failed to update product")
|
||||||
|
|
||||||
|
def delete_product(self, db: Session, product_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete product and associated stock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
product_id: Product ID to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deletion successful
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProductNotFoundException: If product doesn't exist
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
product = self.get_product_by_id_or_raise(db, product_id)
|
||||||
|
|
||||||
|
# Delete associated stock entries if GTIN exists
|
||||||
|
if product.gtin:
|
||||||
|
db.query(Stock).filter(Stock.gtin == product.gtin).delete()
|
||||||
|
|
||||||
|
db.delete(product)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Deleted product {product_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except ProductNotFoundException:
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error deleting product {product_id}: {str(e)}")
|
||||||
|
raise ValidationException("Failed to delete product")
|
||||||
|
|
||||||
|
def get_stock_info(self, db: Session, gtin: str) -> Optional[StockSummaryResponse]:
|
||||||
|
"""
|
||||||
|
Get stock information for a product by GTIN.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
gtin: GTIN to look up stock for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StockSummaryResponse if stock found, None otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stock_entries = db.query(Stock).filter(Stock.gtin == gtin).all()
|
||||||
|
if not stock_entries:
|
||||||
|
return None
|
||||||
|
|
||||||
|
total_quantity = sum(entry.quantity for entry in stock_entries)
|
||||||
|
locations = [
|
||||||
|
StockLocationResponse(location=entry.location, quantity=entry.quantity)
|
||||||
|
for entry in stock_entries
|
||||||
|
]
|
||||||
|
|
||||||
|
return StockSummaryResponse(
|
||||||
|
gtin=gtin, total_quantity=total_quantity, locations=locations
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting stock info for GTIN {gtin}: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def generate_csv_export(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
marketplace: Optional[str] = None,
|
||||||
|
shop_name: Optional[str] = None,
|
||||||
|
) -> Generator[str, None, None]:
|
||||||
|
"""
|
||||||
|
Generate CSV export with streaming for memory efficiency.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
marketplace: Optional marketplace filter
|
||||||
|
shop_name: Optional shop name filter
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
CSV content as strings
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# CSV header
|
||||||
|
yield (
|
||||||
|
"product_id,title,description,link,image_link,availability,price,currency,brand,"
|
||||||
|
"gtin,marketplace,shop_name\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
batch_size = 1000
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
query = db.query(Product)
|
||||||
|
|
||||||
|
# Apply marketplace filters
|
||||||
|
if marketplace:
|
||||||
|
query = query.filter(Product.marketplace.ilike(f"%{marketplace}%"))
|
||||||
|
if shop_name:
|
||||||
|
query = query.filter(Product.shop_name.ilike(f"%{shop_name}%"))
|
||||||
|
|
||||||
|
products = query.offset(offset).limit(batch_size).all()
|
||||||
|
if not products:
|
||||||
|
break
|
||||||
|
|
||||||
|
for product in products:
|
||||||
|
# Create CSV row with marketplace fields
|
||||||
|
row = (
|
||||||
|
f'"{product.product_id}","{product.title or ""}","{product.description or ""}",'
|
||||||
|
f'"{product.link or ""}","{product.image_link or ""}","{product.availability or ""}",'
|
||||||
|
f'"{product.price or ""}","{product.currency or ""}","{product.brand or ""}",'
|
||||||
|
f'"{product.gtin or ""}","{product.marketplace or ""}","{product.shop_name or ""}"\n'
|
||||||
|
)
|
||||||
|
yield row
|
||||||
|
|
||||||
|
offset += batch_size
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating CSV export: {str(e)}")
|
||||||
|
raise ValidationException("Failed to generate CSV export")
|
||||||
|
|
||||||
def product_exists(self, db: Session, product_id: str) -> bool:
|
def product_exists(self, db: Session, product_id: str) -> bool:
|
||||||
"""Check if product exists by ID."""
|
"""Check if product exists by ID."""
|
||||||
return (
|
try:
|
||||||
db.query(Product).filter(Product.product_id == product_id).first()
|
return (
|
||||||
is not None
|
db.query(Product).filter(Product.product_id == product_id).first()
|
||||||
)
|
is not None
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking if product exists: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Private helper methods
|
||||||
|
def _validate_product_data(self, product_data: dict) -> None:
|
||||||
|
"""Validate product data structure."""
|
||||||
|
required_fields = ['product_id', 'title']
|
||||||
|
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in product_data or not product_data[field]:
|
||||||
|
raise ProductValidationException(f"{field} is required", field=field)
|
||||||
|
|
||||||
|
def _normalize_product_data(self, product_data: dict) -> dict:
|
||||||
|
"""Normalize and clean product data."""
|
||||||
|
normalized = product_data.copy()
|
||||||
|
|
||||||
|
# Trim whitespace from string fields
|
||||||
|
string_fields = ['product_id', 'title', 'description', 'brand', 'marketplace', 'shop_name']
|
||||||
|
for field in string_fields:
|
||||||
|
if field in normalized and normalized[field]:
|
||||||
|
normalized[field] = normalized[field].strip()
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
# Create service instance
|
# Create service instance
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
# app/services/shop_service.py
|
# app/services/shop_service.py
|
||||||
"""Summary description ....
|
"""
|
||||||
|
Shop service for managing shop operations and product catalog.
|
||||||
|
|
||||||
This module provides classes and functions for:
|
This module provides classes and functions for:
|
||||||
- ....
|
- Shop creation and management
|
||||||
- ....
|
- Shop access control and validation
|
||||||
- ....
|
- Shop product catalog operations
|
||||||
|
- Shop filtering and search
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.exceptions import (
|
||||||
|
ShopNotFoundException,
|
||||||
|
ShopAlreadyExistsException,
|
||||||
|
UnauthorizedShopAccessException,
|
||||||
|
InvalidShopDataException,
|
||||||
|
ProductNotFoundException,
|
||||||
|
ShopProductAlreadyExistsException,
|
||||||
|
MaxShopsReachedException,
|
||||||
|
ValidationException,
|
||||||
|
)
|
||||||
from models.schemas.shop import ShopCreate, ShopProductCreate
|
from models.schemas.shop import ShopCreate, ShopProductCreate
|
||||||
from models.database.product import Product
|
from models.database.product import Product
|
||||||
from models.database.shop import Shop, ShopProduct
|
from models.database.shop import Shop, ShopProduct
|
||||||
@@ -26,7 +37,7 @@ class ShopService:
|
|||||||
"""Service class for shop operations following the application's service pattern."""
|
"""Service class for shop operations following the application's service pattern."""
|
||||||
|
|
||||||
def create_shop(
|
def create_shop(
|
||||||
self, db: Session, shop_data: ShopCreate, current_user: User
|
self, db: Session, shop_data: ShopCreate, current_user: User
|
||||||
) -> Shop:
|
) -> Shop:
|
||||||
"""
|
"""
|
||||||
Create a new shop.
|
Create a new shop.
|
||||||
@@ -40,49 +51,60 @@ class ShopService:
|
|||||||
Created shop object
|
Created shop object
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If shop code already exists
|
ShopAlreadyExistsException: If shop code already exists
|
||||||
|
MaxShopsReachedException: If user has reached maximum shops
|
||||||
|
InvalidShopDataException: If shop data is invalid
|
||||||
"""
|
"""
|
||||||
# Normalize shop code to uppercase
|
try:
|
||||||
normalized_shop_code = shop_data.shop_code.upper()
|
# Validate shop data
|
||||||
|
self._validate_shop_data(shop_data)
|
||||||
|
|
||||||
# Check if shop code already exists (case-insensitive check against existing data)
|
# Check user's shop limit (if applicable)
|
||||||
existing_shop = (
|
self._check_shop_limit(db, current_user)
|
||||||
db.query(Shop)
|
|
||||||
.filter(func.upper(Shop.shop_code) == normalized_shop_code)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing_shop:
|
# Normalize shop code to uppercase
|
||||||
raise HTTPException(status_code=400, detail="Shop code already exists")
|
normalized_shop_code = shop_data.shop_code.upper()
|
||||||
|
|
||||||
# Create shop with uppercase code
|
# Check if shop code already exists (case-insensitive check)
|
||||||
shop_dict = shop_data.model_dump() # Fixed deprecated .dict() method
|
if self._shop_code_exists(db, normalized_shop_code):
|
||||||
shop_dict["shop_code"] = normalized_shop_code # Store as uppercase
|
raise ShopAlreadyExistsException(normalized_shop_code)
|
||||||
|
|
||||||
new_shop = Shop(
|
# Create shop with uppercase code
|
||||||
**shop_dict,
|
shop_dict = shop_data.model_dump()
|
||||||
owner_id=current_user.id,
|
shop_dict["shop_code"] = normalized_shop_code # Store as uppercase
|
||||||
is_active=True,
|
|
||||||
is_verified=(current_user.role == "admin"),
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(new_shop)
|
new_shop = Shop(
|
||||||
db.commit()
|
**shop_dict,
|
||||||
db.refresh(new_shop)
|
owner_id=current_user.id,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=(current_user.role == "admin"),
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
db.add(new_shop)
|
||||||
f"New shop created: {new_shop.shop_code} by {current_user.username}"
|
db.commit()
|
||||||
)
|
db.refresh(new_shop)
|
||||||
return new_shop
|
|
||||||
|
logger.info(
|
||||||
|
f"New shop created: {new_shop.shop_code} by {current_user.username}"
|
||||||
|
)
|
||||||
|
return new_shop
|
||||||
|
|
||||||
|
except (ShopAlreadyExistsException, MaxShopsReachedException, InvalidShopDataException):
|
||||||
|
db.rollback()
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error creating shop: {str(e)}")
|
||||||
|
raise ValidationException("Failed to create shop")
|
||||||
|
|
||||||
def get_shops(
|
def get_shops(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
current_user: User,
|
current_user: User,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
active_only: bool = True,
|
active_only: bool = True,
|
||||||
verified_only: bool = False,
|
verified_only: bool = False,
|
||||||
) -> Tuple[List[Shop], int]:
|
) -> Tuple[List[Shop], int]:
|
||||||
"""
|
"""
|
||||||
Get shops with filtering.
|
Get shops with filtering.
|
||||||
@@ -98,25 +120,30 @@ class ShopService:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (shops_list, total_count)
|
Tuple of (shops_list, total_count)
|
||||||
"""
|
"""
|
||||||
query = db.query(Shop)
|
try:
|
||||||
|
query = db.query(Shop)
|
||||||
|
|
||||||
# Non-admin users can only see active and verified shops, plus their own
|
# Non-admin users can only see active and verified shops, plus their own
|
||||||
if current_user.role != "admin":
|
if current_user.role != "admin":
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
(Shop.is_active == True)
|
(Shop.is_active == True)
|
||||||
& ((Shop.is_verified == True) | (Shop.owner_id == current_user.id))
|
& ((Shop.is_verified == True) | (Shop.owner_id == current_user.id))
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Admin can apply filters
|
# Admin can apply filters
|
||||||
if active_only:
|
if active_only:
|
||||||
query = query.filter(Shop.is_active == True)
|
query = query.filter(Shop.is_active == True)
|
||||||
if verified_only:
|
if verified_only:
|
||||||
query = query.filter(Shop.is_verified == True)
|
query = query.filter(Shop.is_verified == True)
|
||||||
|
|
||||||
total = query.count()
|
total = query.count()
|
||||||
shops = query.offset(skip).limit(limit).all()
|
shops = query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
return shops, total
|
return shops, total
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting shops: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve shops")
|
||||||
|
|
||||||
def get_shop_by_code(self, db: Session, shop_code: str, current_user: User) -> Shop:
|
def get_shop_by_code(self, db: Session, shop_code: str, current_user: User) -> Shop:
|
||||||
"""
|
"""
|
||||||
@@ -131,28 +158,33 @@ class ShopService:
|
|||||||
Shop object
|
Shop object
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If shop not found or access denied
|
ShopNotFoundException: If shop not found
|
||||||
|
UnauthorizedShopAccessException: If access denied
|
||||||
"""
|
"""
|
||||||
# Explicit type hint to help type checker shop: Optional[Shop]
|
try:
|
||||||
shop: Optional[Shop] = (
|
shop = (
|
||||||
db.query(Shop)
|
db.query(Shop)
|
||||||
.filter(func.upper(Shop.shop_code) == shop_code.upper())
|
.filter(func.upper(Shop.shop_code) == shop_code.upper())
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if not shop:
|
|
||||||
raise HTTPException(status_code=404, detail="Shop not found")
|
|
||||||
|
|
||||||
# Non-admin users can only see active verified shops or their own shops
|
if not shop:
|
||||||
if current_user.role != "admin":
|
raise ShopNotFoundException(shop_code)
|
||||||
if not shop.is_active or (
|
|
||||||
not shop.is_verified and shop.owner_id != current_user.id
|
|
||||||
):
|
|
||||||
raise HTTPException(status_code=404, detail="Shop not found")
|
|
||||||
|
|
||||||
return shop
|
# Check access permissions
|
||||||
|
if not self._can_access_shop(shop, current_user):
|
||||||
|
raise UnauthorizedShopAccessException(shop_code, current_user.id)
|
||||||
|
|
||||||
|
return shop
|
||||||
|
|
||||||
|
except (ShopNotFoundException, UnauthorizedShopAccessException):
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting shop {shop_code}: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve shop")
|
||||||
|
|
||||||
def add_product_to_shop(
|
def add_product_to_shop(
|
||||||
self, db: Session, shop: Shop, shop_product: ShopProductCreate
|
self, db: Session, shop: Shop, shop_product: ShopProductCreate
|
||||||
) -> ShopProduct:
|
) -> ShopProduct:
|
||||||
"""
|
"""
|
||||||
Add existing product to shop catalog with shop-specific settings.
|
Add existing product to shop catalog with shop-specific settings.
|
||||||
@@ -166,59 +198,51 @@ class ShopService:
|
|||||||
Created ShopProduct object
|
Created ShopProduct object
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If product not found or already in shop
|
ProductNotFoundException: If product not found
|
||||||
|
ShopProductAlreadyExistsException: If product already in shop
|
||||||
"""
|
"""
|
||||||
# Check if product exists
|
try:
|
||||||
product = (
|
# Check if product exists
|
||||||
db.query(Product)
|
product = self._get_product_by_id_or_raise(db, shop_product.product_id)
|
||||||
.filter(Product.product_id == shop_product.product_id)
|
|
||||||
.first()
|
# Check if product already in shop
|
||||||
)
|
if self._product_in_shop(db, shop.id, product.id):
|
||||||
if not product:
|
raise ShopProductAlreadyExistsException(shop.shop_code, shop_product.product_id)
|
||||||
raise HTTPException(
|
|
||||||
status_code=404, detail="Product not found in marketplace catalog"
|
# Create shop-product association
|
||||||
|
new_shop_product = ShopProduct(
|
||||||
|
shop_id=shop.id,
|
||||||
|
product_id=product.id,
|
||||||
|
**shop_product.model_dump(exclude={"product_id"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if product already in shop
|
db.add(new_shop_product)
|
||||||
existing_shop_product = (
|
db.commit()
|
||||||
db.query(ShopProduct)
|
db.refresh(new_shop_product)
|
||||||
.filter(
|
|
||||||
ShopProduct.shop_id == shop.id, ShopProduct.product_id == product.id
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing_shop_product:
|
# Load the product relationship
|
||||||
raise HTTPException(
|
db.refresh(new_shop_product)
|
||||||
status_code=400, detail="Product already in shop catalog"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create shop-product association
|
logger.info(f"Product {shop_product.product_id} added to shop {shop.shop_code}")
|
||||||
new_shop_product = ShopProduct(
|
return new_shop_product
|
||||||
shop_id=shop.id,
|
|
||||||
product_id=product.id,
|
|
||||||
**shop_product.model_dump(exclude={"product_id"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(new_shop_product)
|
except (ProductNotFoundException, ShopProductAlreadyExistsException):
|
||||||
db.commit()
|
db.rollback()
|
||||||
db.refresh(new_shop_product)
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
# Load the product relationship
|
db.rollback()
|
||||||
db.refresh(new_shop_product)
|
logger.error(f"Error adding product to shop: {str(e)}")
|
||||||
|
raise ValidationException("Failed to add product to shop")
|
||||||
logger.info(f"Product {shop_product.product_id} added to shop {shop.shop_code}")
|
|
||||||
return new_shop_product
|
|
||||||
|
|
||||||
def get_shop_products(
|
def get_shop_products(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
shop: Shop,
|
shop: Shop,
|
||||||
current_user: User,
|
current_user: User,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
active_only: bool = True,
|
active_only: bool = True,
|
||||||
featured_only: bool = False,
|
featured_only: bool = False,
|
||||||
) -> Tuple[List[ShopProduct], int]:
|
) -> Tuple[List[ShopProduct], int]:
|
||||||
"""
|
"""
|
||||||
Get products in shop catalog with filtering.
|
Get products in shop catalog with filtering.
|
||||||
@@ -236,58 +260,137 @@ class ShopService:
|
|||||||
Tuple of (shop_products_list, total_count)
|
Tuple of (shop_products_list, total_count)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If shop access denied
|
UnauthorizedShopAccessException: If shop access denied
|
||||||
"""
|
"""
|
||||||
# Non-owners can only see active verified shops
|
try:
|
||||||
if current_user.role != "admin" and shop.owner_id != current_user.id:
|
# Check access permissions
|
||||||
if not shop.is_active or not shop.is_verified:
|
if not self._can_access_shop(shop, current_user):
|
||||||
raise HTTPException(status_code=404, detail="Shop not found")
|
raise UnauthorizedShopAccessException(shop.shop_code, current_user.id)
|
||||||
|
|
||||||
# Query shop products
|
# Query shop products
|
||||||
query = db.query(ShopProduct).filter(ShopProduct.shop_id == shop.id)
|
query = db.query(ShopProduct).filter(ShopProduct.shop_id == shop.id)
|
||||||
|
|
||||||
if active_only:
|
if active_only:
|
||||||
query = query.filter(ShopProduct.is_active == True)
|
query = query.filter(ShopProduct.is_active == True)
|
||||||
if featured_only:
|
if featured_only:
|
||||||
query = query.filter(ShopProduct.is_featured == True)
|
query = query.filter(ShopProduct.is_featured == True)
|
||||||
|
|
||||||
total = query.count()
|
total = query.count()
|
||||||
shop_products = query.offset(skip).limit(limit).all()
|
shop_products = query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
return shop_products, total
|
return shop_products, total
|
||||||
|
|
||||||
def get_shop_by_id(self, db: Session, shop_id: int) -> Optional[Shop]:
|
except UnauthorizedShopAccessException:
|
||||||
"""Get shop by ID."""
|
raise # Re-raise custom exceptions
|
||||||
return db.query(Shop).filter(Shop.id == shop_id).first()
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting shop products: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve shop products")
|
||||||
|
|
||||||
def shop_code_exists(self, db: Session, shop_code: str) -> bool:
|
# Private helper methods
|
||||||
"""Check if shop code already exists."""
|
def _validate_shop_data(self, shop_data: ShopCreate) -> None:
|
||||||
return db.query(Shop).filter(Shop.shop_code == shop_code).first() is not None
|
"""Validate shop creation data."""
|
||||||
|
if not shop_data.shop_code or not shop_data.shop_code.strip():
|
||||||
|
raise InvalidShopDataException("Shop code is required", field="shop_code")
|
||||||
|
|
||||||
def get_product_by_id(self, db: Session, product_id: str) -> Optional[Product]:
|
if not shop_data.shop_name or not shop_data.shop_name.strip():
|
||||||
"""Get product by product_id."""
|
raise InvalidShopDataException("Shop name is required", field="shop_name")
|
||||||
return db.query(Product).filter(Product.product_id == product_id).first()
|
|
||||||
|
|
||||||
def product_in_shop(self, db: Session, shop_id: int, product_id: int) -> bool:
|
# Validate shop code format (alphanumeric, underscores, hyphens)
|
||||||
"""Check if product is already in shop."""
|
import re
|
||||||
return (
|
if not re.match(r'^[A-Za-z0-9_-]+$', shop_data.shop_code):
|
||||||
db.query(ShopProduct)
|
raise InvalidShopDataException(
|
||||||
.filter(
|
"Shop code can only contain letters, numbers, underscores, and hyphens",
|
||||||
ShopProduct.shop_id == shop_id, ShopProduct.product_id == product_id
|
field="shop_code"
|
||||||
)
|
)
|
||||||
.first()
|
|
||||||
is not None
|
def _check_shop_limit(self, db: Session, user: User) -> None:
|
||||||
|
"""Check if user has reached maximum shop limit."""
|
||||||
|
if user.role == "admin":
|
||||||
|
return # Admins have no limit
|
||||||
|
|
||||||
|
user_shop_count = db.query(Shop).filter(Shop.owner_id == user.id).count()
|
||||||
|
max_shops = 5 # Configure this as needed
|
||||||
|
|
||||||
|
if user_shop_count >= max_shops:
|
||||||
|
raise MaxShopsReachedException(max_shops, user.id)
|
||||||
|
|
||||||
|
def _shop_code_exists(self, db: Session, shop_code: str) -> bool:
|
||||||
|
"""Check if shop code already exists (case-insensitive)."""
|
||||||
|
return (
|
||||||
|
db.query(Shop)
|
||||||
|
.filter(func.upper(Shop.shop_code) == shop_code.upper())
|
||||||
|
.first() is not None
|
||||||
)
|
)
|
||||||
|
|
||||||
def is_shop_owner(self, shop: Shop, user: User) -> bool:
|
def _get_product_by_id_or_raise(self, db: Session, product_id: str) -> Product:
|
||||||
|
"""Get product by ID or raise exception."""
|
||||||
|
product = db.query(Product).filter(Product.product_id == product_id).first()
|
||||||
|
if not product:
|
||||||
|
raise ProductNotFoundException(product_id)
|
||||||
|
return product
|
||||||
|
|
||||||
|
def _product_in_shop(self, db: Session, shop_id: int, product_id: int) -> bool:
|
||||||
|
"""Check if product is already in shop."""
|
||||||
|
return (
|
||||||
|
db.query(ShopProduct)
|
||||||
|
.filter(
|
||||||
|
ShopProduct.shop_id == shop_id,
|
||||||
|
ShopProduct.product_id == product_id
|
||||||
|
)
|
||||||
|
.first() is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def _can_access_shop(self, shop: Shop, user: User) -> bool:
|
||||||
|
"""Check if user can access shop."""
|
||||||
|
# Admins and owners can always access
|
||||||
|
if user.role == "admin" or shop.owner_id == user.id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Others can only access active and verified shops
|
||||||
|
return shop.is_active and shop.is_verified
|
||||||
|
|
||||||
|
def _is_shop_owner(self, shop: Shop, user: User) -> bool:
|
||||||
"""Check if user is shop owner."""
|
"""Check if user is shop owner."""
|
||||||
return shop.owner_id == user.id
|
return shop.owner_id == user.id
|
||||||
|
|
||||||
|
# Legacy methods for backward compatibility (deprecated)
|
||||||
|
def get_shop_by_id(self, db: Session, shop_id: int) -> Optional[Shop]:
|
||||||
|
"""Get shop by ID. DEPRECATED: Use proper exception handling."""
|
||||||
|
logger.warning("get_shop_by_id is deprecated, use proper exception handling")
|
||||||
|
try:
|
||||||
|
return db.query(Shop).filter(Shop.id == shop_id).first()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting shop by ID: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def shop_code_exists(self, db: Session, shop_code: str) -> bool:
|
||||||
|
"""Check if shop code exists. DEPRECATED: Use proper exception handling."""
|
||||||
|
logger.warning("shop_code_exists is deprecated, use proper exception handling")
|
||||||
|
return self._shop_code_exists(db, shop_code)
|
||||||
|
|
||||||
|
def get_product_by_id(self, db: Session, product_id: str) -> Optional[Product]:
|
||||||
|
"""Get product by ID. DEPRECATED: Use proper exception handling."""
|
||||||
|
logger.warning("get_product_by_id is deprecated, use proper exception handling")
|
||||||
|
try:
|
||||||
|
return db.query(Product).filter(Product.product_id == product_id).first()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting product by ID: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def product_in_shop(self, db: Session, shop_id: int, product_id: int) -> bool:
|
||||||
|
"""Check if product in shop. DEPRECATED: Use proper exception handling."""
|
||||||
|
logger.warning("product_in_shop is deprecated, use proper exception handling")
|
||||||
|
return self._product_in_shop(db, shop_id, product_id)
|
||||||
|
|
||||||
|
def is_shop_owner(self, shop: Shop, user: User) -> bool:
|
||||||
|
"""Check if user is shop owner. DEPRECATED: Use _is_shop_owner."""
|
||||||
|
logger.warning("is_shop_owner is deprecated, use _is_shop_owner")
|
||||||
|
return self._is_shop_owner(shop, user)
|
||||||
|
|
||||||
def can_view_shop(self, shop: Shop, user: User) -> bool:
|
def can_view_shop(self, shop: Shop, user: User) -> bool:
|
||||||
"""Check if user can view shop."""
|
"""Check if user can view shop. DEPRECATED: Use _can_access_shop."""
|
||||||
if user.role == "admin" or self.is_shop_owner(shop, user):
|
logger.warning("can_view_shop is deprecated, use _can_access_shop")
|
||||||
return True
|
return self._can_access_shop(shop, user)
|
||||||
return shop.is_active and shop.is_verified
|
|
||||||
|
|
||||||
|
|
||||||
# Create service instance following the same pattern as other services
|
# Create service instance following the same pattern as other services
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# app/services/stats_service.py
|
# app/services/stats_service.py
|
||||||
"""Summary description ....
|
"""
|
||||||
|
Statistics service for generating system analytics and metrics.
|
||||||
|
|
||||||
This module provides classes and functions for:
|
This module provides classes and functions for:
|
||||||
- ....
|
- Comprehensive system statistics
|
||||||
- ....
|
- Marketplace-specific analytics
|
||||||
- ....
|
- Performance metrics and data insights
|
||||||
|
- Cached statistics for performance
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -13,6 +15,7 @@ from typing import Any, Dict, List
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.exceptions import ValidationException
|
||||||
from models.database.product import Product
|
from models.database.product import Product
|
||||||
from models.database.stock import Stock
|
from models.database.stock import Stock
|
||||||
|
|
||||||
@@ -31,60 +34,39 @@ class StatsService:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary containing all statistics data
|
Dictionary containing all statistics data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationException: If statistics generation fails
|
||||||
"""
|
"""
|
||||||
# Use more efficient queries with proper indexes
|
try:
|
||||||
total_products = db.query(Product).count()
|
# Use more efficient queries with proper indexes
|
||||||
|
total_products = self._get_product_count(db)
|
||||||
|
unique_brands = self._get_unique_brands_count(db)
|
||||||
|
unique_categories = self._get_unique_categories_count(db)
|
||||||
|
unique_marketplaces = self._get_unique_marketplaces_count(db)
|
||||||
|
unique_shops = self._get_unique_shops_count(db)
|
||||||
|
|
||||||
unique_brands = (
|
# Stock statistics
|
||||||
db.query(Product.brand)
|
stock_stats = self._get_stock_statistics(db)
|
||||||
.filter(Product.brand.isnot(None), Product.brand != "")
|
|
||||||
.distinct()
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
unique_categories = (
|
stats_data = {
|
||||||
db.query(Product.google_product_category)
|
"total_products": total_products,
|
||||||
.filter(
|
"unique_brands": unique_brands,
|
||||||
Product.google_product_category.isnot(None),
|
"unique_categories": unique_categories,
|
||||||
Product.google_product_category != "",
|
"unique_marketplaces": unique_marketplaces,
|
||||||
|
"unique_shops": unique_shops,
|
||||||
|
"total_stock_entries": stock_stats["total_stock_entries"],
|
||||||
|
"total_inventory_quantity": stock_stats["total_inventory_quantity"],
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Generated comprehensive stats: {total_products} products, {unique_marketplaces} marketplaces"
|
||||||
)
|
)
|
||||||
.distinct()
|
return stats_data
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
# New marketplace statistics
|
except Exception as e:
|
||||||
unique_marketplaces = (
|
logger.error(f"Error getting comprehensive stats: {str(e)}")
|
||||||
db.query(Product.marketplace)
|
raise ValidationException("Failed to retrieve system statistics")
|
||||||
.filter(Product.marketplace.isnot(None), Product.marketplace != "")
|
|
||||||
.distinct()
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
unique_shops = (
|
|
||||||
db.query(Product.shop_name)
|
|
||||||
.filter(Product.shop_name.isnot(None), Product.shop_name != "")
|
|
||||||
.distinct()
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Stock statistics
|
|
||||||
total_stock_entries = db.query(Stock).count()
|
|
||||||
total_inventory = db.query(func.sum(Stock.quantity)).scalar() or 0
|
|
||||||
|
|
||||||
stats_data = {
|
|
||||||
"total_products": total_products,
|
|
||||||
"unique_brands": unique_brands,
|
|
||||||
"unique_categories": unique_categories,
|
|
||||||
"unique_marketplaces": unique_marketplaces,
|
|
||||||
"unique_shops": unique_shops,
|
|
||||||
"total_stock_entries": total_stock_entries,
|
|
||||||
"total_inventory_quantity": total_inventory,
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Generated comprehensive stats: {total_products} products, {unique_marketplaces} marketplaces"
|
|
||||||
)
|
|
||||||
return stats_data
|
|
||||||
|
|
||||||
def get_marketplace_breakdown_stats(self, db: Session) -> List[Dict[str, Any]]:
|
def get_marketplace_breakdown_stats(self, db: Session) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -95,40 +77,127 @@ class StatsService:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of dictionaries containing marketplace statistics
|
List of dictionaries containing marketplace statistics
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationException: If marketplace statistics generation fails
|
||||||
"""
|
"""
|
||||||
# Query to get stats per marketplace
|
try:
|
||||||
marketplace_stats = (
|
# Query to get stats per marketplace
|
||||||
db.query(
|
marketplace_stats = (
|
||||||
Product.marketplace,
|
db.query(
|
||||||
func.count(Product.id).label("total_products"),
|
Product.marketplace,
|
||||||
func.count(func.distinct(Product.shop_name)).label("unique_shops"),
|
func.count(Product.id).label("total_products"),
|
||||||
func.count(func.distinct(Product.brand)).label("unique_brands"),
|
func.count(func.distinct(Product.shop_name)).label("unique_shops"),
|
||||||
|
func.count(func.distinct(Product.brand)).label("unique_brands"),
|
||||||
|
)
|
||||||
|
.filter(Product.marketplace.isnot(None))
|
||||||
|
.group_by(Product.marketplace)
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
.filter(Product.marketplace.isnot(None))
|
|
||||||
.group_by(Product.marketplace)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
stats_list = [
|
stats_list = [
|
||||||
{
|
{
|
||||||
"marketplace": stat.marketplace,
|
"marketplace": stat.marketplace,
|
||||||
"total_products": stat.total_products,
|
"total_products": stat.total_products,
|
||||||
"unique_shops": stat.unique_shops,
|
"unique_shops": stat.unique_shops,
|
||||||
"unique_brands": stat.unique_brands,
|
"unique_brands": stat.unique_brands,
|
||||||
|
}
|
||||||
|
for stat in marketplace_stats
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Generated marketplace breakdown stats for {len(stats_list)} marketplaces"
|
||||||
|
)
|
||||||
|
return stats_list
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting marketplace breakdown stats: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve marketplace statistics")
|
||||||
|
|
||||||
|
def get_product_statistics(self, db: Session) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get detailed product statistics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing product statistics
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stats = {
|
||||||
|
"total_products": self._get_product_count(db),
|
||||||
|
"unique_brands": self._get_unique_brands_count(db),
|
||||||
|
"unique_categories": self._get_unique_categories_count(db),
|
||||||
|
"unique_marketplaces": self._get_unique_marketplaces_count(db),
|
||||||
|
"unique_shops": self._get_unique_shops_count(db),
|
||||||
|
"products_with_gtin": self._get_products_with_gtin_count(db),
|
||||||
|
"products_with_images": self._get_products_with_images_count(db),
|
||||||
}
|
}
|
||||||
for stat in marketplace_stats
|
|
||||||
]
|
|
||||||
|
|
||||||
logger.info(
|
return stats
|
||||||
f"Generated marketplace breakdown stats for {len(stats_list)} marketplaces"
|
|
||||||
)
|
|
||||||
return stats_list
|
|
||||||
|
|
||||||
def get_product_count(self, db: Session) -> int:
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting product statistics: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve product statistics")
|
||||||
|
|
||||||
|
def get_stock_statistics(self, db: Session) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get stock-related statistics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing stock statistics
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self._get_stock_statistics(db)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting stock statistics: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve stock statistics")
|
||||||
|
|
||||||
|
def get_marketplace_details(self, db: Session, marketplace: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get detailed statistics for a specific marketplace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
marketplace: Marketplace name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing marketplace details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not marketplace or not marketplace.strip():
|
||||||
|
raise ValidationException("Marketplace name is required")
|
||||||
|
|
||||||
|
product_count = self._get_products_by_marketplace_count(db, marketplace)
|
||||||
|
brands = self._get_brands_by_marketplace(db, marketplace)
|
||||||
|
shops = self._get_shops_by_marketplace(db, marketplace)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"marketplace": marketplace,
|
||||||
|
"total_products": product_count,
|
||||||
|
"unique_brands": len(brands),
|
||||||
|
"unique_shops": len(shops),
|
||||||
|
"brands": brands,
|
||||||
|
"shops": shops,
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValidationException:
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting marketplace details for {marketplace}: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve marketplace details")
|
||||||
|
|
||||||
|
# Private helper methods
|
||||||
|
def _get_product_count(self, db: Session) -> int:
|
||||||
"""Get total product count."""
|
"""Get total product count."""
|
||||||
return db.query(Product).count()
|
return db.query(Product).count()
|
||||||
|
|
||||||
def get_unique_brands_count(self, db: Session) -> int:
|
def _get_unique_brands_count(self, db: Session) -> int:
|
||||||
"""Get count of unique brands."""
|
"""Get count of unique brands."""
|
||||||
return (
|
return (
|
||||||
db.query(Product.brand)
|
db.query(Product.brand)
|
||||||
@@ -137,7 +206,7 @@ class StatsService:
|
|||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_unique_categories_count(self, db: Session) -> int:
|
def _get_unique_categories_count(self, db: Session) -> int:
|
||||||
"""Get count of unique categories."""
|
"""Get count of unique categories."""
|
||||||
return (
|
return (
|
||||||
db.query(Product.google_product_category)
|
db.query(Product.google_product_category)
|
||||||
@@ -149,7 +218,7 @@ class StatsService:
|
|||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_unique_marketplaces_count(self, db: Session) -> int:
|
def _get_unique_marketplaces_count(self, db: Session) -> int:
|
||||||
"""Get count of unique marketplaces."""
|
"""Get count of unique marketplaces."""
|
||||||
return (
|
return (
|
||||||
db.query(Product.marketplace)
|
db.query(Product.marketplace)
|
||||||
@@ -158,7 +227,7 @@ class StatsService:
|
|||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_unique_shops_count(self, db: Session) -> int:
|
def _get_unique_shops_count(self, db: Session) -> int:
|
||||||
"""Get count of unique shops."""
|
"""Get count of unique shops."""
|
||||||
return (
|
return (
|
||||||
db.query(Product.shop_name)
|
db.query(Product.shop_name)
|
||||||
@@ -167,16 +236,24 @@ class StatsService:
|
|||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_stock_statistics(self, db: Session) -> Dict[str, int]:
|
def _get_products_with_gtin_count(self, db: Session) -> int:
|
||||||
"""
|
"""Get count of products with GTIN."""
|
||||||
Get stock-related statistics.
|
return (
|
||||||
|
db.query(Product)
|
||||||
|
.filter(Product.gtin.isnot(None), Product.gtin != "")
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
Args:
|
def _get_products_with_images_count(self, db: Session) -> int:
|
||||||
db: Database session
|
"""Get count of products with images."""
|
||||||
|
return (
|
||||||
|
db.query(Product)
|
||||||
|
.filter(Product.image_link.isnot(None), Product.image_link != "")
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
Returns:
|
def _get_stock_statistics(self, db: Session) -> Dict[str, int]:
|
||||||
Dictionary containing stock statistics
|
"""Get stock-related statistics."""
|
||||||
"""
|
|
||||||
total_stock_entries = db.query(Stock).count()
|
total_stock_entries = db.query(Stock).count()
|
||||||
total_inventory = db.query(func.sum(Stock.quantity)).scalar() or 0
|
total_inventory = db.query(func.sum(Stock.quantity)).scalar() or 0
|
||||||
|
|
||||||
@@ -185,7 +262,7 @@ class StatsService:
|
|||||||
"total_inventory_quantity": total_inventory,
|
"total_inventory_quantity": total_inventory,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_brands_by_marketplace(self, db: Session, marketplace: str) -> List[str]:
|
def _get_brands_by_marketplace(self, db: Session, marketplace: str) -> List[str]:
|
||||||
"""Get unique brands for a specific marketplace."""
|
"""Get unique brands for a specific marketplace."""
|
||||||
brands = (
|
brands = (
|
||||||
db.query(Product.brand)
|
db.query(Product.brand)
|
||||||
@@ -199,7 +276,7 @@ class StatsService:
|
|||||||
)
|
)
|
||||||
return [brand[0] for brand in brands]
|
return [brand[0] for brand in brands]
|
||||||
|
|
||||||
def get_shops_by_marketplace(self, db: Session, marketplace: str) -> List[str]:
|
def _get_shops_by_marketplace(self, db: Session, marketplace: str) -> List[str]:
|
||||||
"""Get unique shops for a specific marketplace."""
|
"""Get unique shops for a specific marketplace."""
|
||||||
shops = (
|
shops = (
|
||||||
db.query(Product.shop_name)
|
db.query(Product.shop_name)
|
||||||
@@ -213,10 +290,51 @@ class StatsService:
|
|||||||
)
|
)
|
||||||
return [shop[0] for shop in shops]
|
return [shop[0] for shop in shops]
|
||||||
|
|
||||||
def get_products_by_marketplace(self, db: Session, marketplace: str) -> int:
|
def _get_products_by_marketplace_count(self, db: Session, marketplace: str) -> int:
|
||||||
"""Get product count for a specific marketplace."""
|
"""Get product count for a specific marketplace."""
|
||||||
return db.query(Product).filter(Product.marketplace == marketplace).count()
|
return db.query(Product).filter(Product.marketplace == marketplace).count()
|
||||||
|
|
||||||
|
# Legacy methods for backward compatibility (deprecated)
|
||||||
|
def get_product_count(self, db: Session) -> int:
|
||||||
|
"""Get total product count. DEPRECATED: Use _get_product_count."""
|
||||||
|
logger.warning("get_product_count is deprecated, use _get_product_count")
|
||||||
|
return self._get_product_count(db)
|
||||||
|
|
||||||
|
def get_unique_brands_count(self, db: Session) -> int:
|
||||||
|
"""Get unique brands count. DEPRECATED: Use _get_unique_brands_count."""
|
||||||
|
logger.warning("get_unique_brands_count is deprecated, use _get_unique_brands_count")
|
||||||
|
return self._get_unique_brands_count(db)
|
||||||
|
|
||||||
|
def get_unique_categories_count(self, db: Session) -> int:
|
||||||
|
"""Get unique categories count. DEPRECATED: Use _get_unique_categories_count."""
|
||||||
|
logger.warning("get_unique_categories_count is deprecated, use _get_unique_categories_count")
|
||||||
|
return self._get_unique_categories_count(db)
|
||||||
|
|
||||||
|
def get_unique_marketplaces_count(self, db: Session) -> int:
|
||||||
|
"""Get unique marketplaces count. DEPRECATED: Use _get_unique_marketplaces_count."""
|
||||||
|
logger.warning("get_unique_marketplaces_count is deprecated, use _get_unique_marketplaces_count")
|
||||||
|
return self._get_unique_marketplaces_count(db)
|
||||||
|
|
||||||
|
def get_unique_shops_count(self, db: Session) -> int:
|
||||||
|
"""Get unique shops count. DEPRECATED: Use _get_unique_shops_count."""
|
||||||
|
logger.warning("get_unique_shops_count is deprecated, use _get_unique_shops_count")
|
||||||
|
return self._get_unique_shops_count(db)
|
||||||
|
|
||||||
|
def get_brands_by_marketplace(self, db: Session, marketplace: str) -> List[str]:
|
||||||
|
"""Get brands by marketplace. DEPRECATED: Use _get_brands_by_marketplace."""
|
||||||
|
logger.warning("get_brands_by_marketplace is deprecated, use _get_brands_by_marketplace")
|
||||||
|
return self._get_brands_by_marketplace(db, marketplace)
|
||||||
|
|
||||||
|
def get_shops_by_marketplace(self, db: Session, marketplace: str) -> List[str]:
|
||||||
|
"""Get shops by marketplace. DEPRECATED: Use _get_shops_by_marketplace."""
|
||||||
|
logger.warning("get_shops_by_marketplace is deprecated, use _get_shops_by_marketplace")
|
||||||
|
return self._get_shops_by_marketplace(db, marketplace)
|
||||||
|
|
||||||
|
def get_products_by_marketplace(self, db: Session, marketplace: str) -> int:
|
||||||
|
"""Get products by marketplace. DEPRECATED: Use _get_products_by_marketplace_count."""
|
||||||
|
logger.warning("get_products_by_marketplace is deprecated, use _get_products_by_marketplace_count")
|
||||||
|
return self._get_products_by_marketplace_count(db, marketplace)
|
||||||
|
|
||||||
|
|
||||||
# Create service instance following the same pattern as other services
|
# Create service instance following the same pattern as other services
|
||||||
stats_service = StatsService()
|
stats_service = StatsService()
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# app/services/stock_service.py
|
# app/services/stock_service.py
|
||||||
"""Summary description ....
|
"""
|
||||||
|
Stock service for managing inventory operations.
|
||||||
|
|
||||||
This module provides classes and functions for:
|
This module provides classes and functions for:
|
||||||
- ....
|
- Stock quantity management (set, add, remove)
|
||||||
- ....
|
- Stock information retrieval and validation
|
||||||
- ....
|
- Location-based inventory tracking
|
||||||
|
- GTIN normalization and validation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -13,6 +15,15 @@ from typing import List, Optional
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.exceptions import (
|
||||||
|
StockNotFoundException,
|
||||||
|
InsufficientStockException,
|
||||||
|
InvalidStockOperationException,
|
||||||
|
StockValidationException,
|
||||||
|
NegativeStockException,
|
||||||
|
InvalidQuantityException,
|
||||||
|
ValidationException,
|
||||||
|
)
|
||||||
from models.schemas.stock import (StockAdd, StockCreate, StockLocationResponse,
|
from models.schemas.stock import (StockAdd, StockCreate, StockLocationResponse,
|
||||||
StockSummaryResponse, StockUpdate)
|
StockSummaryResponse, StockUpdate)
|
||||||
from models.database.product import Product
|
from models.database.product import Product
|
||||||
@@ -29,236 +40,544 @@ class StockService:
|
|||||||
"""Class constructor."""
|
"""Class constructor."""
|
||||||
self.gtin_processor = GTINProcessor()
|
self.gtin_processor = GTINProcessor()
|
||||||
|
|
||||||
def normalize_gtin(self, gtin_value) -> Optional[str]:
|
|
||||||
"""Normalize GTIN format using the GTINProcessor."""
|
|
||||||
return self.gtin_processor.normalize(gtin_value)
|
|
||||||
|
|
||||||
def set_stock(self, db: Session, stock_data: StockCreate) -> Stock:
|
def set_stock(self, db: Session, stock_data: StockCreate) -> Stock:
|
||||||
"""Set exact stock quantity for a GTIN at a specific location (replaces existing quantity)."""
|
"""
|
||||||
normalized_gtin = self.normalize_gtin(stock_data.gtin)
|
Set exact stock quantity for a GTIN at a specific location (replaces existing quantity).
|
||||||
if not normalized_gtin:
|
|
||||||
raise ValueError("Invalid GTIN format")
|
|
||||||
|
|
||||||
location = stock_data.location.strip().upper()
|
Args:
|
||||||
|
db: Database session
|
||||||
|
stock_data: Stock creation data
|
||||||
|
|
||||||
# Check if stock entry already exists for this GTIN and location
|
Returns:
|
||||||
existing_stock = (
|
Stock object with updated quantity
|
||||||
db.query(Stock)
|
|
||||||
.filter(Stock.gtin == normalized_gtin, Stock.location == location)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing_stock:
|
Raises:
|
||||||
# Update existing stock (SET to exact quantity)
|
InvalidQuantityException: If quantity is negative
|
||||||
old_quantity = existing_stock.quantity
|
StockValidationException: If GTIN or location is invalid
|
||||||
existing_stock.quantity = stock_data.quantity
|
"""
|
||||||
existing_stock.updated_at = datetime.utcnow()
|
try:
|
||||||
db.commit()
|
# Validate and normalize input
|
||||||
db.refresh(existing_stock)
|
normalized_gtin = self._validate_and_normalize_gtin(stock_data.gtin)
|
||||||
logger.info(
|
location = self._validate_and_normalize_location(stock_data.location)
|
||||||
f"Updated stock for GTIN {normalized_gtin} at {location}: {old_quantity} → {stock_data.quantity}"
|
self._validate_quantity(stock_data.quantity, allow_zero=True)
|
||||||
)
|
|
||||||
return existing_stock
|
# Check if stock entry already exists for this GTIN and location
|
||||||
else:
|
existing_stock = self._get_stock_entry(db, normalized_gtin, location)
|
||||||
# Create new stock entry
|
|
||||||
new_stock = Stock(
|
if existing_stock:
|
||||||
gtin=normalized_gtin, location=location, quantity=stock_data.quantity
|
# Update existing stock (SET to exact quantity)
|
||||||
)
|
old_quantity = existing_stock.quantity
|
||||||
db.add(new_stock)
|
existing_stock.quantity = stock_data.quantity
|
||||||
db.commit()
|
existing_stock.updated_at = datetime.utcnow()
|
||||||
db.refresh(new_stock)
|
db.commit()
|
||||||
logger.info(
|
db.refresh(existing_stock)
|
||||||
f"Created new stock for GTIN {normalized_gtin} at {location}: {stock_data.quantity}"
|
|
||||||
)
|
logger.info(
|
||||||
return new_stock
|
f"Updated stock for GTIN {normalized_gtin} at {location}: {old_quantity} → {stock_data.quantity}"
|
||||||
|
)
|
||||||
|
return existing_stock
|
||||||
|
else:
|
||||||
|
# Create new stock entry
|
||||||
|
new_stock = Stock(
|
||||||
|
gtin=normalized_gtin,
|
||||||
|
location=location,
|
||||||
|
quantity=stock_data.quantity
|
||||||
|
)
|
||||||
|
db.add(new_stock)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_stock)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created new stock for GTIN {normalized_gtin} at {location}: {stock_data.quantity}"
|
||||||
|
)
|
||||||
|
return new_stock
|
||||||
|
|
||||||
|
except (InvalidQuantityException, StockValidationException):
|
||||||
|
db.rollback()
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error setting stock: {str(e)}")
|
||||||
|
raise ValidationException("Failed to set stock")
|
||||||
|
|
||||||
def add_stock(self, db: Session, stock_data: StockAdd) -> Stock:
|
def add_stock(self, db: Session, stock_data: StockAdd) -> Stock:
|
||||||
"""Add quantity to existing stock for a GTIN at a specific location (adds to existing quantity)."""
|
"""
|
||||||
normalized_gtin = self.normalize_gtin(stock_data.gtin)
|
Add quantity to existing stock for a GTIN at a specific location (adds to existing quantity).
|
||||||
if not normalized_gtin:
|
|
||||||
raise ValueError("Invalid GTIN format")
|
|
||||||
|
|
||||||
location = stock_data.location.strip().upper()
|
Args:
|
||||||
|
db: Database session
|
||||||
|
stock_data: Stock addition data
|
||||||
|
|
||||||
# Check if stock entry already exists for this GTIN and location
|
Returns:
|
||||||
existing_stock = (
|
Stock object with updated quantity
|
||||||
db.query(Stock)
|
|
||||||
.filter(Stock.gtin == normalized_gtin, Stock.location == location)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing_stock:
|
Raises:
|
||||||
# Add to existing stock
|
InvalidQuantityException: If quantity is not positive
|
||||||
|
StockValidationException: If GTIN or location is invalid
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate and normalize input
|
||||||
|
normalized_gtin = self._validate_and_normalize_gtin(stock_data.gtin)
|
||||||
|
location = self._validate_and_normalize_location(stock_data.location)
|
||||||
|
self._validate_quantity(stock_data.quantity, allow_zero=False)
|
||||||
|
|
||||||
|
# Check if stock entry already exists for this GTIN and location
|
||||||
|
existing_stock = self._get_stock_entry(db, normalized_gtin, location)
|
||||||
|
|
||||||
|
if existing_stock:
|
||||||
|
# Add to existing stock
|
||||||
|
old_quantity = existing_stock.quantity
|
||||||
|
existing_stock.quantity += stock_data.quantity
|
||||||
|
existing_stock.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing_stock)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Added stock for GTIN {normalized_gtin} at {location}: "
|
||||||
|
f"{old_quantity} + {stock_data.quantity} = {existing_stock.quantity}"
|
||||||
|
)
|
||||||
|
return existing_stock
|
||||||
|
else:
|
||||||
|
# Create new stock entry with the quantity
|
||||||
|
new_stock = Stock(
|
||||||
|
gtin=normalized_gtin,
|
||||||
|
location=location,
|
||||||
|
quantity=stock_data.quantity
|
||||||
|
)
|
||||||
|
db.add(new_stock)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_stock)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created new stock for GTIN {normalized_gtin} at {location}: {stock_data.quantity}"
|
||||||
|
)
|
||||||
|
return new_stock
|
||||||
|
|
||||||
|
except (InvalidQuantityException, StockValidationException):
|
||||||
|
db.rollback()
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error adding stock: {str(e)}")
|
||||||
|
raise ValidationException("Failed to add stock")
|
||||||
|
|
||||||
|
def remove_stock(self, db: Session, stock_data: StockAdd) -> Stock:
|
||||||
|
"""
|
||||||
|
Remove quantity from existing stock for a GTIN at a specific location.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
stock_data: Stock removal data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Stock object with updated quantity
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
StockNotFoundException: If no stock found for GTIN/location
|
||||||
|
InsufficientStockException: If not enough stock available
|
||||||
|
InvalidQuantityException: If quantity is not positive
|
||||||
|
NegativeStockException: If operation would result in negative stock
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate and normalize input
|
||||||
|
normalized_gtin = self._validate_and_normalize_gtin(stock_data.gtin)
|
||||||
|
location = self._validate_and_normalize_location(stock_data.location)
|
||||||
|
self._validate_quantity(stock_data.quantity, allow_zero=False)
|
||||||
|
|
||||||
|
# Find existing stock entry
|
||||||
|
existing_stock = self._get_stock_entry(db, normalized_gtin, location)
|
||||||
|
if not existing_stock:
|
||||||
|
raise StockNotFoundException(normalized_gtin, identifier_type="gtin")
|
||||||
|
|
||||||
|
# Check if we have enough stock to remove
|
||||||
|
if existing_stock.quantity < stock_data.quantity:
|
||||||
|
raise InsufficientStockException(
|
||||||
|
gtin=normalized_gtin,
|
||||||
|
location=location,
|
||||||
|
requested=stock_data.quantity,
|
||||||
|
available=existing_stock.quantity
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove from existing stock
|
||||||
old_quantity = existing_stock.quantity
|
old_quantity = existing_stock.quantity
|
||||||
existing_stock.quantity += stock_data.quantity
|
new_quantity = existing_stock.quantity - stock_data.quantity
|
||||||
|
|
||||||
|
# Validate resulting quantity
|
||||||
|
if new_quantity < 0:
|
||||||
|
raise NegativeStockException(normalized_gtin, location, new_quantity)
|
||||||
|
|
||||||
|
existing_stock.quantity = new_quantity
|
||||||
existing_stock.updated_at = datetime.utcnow()
|
existing_stock.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(existing_stock)
|
db.refresh(existing_stock)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Added stock for GTIN {normalized_gtin} at {location}: "
|
f"Removed stock for GTIN {normalized_gtin} at {location}: "
|
||||||
f"{old_quantity} + {stock_data.quantity} = {existing_stock.quantity}"
|
f"{old_quantity} - {stock_data.quantity} = {existing_stock.quantity}"
|
||||||
)
|
)
|
||||||
return existing_stock
|
return existing_stock
|
||||||
else:
|
|
||||||
# Create new stock entry with the quantity
|
except (StockNotFoundException, InsufficientStockException, InvalidQuantityException, NegativeStockException):
|
||||||
new_stock = Stock(
|
db.rollback()
|
||||||
gtin=normalized_gtin, location=location, quantity=stock_data.quantity
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error removing stock: {str(e)}")
|
||||||
|
raise ValidationException("Failed to remove stock")
|
||||||
|
|
||||||
|
def get_stock_by_gtin(self, db: Session, gtin: str) -> StockSummaryResponse:
|
||||||
|
"""
|
||||||
|
Get all stock locations and total quantity for a specific GTIN.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
gtin: GTIN to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StockSummaryResponse with locations and totals
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
StockNotFoundException: If no stock found for GTIN
|
||||||
|
StockValidationException: If GTIN is invalid
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
normalized_gtin = self._validate_and_normalize_gtin(gtin)
|
||||||
|
|
||||||
|
# Get all stock entries for this GTIN
|
||||||
|
stock_entries = db.query(Stock).filter(Stock.gtin == normalized_gtin).all()
|
||||||
|
|
||||||
|
if not stock_entries:
|
||||||
|
raise StockNotFoundException(normalized_gtin, identifier_type="gtin")
|
||||||
|
|
||||||
|
# Calculate total quantity and build locations list
|
||||||
|
total_quantity = 0
|
||||||
|
locations = []
|
||||||
|
|
||||||
|
for entry in stock_entries:
|
||||||
|
total_quantity += entry.quantity
|
||||||
|
locations.append(
|
||||||
|
StockLocationResponse(location=entry.location, quantity=entry.quantity)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to get product title for reference
|
||||||
|
product = db.query(Product).filter(Product.gtin == normalized_gtin).first()
|
||||||
|
product_title = product.title if product else None
|
||||||
|
|
||||||
|
return StockSummaryResponse(
|
||||||
|
gtin=normalized_gtin,
|
||||||
|
total_quantity=total_quantity,
|
||||||
|
locations=locations,
|
||||||
|
product_title=product_title,
|
||||||
)
|
)
|
||||||
db.add(new_stock)
|
|
||||||
|
except (StockNotFoundException, StockValidationException):
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting stock by GTIN {gtin}: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve stock information")
|
||||||
|
|
||||||
|
def get_total_stock(self, db: Session, gtin: str) -> dict:
|
||||||
|
"""
|
||||||
|
Get total quantity in stock for a specific GTIN.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
gtin: GTIN to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with total stock information
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
StockNotFoundException: If no stock found for GTIN
|
||||||
|
StockValidationException: If GTIN is invalid
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
normalized_gtin = self._validate_and_normalize_gtin(gtin)
|
||||||
|
|
||||||
|
# Calculate total stock
|
||||||
|
total_stock = db.query(Stock).filter(Stock.gtin == normalized_gtin).all()
|
||||||
|
|
||||||
|
if not total_stock:
|
||||||
|
raise StockNotFoundException(normalized_gtin, identifier_type="gtin")
|
||||||
|
|
||||||
|
total_quantity = sum(entry.quantity for entry in total_stock)
|
||||||
|
|
||||||
|
# Get product info for context
|
||||||
|
product = db.query(Product).filter(Product.gtin == normalized_gtin).first()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"gtin": normalized_gtin,
|
||||||
|
"total_quantity": total_quantity,
|
||||||
|
"product_title": product.title if product else None,
|
||||||
|
"locations_count": len(total_stock),
|
||||||
|
}
|
||||||
|
|
||||||
|
except (StockNotFoundException, StockValidationException):
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting total stock for GTIN {gtin}: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve total stock")
|
||||||
|
|
||||||
|
def get_all_stock(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
location: Optional[str] = None,
|
||||||
|
gtin: Optional[str] = None,
|
||||||
|
) -> List[Stock]:
|
||||||
|
"""
|
||||||
|
Get all stock entries with optional filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
skip: Number of records to skip
|
||||||
|
limit: Maximum records to return
|
||||||
|
location: Optional location filter
|
||||||
|
gtin: Optional GTIN filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Stock objects
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = db.query(Stock)
|
||||||
|
|
||||||
|
if location:
|
||||||
|
query = query.filter(Stock.location.ilike(f"%{location}%"))
|
||||||
|
|
||||||
|
if gtin:
|
||||||
|
normalized_gtin = self._normalize_gtin(gtin)
|
||||||
|
if normalized_gtin:
|
||||||
|
query = query.filter(Stock.gtin == normalized_gtin)
|
||||||
|
|
||||||
|
return query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting all stock: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve stock entries")
|
||||||
|
|
||||||
|
def update_stock(
|
||||||
|
self, db: Session, stock_id: int, stock_update: StockUpdate
|
||||||
|
) -> Stock:
|
||||||
|
"""
|
||||||
|
Update stock quantity for a specific stock entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
stock_id: Stock entry ID
|
||||||
|
stock_update: Update data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Stock object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
StockNotFoundException: If stock entry not found
|
||||||
|
InvalidQuantityException: If quantity is invalid
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stock_entry = self._get_stock_by_id_or_raise(db, stock_id)
|
||||||
|
|
||||||
|
# Validate new quantity
|
||||||
|
self._validate_quantity(stock_update.quantity, allow_zero=True)
|
||||||
|
|
||||||
|
stock_entry.quantity = stock_update.quantity
|
||||||
|
stock_entry.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_stock)
|
db.refresh(stock_entry)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Created new stock for GTIN {normalized_gtin} at {location}: {stock_data.quantity}"
|
f"Updated stock entry {stock_id} to quantity {stock_update.quantity}"
|
||||||
)
|
)
|
||||||
return new_stock
|
return stock_entry
|
||||||
|
|
||||||
def remove_stock(self, db: Session, stock_data: StockAdd) -> Stock:
|
except (StockNotFoundException, InvalidQuantityException):
|
||||||
"""Remove quantity from existing stock for a GTIN at a specific location."""
|
db.rollback()
|
||||||
normalized_gtin = self.normalize_gtin(stock_data.gtin)
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error updating stock {stock_id}: {str(e)}")
|
||||||
|
raise ValidationException("Failed to update stock")
|
||||||
|
|
||||||
|
def delete_stock(self, db: Session, stock_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a stock entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
stock_id: Stock entry ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deletion successful
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
StockNotFoundException: If stock entry not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stock_entry = self._get_stock_by_id_or_raise(db, stock_id)
|
||||||
|
|
||||||
|
gtin = stock_entry.gtin
|
||||||
|
location = stock_entry.location
|
||||||
|
db.delete(stock_entry)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Deleted stock entry {stock_id} for GTIN {gtin} at {location}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except StockNotFoundException:
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error deleting stock {stock_id}: {str(e)}")
|
||||||
|
raise ValidationException("Failed to delete stock entry")
|
||||||
|
|
||||||
|
def get_stock_summary_by_location(self, db: Session, location: str) -> dict:
|
||||||
|
"""
|
||||||
|
Get stock summary for a specific location.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
location: Location to summarize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with location stock summary
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
normalized_location = self._validate_and_normalize_location(location)
|
||||||
|
|
||||||
|
stock_entries = db.query(Stock).filter(Stock.location == normalized_location).all()
|
||||||
|
|
||||||
|
if not stock_entries:
|
||||||
|
return {
|
||||||
|
"location": normalized_location,
|
||||||
|
"total_items": 0,
|
||||||
|
"total_quantity": 0,
|
||||||
|
"unique_gtins": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
total_quantity = sum(entry.quantity for entry in stock_entries)
|
||||||
|
unique_gtins = len(set(entry.gtin for entry in stock_entries))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"location": normalized_location,
|
||||||
|
"total_items": len(stock_entries),
|
||||||
|
"total_quantity": total_quantity,
|
||||||
|
"unique_gtins": unique_gtins,
|
||||||
|
}
|
||||||
|
|
||||||
|
except StockValidationException:
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting stock summary for location {location}: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve location stock summary")
|
||||||
|
|
||||||
|
def get_low_stock_items(self, db: Session, threshold: int = 10) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Get items with stock below threshold.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
threshold: Stock threshold to consider "low"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of low stock items with details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if threshold < 0:
|
||||||
|
raise InvalidQuantityException(threshold, "Threshold must be non-negative")
|
||||||
|
|
||||||
|
low_stock_entries = db.query(Stock).filter(Stock.quantity <= threshold).all()
|
||||||
|
|
||||||
|
low_stock_items = []
|
||||||
|
for entry in low_stock_entries:
|
||||||
|
# Get product info if available
|
||||||
|
product = db.query(Product).filter(Product.gtin == entry.gtin).first()
|
||||||
|
|
||||||
|
low_stock_items.append({
|
||||||
|
"gtin": entry.gtin,
|
||||||
|
"location": entry.location,
|
||||||
|
"current_quantity": entry.quantity,
|
||||||
|
"product_title": product.title if product else None,
|
||||||
|
"product_id": product.product_id if product else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return low_stock_items
|
||||||
|
|
||||||
|
except InvalidQuantityException:
|
||||||
|
raise # Re-raise custom exceptions
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting low stock items: {str(e)}")
|
||||||
|
raise ValidationException("Failed to retrieve low stock items")
|
||||||
|
|
||||||
|
# Private helper methods
|
||||||
|
def _validate_and_normalize_gtin(self, gtin: str) -> str:
|
||||||
|
"""Validate and normalize GTIN format."""
|
||||||
|
if not gtin or not gtin.strip():
|
||||||
|
raise StockValidationException("GTIN is required", field="gtin")
|
||||||
|
|
||||||
|
normalized_gtin = self._normalize_gtin(gtin)
|
||||||
if not normalized_gtin:
|
if not normalized_gtin:
|
||||||
raise ValueError("Invalid GTIN format")
|
raise StockValidationException("Invalid GTIN format", field="gtin")
|
||||||
|
|
||||||
location = stock_data.location.strip().upper()
|
return normalized_gtin
|
||||||
|
|
||||||
# Find existing stock entry
|
def _validate_and_normalize_location(self, location: str) -> str:
|
||||||
existing_stock = (
|
"""Validate and normalize location."""
|
||||||
|
if not location or not location.strip():
|
||||||
|
raise StockValidationException("Location is required", field="location")
|
||||||
|
|
||||||
|
return location.strip().upper()
|
||||||
|
|
||||||
|
def _validate_quantity(self, quantity: int, allow_zero: bool = True) -> None:
|
||||||
|
"""Validate quantity value."""
|
||||||
|
if quantity is None:
|
||||||
|
raise InvalidQuantityException(quantity, "Quantity is required")
|
||||||
|
|
||||||
|
if not isinstance(quantity, int):
|
||||||
|
raise InvalidQuantityException(quantity, "Quantity must be an integer")
|
||||||
|
|
||||||
|
if quantity < 0:
|
||||||
|
raise InvalidQuantityException(quantity, "Quantity cannot be negative")
|
||||||
|
|
||||||
|
if not allow_zero and quantity == 0:
|
||||||
|
raise InvalidQuantityException(quantity, "Quantity must be positive")
|
||||||
|
|
||||||
|
def _normalize_gtin(self, gtin_value) -> Optional[str]:
|
||||||
|
"""Normalize GTIN format using the GTINProcessor."""
|
||||||
|
try:
|
||||||
|
return self.gtin_processor.normalize(gtin_value)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error normalizing GTIN {gtin_value}: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_stock_entry(self, db: Session, gtin: str, location: str) -> Optional[Stock]:
|
||||||
|
"""Get stock entry by GTIN and location."""
|
||||||
|
return (
|
||||||
db.query(Stock)
|
db.query(Stock)
|
||||||
.filter(Stock.gtin == normalized_gtin, Stock.location == location)
|
.filter(Stock.gtin == gtin, Stock.location == location)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
if not existing_stock:
|
def _get_stock_by_id_or_raise(self, db: Session, stock_id: int) -> Stock:
|
||||||
raise ValueError(
|
"""Get stock by ID or raise exception."""
|
||||||
f"No stock found for GTIN {normalized_gtin} at location {location}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if we have enough stock to remove
|
|
||||||
if existing_stock.quantity < stock_data.quantity:
|
|
||||||
raise ValueError(
|
|
||||||
f"Insufficient stock. Available: {existing_stock.quantity}, Requested to remove: {stock_data.quantity}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove from existing stock
|
|
||||||
old_quantity = existing_stock.quantity
|
|
||||||
existing_stock.quantity -= stock_data.quantity
|
|
||||||
existing_stock.updated_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
db.refresh(existing_stock)
|
|
||||||
logger.info(
|
|
||||||
f"Removed stock for GTIN {normalized_gtin} at {location}: "
|
|
||||||
f"{old_quantity} - {stock_data.quantity} = {existing_stock.quantity}"
|
|
||||||
)
|
|
||||||
return existing_stock
|
|
||||||
|
|
||||||
def get_stock_by_gtin(self, db: Session, gtin: str) -> StockSummaryResponse:
|
|
||||||
"""Get all stock locations and total quantity for a specific GTIN."""
|
|
||||||
normalized_gtin = self.normalize_gtin(gtin)
|
|
||||||
if not normalized_gtin:
|
|
||||||
raise ValueError("Invalid GTIN format")
|
|
||||||
|
|
||||||
# Get all stock entries for this GTIN
|
|
||||||
stock_entries = db.query(Stock).filter(Stock.gtin == normalized_gtin).all()
|
|
||||||
|
|
||||||
if not stock_entries:
|
|
||||||
raise ValueError(f"No stock found for GTIN: {gtin}")
|
|
||||||
|
|
||||||
# Calculate total quantity and build locations list
|
|
||||||
total_quantity = 0
|
|
||||||
locations = []
|
|
||||||
|
|
||||||
for entry in stock_entries:
|
|
||||||
total_quantity += entry.quantity
|
|
||||||
locations.append(
|
|
||||||
StockLocationResponse(location=entry.location, quantity=entry.quantity)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to get product title for reference
|
|
||||||
product = db.query(Product).filter(Product.gtin == normalized_gtin).first()
|
|
||||||
product_title = product.title if product else None
|
|
||||||
|
|
||||||
return StockSummaryResponse(
|
|
||||||
gtin=normalized_gtin,
|
|
||||||
total_quantity=total_quantity,
|
|
||||||
locations=locations,
|
|
||||||
product_title=product_title,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_total_stock(self, db: Session, gtin: str) -> dict:
|
|
||||||
"""Get total quantity in stock for a specific GTIN."""
|
|
||||||
normalized_gtin = self.normalize_gtin(gtin)
|
|
||||||
if not normalized_gtin:
|
|
||||||
raise ValueError("Invalid GTIN format")
|
|
||||||
|
|
||||||
# Calculate total stock
|
|
||||||
total_stock = db.query(Stock).filter(Stock.gtin == normalized_gtin).all()
|
|
||||||
total_quantity = sum(entry.quantity for entry in total_stock)
|
|
||||||
|
|
||||||
# Get product info for context
|
|
||||||
product = db.query(Product).filter(Product.gtin == normalized_gtin).first()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"gtin": normalized_gtin,
|
|
||||||
"total_quantity": total_quantity,
|
|
||||||
"product_title": product.title if product else None,
|
|
||||||
"locations_count": len(total_stock),
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_all_stock(
|
|
||||||
self,
|
|
||||||
db: Session,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
location: Optional[str] = None,
|
|
||||||
gtin: Optional[str] = None,
|
|
||||||
) -> List[Stock]:
|
|
||||||
"""Get all stock entries with optional filtering."""
|
|
||||||
query = db.query(Stock)
|
|
||||||
|
|
||||||
if location:
|
|
||||||
query = query.filter(Stock.location.ilike(f"%{location}%"))
|
|
||||||
|
|
||||||
if gtin:
|
|
||||||
normalized_gtin = self.normalize_gtin(gtin)
|
|
||||||
if normalized_gtin:
|
|
||||||
query = query.filter(Stock.gtin == normalized_gtin)
|
|
||||||
|
|
||||||
return query.offset(skip).limit(limit).all()
|
|
||||||
|
|
||||||
def update_stock(
|
|
||||||
self, db: Session, stock_id: int, stock_update: StockUpdate
|
|
||||||
) -> Stock:
|
|
||||||
"""Update stock quantity for a specific stock entry."""
|
|
||||||
stock_entry = db.query(Stock).filter(Stock.id == stock_id).first()
|
stock_entry = db.query(Stock).filter(Stock.id == stock_id).first()
|
||||||
if not stock_entry:
|
if not stock_entry:
|
||||||
raise ValueError("Stock entry not found")
|
raise StockNotFoundException(str(stock_id))
|
||||||
|
|
||||||
stock_entry.quantity = stock_update.quantity
|
|
||||||
stock_entry.updated_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
db.refresh(stock_entry)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Updated stock entry {stock_id} to quantity {stock_update.quantity}"
|
|
||||||
)
|
|
||||||
return stock_entry
|
return stock_entry
|
||||||
|
|
||||||
def delete_stock(self, db: Session, stock_id: int) -> bool:
|
# Legacy methods for backward compatibility (deprecated)
|
||||||
"""Delete a stock entry."""
|
def normalize_gtin(self, gtin_value) -> Optional[str]:
|
||||||
stock_entry = db.query(Stock).filter(Stock.id == stock_id).first()
|
"""Normalize GTIN format. DEPRECATED: Use _normalize_gtin."""
|
||||||
if not stock_entry:
|
logger.warning("normalize_gtin is deprecated, use _normalize_gtin")
|
||||||
raise ValueError("Stock entry not found")
|
return self._normalize_gtin(gtin_value)
|
||||||
|
|
||||||
gtin = stock_entry.gtin
|
|
||||||
location = stock_entry.location
|
|
||||||
db.delete(stock_entry)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
logger.info(f"Deleted stock entry {stock_id} for GTIN {gtin} at {location}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_stock_by_id(self, db: Session, stock_id: int) -> Optional[Stock]:
|
def get_stock_by_id(self, db: Session, stock_id: int) -> Optional[Stock]:
|
||||||
"""Get a stock entry by its ID."""
|
"""Get stock by ID. DEPRECATED: Use proper exception handling."""
|
||||||
return db.query(Stock).filter(Stock.id == stock_id).first()
|
logger.warning("get_stock_by_id is deprecated, use proper exception handling")
|
||||||
|
try:
|
||||||
|
return db.query(Stock).filter(Stock.id == stock_id).first()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting stock by ID: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Create service instance
|
# Create service instance
|
||||||
|
|||||||
577
docs/development/frontend-exception-handling.md
Normal file
577
docs/development/frontend-exception-handling.md
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
# Frontend Exception Handling Guide
|
||||||
|
|
||||||
|
## Understanding Your API's Exception Structure
|
||||||
|
|
||||||
|
Your API returns consistent error responses with this structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error_code": "PRODUCT_NOT_FOUND",
|
||||||
|
"message": "Product with ID 'ABC123' not found",
|
||||||
|
"status_code": 404,
|
||||||
|
"field": "product_id",
|
||||||
|
"details": {
|
||||||
|
"resource_type": "Product",
|
||||||
|
"identifier": "ABC123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Error Handling Strategy
|
||||||
|
|
||||||
|
### 1. HTTP Client Configuration
|
||||||
|
|
||||||
|
Configure your HTTP client to handle error responses consistently:
|
||||||
|
|
||||||
|
#### Axios Example
|
||||||
|
```javascript
|
||||||
|
// api/client.js
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: 'https://your-api.com/api/v1',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response interceptor for error handling
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.data) {
|
||||||
|
// Transform API error to standardized format
|
||||||
|
const apiError = {
|
||||||
|
errorCode: error.response.data.error_code,
|
||||||
|
message: error.response.data.message,
|
||||||
|
statusCode: error.response.data.status_code,
|
||||||
|
field: error.response.data.field,
|
||||||
|
details: error.response.data.details,
|
||||||
|
originalError: error
|
||||||
|
};
|
||||||
|
throw apiError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle network errors
|
||||||
|
if (error.code === 'ECONNABORTED') {
|
||||||
|
throw {
|
||||||
|
errorCode: 'NETWORK_TIMEOUT',
|
||||||
|
message: 'Request timed out. Please try again.',
|
||||||
|
statusCode: 408
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw {
|
||||||
|
errorCode: 'NETWORK_ERROR',
|
||||||
|
message: 'Network error. Please check your connection.',
|
||||||
|
statusCode: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fetch Example
|
||||||
|
```javascript
|
||||||
|
// api/client.js
|
||||||
|
class ApiClient {
|
||||||
|
constructor(baseURL) {
|
||||||
|
this.baseURL = baseURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(endpoint, options = {}) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw {
|
||||||
|
errorCode: data.error_code,
|
||||||
|
message: data.message,
|
||||||
|
statusCode: data.status_code,
|
||||||
|
field: data.field,
|
||||||
|
details: data.details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.errorCode) {
|
||||||
|
throw error; // Re-throw API errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle network errors
|
||||||
|
throw {
|
||||||
|
errorCode: 'NETWORK_ERROR',
|
||||||
|
message: 'Failed to connect to server',
|
||||||
|
statusCode: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = new ApiClient('https://your-api.com/api/v1');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Error Code Mapping
|
||||||
|
|
||||||
|
Create mappings for user-friendly messages:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// constants/errorMessages.js
|
||||||
|
export const ERROR_MESSAGES = {
|
||||||
|
// Authentication errors
|
||||||
|
INVALID_CREDENTIALS: 'Invalid username or password. Please try again.',
|
||||||
|
TOKEN_EXPIRED: 'Your session has expired. Please log in again.',
|
||||||
|
USER_NOT_ACTIVE: 'Your account has been deactivated. Contact support.',
|
||||||
|
|
||||||
|
// Product errors
|
||||||
|
PRODUCT_NOT_FOUND: 'Product not found. It may have been removed.',
|
||||||
|
PRODUCT_ALREADY_EXISTS: 'A product with this ID already exists.',
|
||||||
|
INVALID_PRODUCT_DATA: 'Please check the product information and try again.',
|
||||||
|
|
||||||
|
// Stock errors
|
||||||
|
INSUFFICIENT_STOCK: 'Not enough stock available for this operation.',
|
||||||
|
STOCK_NOT_FOUND: 'No stock information found for this product.',
|
||||||
|
NEGATIVE_STOCK_NOT_ALLOWED: 'Stock quantity cannot be negative.',
|
||||||
|
|
||||||
|
// Shop errors
|
||||||
|
SHOP_NOT_FOUND: 'Shop not found or no longer available.',
|
||||||
|
UNAUTHORIZED_SHOP_ACCESS: 'You do not have permission to access this shop.',
|
||||||
|
SHOP_ALREADY_EXISTS: 'A shop with this code already exists.',
|
||||||
|
MAX_SHOPS_REACHED: 'You have reached the maximum number of shops allowed.',
|
||||||
|
|
||||||
|
// Import errors
|
||||||
|
IMPORT_JOB_NOT_FOUND: 'Import job not found.',
|
||||||
|
IMPORT_JOB_CANNOT_BE_CANCELLED: 'This import job cannot be cancelled at this time.',
|
||||||
|
MARKETPLACE_CONNECTION_FAILED: 'Failed to connect to marketplace. Please try again.',
|
||||||
|
|
||||||
|
// Generic fallbacks
|
||||||
|
VALIDATION_ERROR: 'Please check your input and try again.',
|
||||||
|
NETWORK_ERROR: 'Connection error. Please check your internet connection.',
|
||||||
|
NETWORK_TIMEOUT: 'Request timed out. Please try again.',
|
||||||
|
INTERNAL_SERVER_ERROR: 'Something went wrong. Please try again later.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getErrorMessage = (errorCode, fallbackMessage = null) => {
|
||||||
|
return ERROR_MESSAGES[errorCode] || fallbackMessage || 'An unexpected error occurred.';
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Component Error Handling
|
||||||
|
|
||||||
|
#### React Hook for Error Handling
|
||||||
|
```javascript
|
||||||
|
// hooks/useApiError.js
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { getErrorMessage } from '../constants/errorMessages';
|
||||||
|
|
||||||
|
export const useApiError = () => {
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleApiCall = async (apiCall) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiCall();
|
||||||
|
setIsLoading(false);
|
||||||
|
return result;
|
||||||
|
} catch (apiError) {
|
||||||
|
setIsLoading(false);
|
||||||
|
setError({
|
||||||
|
code: apiError.errorCode,
|
||||||
|
message: getErrorMessage(apiError.errorCode, apiError.message),
|
||||||
|
field: apiError.field,
|
||||||
|
details: apiError.details
|
||||||
|
});
|
||||||
|
throw apiError; // Re-throw for component-specific handling
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearError = () => setError(null);
|
||||||
|
|
||||||
|
return { error, isLoading, handleApiCall, clearError };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Form Error Handling
|
||||||
|
```javascript
|
||||||
|
// components/ProductForm.jsx
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useApiError } from '../hooks/useApiError';
|
||||||
|
import { createProduct } from '../api/products';
|
||||||
|
|
||||||
|
const ProductForm = () => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
product_id: '',
|
||||||
|
name: '',
|
||||||
|
price: ''
|
||||||
|
});
|
||||||
|
const [fieldErrors, setFieldErrors] = useState({});
|
||||||
|
const { error, isLoading, handleApiCall, clearError } = useApiError();
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFieldErrors({});
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleApiCall(() => createProduct(formData));
|
||||||
|
// Success handling
|
||||||
|
alert('Product created successfully!');
|
||||||
|
setFormData({ product_id: '', name: '', price: '' });
|
||||||
|
} catch (apiError) {
|
||||||
|
// Handle field-specific errors
|
||||||
|
if (apiError.field) {
|
||||||
|
setFieldErrors({ [apiError.field]: apiError.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
switch (apiError.errorCode) {
|
||||||
|
case 'PRODUCT_ALREADY_EXISTS':
|
||||||
|
setFieldErrors({ product_id: 'This product ID is already taken' });
|
||||||
|
break;
|
||||||
|
case 'INVALID_PRODUCT_DATA':
|
||||||
|
if (apiError.field) {
|
||||||
|
setFieldErrors({ [apiError.field]: apiError.message });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Generic error is handled by useApiError hook
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{error && !error.field && (
|
||||||
|
<div className="error-banner">
|
||||||
|
{error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-field">
|
||||||
|
<label>Product ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.product_id}
|
||||||
|
onChange={(e) => setFormData({...formData, product_id: e.target.value})}
|
||||||
|
className={fieldErrors.product_id ? 'error' : ''}
|
||||||
|
/>
|
||||||
|
{fieldErrors.product_id && (
|
||||||
|
<span className="field-error">{fieldErrors.product_id}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-field">
|
||||||
|
<label>Product Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||||
|
className={fieldErrors.name ? 'error' : ''}
|
||||||
|
/>
|
||||||
|
{fieldErrors.name && (
|
||||||
|
<span className="field-error">{fieldErrors.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Creating...' : 'Create Product'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Global Error Handler
|
||||||
|
|
||||||
|
#### React Error Boundary
|
||||||
|
```javascript
|
||||||
|
// components/ErrorBoundary.jsx
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
class ErrorBoundary extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error) {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error, errorInfo) {
|
||||||
|
console.error('Error caught by boundary:', error, errorInfo);
|
||||||
|
|
||||||
|
// Log to error reporting service
|
||||||
|
if (window.errorReporting) {
|
||||||
|
window.errorReporting.captureException(error, {
|
||||||
|
extra: errorInfo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="error-fallback">
|
||||||
|
<h2>Something went wrong</h2>
|
||||||
|
<p>We're sorry, but something unexpected happened.</p>
|
||||||
|
<button onClick={() => window.location.reload()}>
|
||||||
|
Reload Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Global Toast Notifications
|
||||||
|
```javascript
|
||||||
|
// utils/notifications.js
|
||||||
|
class NotificationManager {
|
||||||
|
constructor() {
|
||||||
|
this.notifications = [];
|
||||||
|
this.listeners = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(callback) {
|
||||||
|
this.listeners.push(callback);
|
||||||
|
return () => {
|
||||||
|
this.listeners = this.listeners.filter(l => l !== callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(type, message, options = {}) {
|
||||||
|
const notification = {
|
||||||
|
id: Date.now(),
|
||||||
|
type, // 'success', 'error', 'warning', 'info'
|
||||||
|
message,
|
||||||
|
duration: options.duration || 5000,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.notifications.push(notification);
|
||||||
|
this.listeners.forEach(callback => callback(this.notifications));
|
||||||
|
|
||||||
|
if (notification.duration > 0) {
|
||||||
|
setTimeout(() => this.remove(notification.id), notification.duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyError(apiError) {
|
||||||
|
const message = getErrorMessage(apiError.errorCode, apiError.message);
|
||||||
|
this.notify('error', message, {
|
||||||
|
errorCode: apiError.errorCode,
|
||||||
|
details: apiError.details
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id) {
|
||||||
|
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||||
|
this.listeners.forEach(callback => callback(this.notifications));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationManager = new NotificationManager();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Specific Error Handling Patterns
|
||||||
|
|
||||||
|
#### Authentication Errors
|
||||||
|
```javascript
|
||||||
|
// api/auth.js
|
||||||
|
import { apiClient } from './client';
|
||||||
|
import { notificationManager } from '../utils/notifications';
|
||||||
|
|
||||||
|
export const login = async (credentials) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/auth/login', credentials);
|
||||||
|
localStorage.setItem('token', response.access_token);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
switch (error.errorCode) {
|
||||||
|
case 'INVALID_CREDENTIALS':
|
||||||
|
notificationManager.notify('error', 'Invalid username or password');
|
||||||
|
break;
|
||||||
|
case 'USER_NOT_ACTIVE':
|
||||||
|
notificationManager.notify('error', 'Account deactivated. Contact support.');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
notificationManager.notifyError(error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle token expiration globally
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.data?.error_code === 'TOKEN_EXPIRED') {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
notificationManager.notify('warning', 'Session expired. Please log in again.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Stock Management Errors
|
||||||
|
```javascript
|
||||||
|
// components/StockManager.jsx
|
||||||
|
const handleStockUpdate = async (gtin, location, quantity) => {
|
||||||
|
try {
|
||||||
|
await updateStock(gtin, location, quantity);
|
||||||
|
notificationManager.notify('success', 'Stock updated successfully');
|
||||||
|
} catch (error) {
|
||||||
|
switch (error.errorCode) {
|
||||||
|
case 'INSUFFICIENT_STOCK':
|
||||||
|
const { available_quantity, requested_quantity } = error.details;
|
||||||
|
notificationManager.notify('error',
|
||||||
|
`Cannot remove ${requested_quantity} items. Only ${available_quantity} available.`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'STOCK_NOT_FOUND':
|
||||||
|
notificationManager.notify('error', 'No stock record found for this product');
|
||||||
|
break;
|
||||||
|
case 'NEGATIVE_STOCK_NOT_ALLOWED':
|
||||||
|
notificationManager.notify('error', 'Stock quantity cannot be negative');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
notificationManager.notifyError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Import Job Monitoring
|
||||||
|
```javascript
|
||||||
|
// components/ImportJobStatus.jsx
|
||||||
|
const ImportJobStatus = ({ jobId }) => {
|
||||||
|
const [job, setJob] = useState(null);
|
||||||
|
const { error, isLoading, handleApiCall } = useApiError();
|
||||||
|
|
||||||
|
const fetchJobStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const jobData = await handleApiCall(() => getImportJobStatus(jobId));
|
||||||
|
setJob(jobData);
|
||||||
|
} catch (error) {
|
||||||
|
switch (error.errorCode) {
|
||||||
|
case 'IMPORT_JOB_NOT_FOUND':
|
||||||
|
notificationManager.notify('error', 'Import job not found or has been deleted');
|
||||||
|
break;
|
||||||
|
case 'IMPORT_JOB_NOT_OWNED':
|
||||||
|
notificationManager.notify('error', 'You do not have access to this import job');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
notificationManager.notifyError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [jobId, handleApiCall]);
|
||||||
|
|
||||||
|
const cancelJob = async () => {
|
||||||
|
try {
|
||||||
|
await handleApiCall(() => cancelImportJob(jobId));
|
||||||
|
notificationManager.notify('success', 'Import job cancelled successfully');
|
||||||
|
fetchJobStatus(); // Refresh status
|
||||||
|
} catch (error) {
|
||||||
|
switch (error.errorCode) {
|
||||||
|
case 'IMPORT_JOB_CANNOT_BE_CANCELLED':
|
||||||
|
const { current_status } = error.details;
|
||||||
|
notificationManager.notify('error',
|
||||||
|
`Cannot cancel job in ${current_status} status`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
notificationManager.notifyError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="import-job-status">
|
||||||
|
{/* Job status UI */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Error Logging and Monitoring
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// utils/errorReporting.js
|
||||||
|
export const logError = (error, context = {}) => {
|
||||||
|
const errorData = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
errorCode: error.errorCode,
|
||||||
|
message: error.message,
|
||||||
|
statusCode: error.statusCode,
|
||||||
|
field: error.field,
|
||||||
|
details: error.details,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
url: window.location.href,
|
||||||
|
userId: getCurrentUserId(),
|
||||||
|
...context
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log to console in development
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.error('API Error:', errorData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to error tracking service
|
||||||
|
if (window.errorTracker) {
|
||||||
|
window.errorTracker.captureException(error, {
|
||||||
|
tags: {
|
||||||
|
errorCode: error.errorCode,
|
||||||
|
statusCode: error.statusCode
|
||||||
|
},
|
||||||
|
extra: errorData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in local storage for offline analysis
|
||||||
|
try {
|
||||||
|
const existingErrors = JSON.parse(localStorage.getItem('apiErrors') || '[]');
|
||||||
|
existingErrors.push(errorData);
|
||||||
|
|
||||||
|
// Keep only last 50 errors
|
||||||
|
if (existingErrors.length > 50) {
|
||||||
|
existingErrors.splice(0, existingErrors.length - 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('apiErrors', JSON.stringify(existingErrors));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to store error locally:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Best Practices Summary
|
||||||
|
|
||||||
|
1. **Consistent Error Structure**: Always expect the same error format from your API
|
||||||
|
2. **User-Friendly Messages**: Map error codes to readable messages
|
||||||
|
3. **Field-Specific Errors**: Handle validation errors at the field level
|
||||||
|
4. **Progressive Enhancement**: Start with basic error handling, add sophistication gradually
|
||||||
|
5. **Logging**: Track errors for debugging and improving user experience
|
||||||
|
6. **Fallback Handling**: Always have fallback messages for unknown error codes
|
||||||
|
7. **Loading States**: Show appropriate loading indicators during API calls
|
||||||
|
8. **Retry Logic**: Implement retry for transient network errors
|
||||||
|
9. **Offline Handling**: Consider offline scenarios and network recovery
|
||||||
|
|
||||||
|
This approach gives you robust error handling that scales with your application while providing excellent user experience and debugging capabilities.
|
||||||
9
main.py
9
main.py
@@ -11,6 +11,8 @@ from app.api.main import api_router
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.lifespan import lifespan
|
from app.core.lifespan import lifespan
|
||||||
|
from app.exceptions.handler import setup_exception_handlers
|
||||||
|
from app.exceptions import ServiceUnavailableException
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -22,6 +24,9 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Setup custom exception handlers (unified approach)
|
||||||
|
setup_exception_handlers(app)
|
||||||
|
|
||||||
# Add CORS middleware
|
# Add CORS middleware
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -72,9 +77,7 @@ def health_check(db: Session = Depends(get_db)):
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Health check failed: {e}")
|
logger.error(f"Health check failed: {e}")
|
||||||
raise HTTPException(status_code=503, detail="Service unhealthy")
|
raise ServiceUnavailableException("Service unhealthy")
|
||||||
|
|
||||||
# Add this temporary endpoint to your router:
|
|
||||||
|
|
||||||
|
|
||||||
# Documentation redirect endpoints
|
# Documentation redirect endpoints
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ from jose import jwt
|
|||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.exceptions import (
|
||||||
|
AdminRequiredException,
|
||||||
|
InvalidTokenException,
|
||||||
|
TokenExpiredException,
|
||||||
|
UserNotActiveException,
|
||||||
|
InvalidCredentialsException
|
||||||
|
)
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -46,7 +53,7 @@ class AuthManager:
|
|||||||
return pwd_context.verify(plain_password, hashed_password)
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
def authenticate_user(
|
def authenticate_user(
|
||||||
self, db: Session, username: str, password: str
|
self, db: Session, username: str, password: str
|
||||||
) -> Optional[User]:
|
) -> Optional[User]:
|
||||||
"""Authenticate user and return user object if valid."""
|
"""Authenticate user and return user object if valid."""
|
||||||
user = (
|
user = (
|
||||||
@@ -101,17 +108,15 @@ class AuthManager:
|
|||||||
# Check if token has expired
|
# Check if token has expired
|
||||||
exp = payload.get("exp")
|
exp = payload.get("exp")
|
||||||
if exp is None:
|
if exp is None:
|
||||||
raise HTTPException(status_code=401, detail="Token missing expiration")
|
raise InvalidTokenException("Token missing expiration")
|
||||||
|
|
||||||
if datetime.utcnow() > datetime.fromtimestamp(exp):
|
if datetime.utcnow() > datetime.fromtimestamp(exp):
|
||||||
raise HTTPException(status_code=401, detail="Token has expired")
|
raise TokenExpiredException()
|
||||||
|
|
||||||
# Extract user data
|
# Extract user data
|
||||||
user_id = payload.get("sub")
|
user_id = payload.get("sub")
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
raise HTTPException(
|
raise InvalidTokenException("Token missing user identifier")
|
||||||
status_code=401, detail="Token missing user identifier"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"user_id": int(user_id),
|
"user_id": int(user_id),
|
||||||
@@ -121,28 +126,24 @@ class AuthManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
except jwt.ExpiredSignatureError:
|
except jwt.ExpiredSignatureError:
|
||||||
raise HTTPException(status_code=401, detail="Token has expired")
|
raise TokenExpiredException()
|
||||||
except jwt.JWTError as e:
|
except jwt.JWTError as e:
|
||||||
logger.error(f"JWT decode error: {e}")
|
logger.error(f"JWT decode error: {e}")
|
||||||
raise HTTPException(
|
raise InvalidTokenException("Could not validate credentials")
|
||||||
status_code=401, detail="Could not validate credentials"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Token verification error: {e}")
|
logger.error(f"Token verification error: {e}")
|
||||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
raise InvalidTokenException("Authentication failed")
|
||||||
|
|
||||||
def get_current_user(
|
def get_current_user(self, db: Session, credentials: HTTPAuthorizationCredentials) -> User:
|
||||||
self, db: Session, credentials: HTTPAuthorizationCredentials
|
|
||||||
) -> User:
|
|
||||||
"""Get current authenticated user from database."""
|
"""Get current authenticated user from database."""
|
||||||
user_data = self.verify_token(credentials.credentials)
|
user_data = self.verify_token(credentials.credentials)
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == user_data["user_id"]).first()
|
user = db.query(User).filter(User.id == user_data["user_id"]).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="User not found")
|
raise InvalidCredentialsException("User not found")
|
||||||
|
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise HTTPException(status_code=401, detail="User account is inactive")
|
raise UserNotActiveException()
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -165,7 +166,7 @@ class AuthManager:
|
|||||||
def require_admin(self, current_user: User):
|
def require_admin(self, current_user: User):
|
||||||
"""Require admin role."""
|
"""Require admin role."""
|
||||||
if current_user.role != "admin":
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin privileges required")
|
raise AdminRequiredException()
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
def create_default_admin_user(self, db: Session):
|
def create_default_admin_user(self, db: Session):
|
||||||
|
|||||||
@@ -1,36 +1,35 @@
|
|||||||
# middleware/decorators.py
|
# middleware/decorators.py
|
||||||
"""Summary description ....
|
"""
|
||||||
|
FastAPI decorators for cross-cutting concerns.
|
||||||
|
|
||||||
This module provides classes and functions for:
|
This module provides classes and functions for:
|
||||||
- ....
|
- Rate limiting decorators for endpoint protection
|
||||||
- ....
|
- Request throttling and abuse prevention
|
||||||
- ....
|
- Consistent error handling for rate limit violations
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from app.exceptions.base import RateLimitException # Add this import
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
from middleware.rate_limiter import RateLimiter
|
from middleware.rate_limiter import RateLimiter
|
||||||
|
|
||||||
# Initialize rate limiter instance
|
# Initialize rate limiter instance
|
||||||
rate_limiter = RateLimiter()
|
rate_limiter = RateLimiter()
|
||||||
|
|
||||||
|
|
||||||
def rate_limit(max_requests: int = 100, window_seconds: int = 3600):
|
def rate_limit(max_requests: int = 100, window_seconds: int = 3600):
|
||||||
"""Rate limiting decorator for FastAPI endpoints."""
|
"""Rate limiting decorator for FastAPI endpoints."""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def wrapper(*args, **kwargs):
|
async def wrapper(*args, **kwargs):
|
||||||
# Extract client IP or user ID for rate limiting
|
|
||||||
client_id = "anonymous" # In production, extract from request
|
client_id = "anonymous" # In production, extract from request
|
||||||
|
|
||||||
if not rate_limiter.allow_request(client_id, max_requests, window_seconds):
|
if not rate_limiter.allow_request(client_id, max_requests, window_seconds):
|
||||||
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
# Use custom exception instead of HTTPException
|
||||||
|
raise RateLimitException(
|
||||||
|
message="Rate limit exceeded",
|
||||||
|
retry_after=window_seconds
|
||||||
|
)
|
||||||
|
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
# middleware/error_handler.py
|
|
||||||
"""Summary description ....
|
|
||||||
|
|
||||||
This module provides classes and functions for:
|
|
||||||
- ....
|
|
||||||
- ....
|
|
||||||
- ....
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
|
||||||
from fastapi.exceptions import RequestValidationError
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def custom_http_exception_handler(request: Request, exc: HTTPException):
|
|
||||||
"""Handle HTTP exception."""
|
|
||||||
logger.error(
|
|
||||||
f"HTTP {exc.status_code}: {exc.detail} - {request.method} {request.url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=exc.status_code,
|
|
||||||
content={
|
|
||||||
"error": {
|
|
||||||
"code": exc.status_code,
|
|
||||||
"message": exc.detail,
|
|
||||||
"type": "http_exception",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
|
||||||
"""Handle Pydantic validation errors."""
|
|
||||||
logger.error(f"Validation error: {exc.errors()} - {request.method} {request.url}")
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=422,
|
|
||||||
content={
|
|
||||||
"error": {
|
|
||||||
"code": 422,
|
|
||||||
"message": "Validation error",
|
|
||||||
"type": "validation_error",
|
|
||||||
"details": exc.errors(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def general_exception_handler(request: Request, exc: Exception):
|
|
||||||
"""Handle unexpected exceptions."""
|
|
||||||
logger.error(
|
|
||||||
f"Unexpected error: {str(exc)} - {request.method} {request.url}", exc_info=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={
|
|
||||||
"error": {
|
|
||||||
"code": 500,
|
|
||||||
"message": "Internal server error",
|
|
||||||
"type": "server_error",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -35,6 +35,8 @@ nav:
|
|||||||
- Services: development/services.md
|
- Services: development/services.md
|
||||||
- Contributing: development/contributing.md
|
- Contributing: development/contributing.md
|
||||||
- Pycharm configuration: development/pycharm-configuration.md
|
- Pycharm configuration: development/pycharm-configuration.md
|
||||||
|
- Pycharm configuration: development/exception-handling.md
|
||||||
|
- Pycharm configuration: development/frontend-exception-handling.md
|
||||||
- Deployment:
|
- Deployment:
|
||||||
- Overview: deployment/index.md
|
- Overview: deployment/index.md
|
||||||
- Docker: deployment/docker.md
|
- Docker: deployment/docker.md
|
||||||
|
|||||||
16
tests/fixtures/auth_fixtures.py
vendored
16
tests/fixtures/auth_fixtures.py
vendored
@@ -48,6 +48,22 @@ def test_admin(db, auth_manager):
|
|||||||
db.refresh(admin)
|
db.refresh(admin)
|
||||||
return admin
|
return admin
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def another_admin(db, auth_manager):
|
||||||
|
"""Create another test admin user for testing admin-to-admin interactions"""
|
||||||
|
unique_id = str(uuid.uuid4())[:8] # Short unique identifier
|
||||||
|
hashed_password = auth_manager.hash_password("anotheradminpass123")
|
||||||
|
admin = User(
|
||||||
|
email=f"another_admin_{unique_id}@example.com",
|
||||||
|
username=f"another_admin_{unique_id}",
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
role="admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(admin)
|
||||||
|
return admin
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def other_user(db, auth_manager):
|
def other_user(db, auth_manager):
|
||||||
|
|||||||
@@ -23,10 +23,9 @@ class TestAdminAPI:
|
|||||||
response = client.get("/api/v1/admin/users", headers=auth_headers)
|
response = client.get("/api/v1/admin/users", headers=auth_headers)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert (
|
data = response.json()
|
||||||
"Access denied" in response.json()["detail"]
|
assert data["error_code"] == "ADMIN_REQUIRED"
|
||||||
or "admin" in response.json()["detail"].lower()
|
assert "Admin privileges required" in data["message"]
|
||||||
)
|
|
||||||
|
|
||||||
def test_toggle_user_status_admin(self, client, admin_headers, test_user):
|
def test_toggle_user_status_admin(self, client, admin_headers, test_user):
|
||||||
"""Test admin toggling user status"""
|
"""Test admin toggling user status"""
|
||||||
@@ -45,18 +44,35 @@ class TestAdminAPI:
|
|||||||
response = client.put("/api/v1/admin/users/99999/status", headers=admin_headers)
|
response = client.put("/api/v1/admin/users/99999/status", headers=admin_headers)
|
||||||
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
assert "User not found" in response.json()["detail"]
|
data = response.json()
|
||||||
|
assert data["error_code"] == "USER_NOT_FOUND"
|
||||||
|
assert "User with ID '99999' not found" in data["message"]
|
||||||
|
|
||||||
def test_toggle_user_status_cannot_deactivate_self(
|
def test_toggle_user_status_cannot_modify_self(
|
||||||
self, client, admin_headers, test_admin
|
self, client, admin_headers, test_admin
|
||||||
):
|
):
|
||||||
"""Test that admin cannot deactivate their own account"""
|
"""Test that admin cannot modify their own account"""
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1/admin/users/{test_admin.id}/status", headers=admin_headers
|
f"/api/v1/admin/users/{test_admin.id}/status", headers=admin_headers
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400 # Business logic error
|
||||||
assert "Cannot deactivate your own account" in response.json()["detail"]
|
data = response.json()
|
||||||
|
assert data["error_code"] == "CANNOT_MODIFY_SELF"
|
||||||
|
assert "Cannot perform 'deactivate account' on your own account" in data["message"]
|
||||||
|
|
||||||
|
def test_toggle_user_status_cannot_modify_admin(
|
||||||
|
self, client, admin_headers, test_admin, another_admin
|
||||||
|
):
|
||||||
|
"""Test that admin cannot modify another admin"""
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1/admin/users/{another_admin.id}/status", headers=admin_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400 # Business logic error
|
||||||
|
data = response.json()
|
||||||
|
assert data["error_code"] == "USER_STATUS_CHANGE_FAILED"
|
||||||
|
assert "Cannot modify another admin user" in data["message"]
|
||||||
|
|
||||||
def test_get_all_shops_admin(self, client, admin_headers, test_shop):
|
def test_get_all_shops_admin(self, client, admin_headers, test_shop):
|
||||||
"""Test admin getting all shops"""
|
"""Test admin getting all shops"""
|
||||||
@@ -78,10 +94,8 @@ class TestAdminAPI:
|
|||||||
response = client.get("/api/v1/admin/shops", headers=auth_headers)
|
response = client.get("/api/v1/admin/shops", headers=auth_headers)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert (
|
data = response.json()
|
||||||
"Access denied" in response.json()["detail"]
|
assert data["error_code"] == "ADMIN_REQUIRED"
|
||||||
or "admin" in response.json()["detail"].lower()
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_verify_shop_admin(self, client, admin_headers, test_shop):
|
def test_verify_shop_admin(self, client, admin_headers, test_shop):
|
||||||
"""Test admin verifying/unverifying shop"""
|
"""Test admin verifying/unverifying shop"""
|
||||||
@@ -99,7 +113,9 @@ class TestAdminAPI:
|
|||||||
response = client.put("/api/v1/admin/shops/99999/verify", headers=admin_headers)
|
response = client.put("/api/v1/admin/shops/99999/verify", headers=admin_headers)
|
||||||
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
assert "Shop not found" in response.json()["detail"]
|
data = response.json()
|
||||||
|
assert data["error_code"] == "SHOP_NOT_FOUND"
|
||||||
|
assert "Shop with ID '99999' not found" in data["message"]
|
||||||
|
|
||||||
def test_toggle_shop_status_admin(self, client, admin_headers, test_shop):
|
def test_toggle_shop_status_admin(self, client, admin_headers, test_shop):
|
||||||
"""Test admin toggling shop status"""
|
"""Test admin toggling shop status"""
|
||||||
@@ -117,7 +133,8 @@ class TestAdminAPI:
|
|||||||
response = client.put("/api/v1/admin/shops/99999/status", headers=admin_headers)
|
response = client.put("/api/v1/admin/shops/99999/status", headers=admin_headers)
|
||||||
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
assert "Shop not found" in response.json()["detail"]
|
data = response.json()
|
||||||
|
assert data["error_code"] == "SHOP_NOT_FOUND"
|
||||||
|
|
||||||
def test_get_marketplace_import_jobs_admin(
|
def test_get_marketplace_import_jobs_admin(
|
||||||
self, client, admin_headers, test_marketplace_job
|
self, client, admin_headers, test_marketplace_job
|
||||||
@@ -159,10 +176,32 @@ class TestAdminAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert (
|
data = response.json()
|
||||||
"Access denied" in response.json()["detail"]
|
assert data["error_code"] == "ADMIN_REQUIRED"
|
||||||
or "admin" in response.json()["detail"].lower()
|
|
||||||
)
|
def test_get_user_statistics(self, client, admin_headers):
|
||||||
|
"""Test admin getting user statistics"""
|
||||||
|
response = client.get("/api/v1/admin/stats/users", headers=admin_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "total_users" in data
|
||||||
|
assert "active_users" in data
|
||||||
|
assert "inactive_users" in data
|
||||||
|
assert "activation_rate" in data
|
||||||
|
assert isinstance(data["total_users"], int)
|
||||||
|
|
||||||
|
def test_get_shop_statistics(self, client, admin_headers):
|
||||||
|
"""Test admin getting shop statistics"""
|
||||||
|
response = client.get("/api/v1/admin/stats/shops", headers=admin_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "total_shops" in data
|
||||||
|
assert "active_shops" in data
|
||||||
|
assert "verified_shops" in data
|
||||||
|
assert "verification_rate" in data
|
||||||
|
assert isinstance(data["total_shops"], int)
|
||||||
|
|
||||||
def test_admin_pagination_users(self, client, admin_headers, test_user, test_admin):
|
def test_admin_pagination_users(self, client, admin_headers, test_user, test_admin):
|
||||||
"""Test user pagination works correctly"""
|
"""Test user pagination works correctly"""
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# tests/test_authentication_endpoints.py
|
# tests/integration/api/v1/test_authentication_endpoints.py
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
@@ -34,8 +34,11 @@ class TestAuthenticationAPI:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 409 # Changed from 400 to 409
|
||||||
assert "Email already registered" in response.json()["detail"]
|
data = response.json()
|
||||||
|
assert data["error_code"] == "USER_ALREADY_EXISTS"
|
||||||
|
assert "Email already exists" in data["message"]
|
||||||
|
assert data["field"] == "email"
|
||||||
|
|
||||||
def test_register_user_duplicate_username(self, client, test_user):
|
def test_register_user_duplicate_username(self, client, test_user):
|
||||||
"""Test registration with duplicate username"""
|
"""Test registration with duplicate username"""
|
||||||
@@ -48,8 +51,11 @@ class TestAuthenticationAPI:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 409 # Changed from 400 to 409
|
||||||
assert "Username already taken" in response.json()["detail"]
|
data = response.json()
|
||||||
|
assert data["error_code"] == "USER_ALREADY_EXISTS"
|
||||||
|
assert "Username already taken" in data["message"]
|
||||||
|
assert data["field"] == "username"
|
||||||
|
|
||||||
def test_login_success(self, client, test_user):
|
def test_login_success(self, client, test_user):
|
||||||
"""Test successful login"""
|
"""Test successful login"""
|
||||||
@@ -73,9 +79,11 @@ class TestAuthenticationAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
assert "Incorrect username or password" in response.json()["detail"]
|
data = response.json()
|
||||||
|
assert data["error_code"] == "INVALID_CREDENTIALS"
|
||||||
|
assert "Invalid username or password" in data["message"]
|
||||||
|
|
||||||
def test_login_nonexistent_user(self, client, db): # Added db fixture
|
def test_login_nonexistent_user(self, client, db):
|
||||||
"""Test login with nonexistent user"""
|
"""Test login with nonexistent user"""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
@@ -83,6 +91,28 @@ class TestAuthenticationAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
data = response.json()
|
||||||
|
assert data["error_code"] == "INVALID_CREDENTIALS"
|
||||||
|
|
||||||
|
def test_login_inactive_user(self, client, db, test_user):
|
||||||
|
"""Test login with inactive user account"""
|
||||||
|
# Manually deactivate the user for this test
|
||||||
|
test_user.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": test_user.username, "password": "testpass123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
data = response.json()
|
||||||
|
assert data["error_code"] == "USER_NOT_ACTIVE"
|
||||||
|
assert "User account is not active" in data["message"]
|
||||||
|
|
||||||
|
# Reactivate for other tests
|
||||||
|
test_user.is_active = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
def test_get_current_user_info(self, client, auth_headers, test_user):
|
def test_get_current_user_info(self, client, auth_headers, test_user):
|
||||||
"""Test getting current user info"""
|
"""Test getting current user info"""
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# tests/test_export.py
|
# tests/integration/api/v1/test_export.py
|
||||||
import csv
|
import csv
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# tests/test_filtering.py
|
# tests/integration/api/v1/test_filtering.py
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from models.database.product import Product
|
from models.database.product import Product
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# tests/test_marketplace_endpoints.py
|
# tests/integration/api/v1/test_marketplace_endpoints.py
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# tests/test_product_endpoints.py
|
# tests/integration/api/v1/test_product_endpoints.py
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@@ -82,16 +82,27 @@ class TestProductsAPI:
|
|||||||
"/api/v1/product", headers=auth_headers, json=product_data
|
"/api/v1/product", headers=auth_headers, json=product_data
|
||||||
)
|
)
|
||||||
|
|
||||||
# Debug output
|
assert response.status_code == 409 # Changed from 400 to 409
|
||||||
print(f"Status Code: {response.status_code}")
|
data = response.json()
|
||||||
print(f"Response Content: {response.content}")
|
assert data["error_code"] == "PRODUCT_ALREADY_EXISTS"
|
||||||
try:
|
assert test_product.product_id in data["message"]
|
||||||
print(f"Response JSON: {response.json()}")
|
|
||||||
except:
|
def test_create_product_invalid_data(self, client, auth_headers):
|
||||||
print("Could not parse response as JSON")
|
"""Test creating product with invalid data"""
|
||||||
|
product_data = {
|
||||||
|
"product_id": "", # Empty product ID
|
||||||
|
"title": "New Product",
|
||||||
|
"price": "invalid_price",
|
||||||
|
"gtin": "invalid_gtin",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/product", headers=auth_headers, json=product_data
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "already exists" in response.json()["detail"]
|
data = response.json()
|
||||||
|
assert data["error_code"] in ["INVALID_PRODUCT_DATA", "PRODUCT_VALIDATION_FAILED"]
|
||||||
|
|
||||||
def test_get_product_by_id(self, client, auth_headers, test_product):
|
def test_get_product_by_id(self, client, auth_headers, test_product):
|
||||||
"""Test getting specific product"""
|
"""Test getting specific product"""
|
||||||
@@ -109,6 +120,9 @@ class TestProductsAPI:
|
|||||||
response = client.get("/api/v1/product/NONEXISTENT", headers=auth_headers)
|
response = client.get("/api/v1/product/NONEXISTENT", headers=auth_headers)
|
||||||
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
data = response.json()
|
||||||
|
assert data["error_code"] == "PRODUCT_NOT_FOUND"
|
||||||
|
assert "NONEXISTENT" in data["message"]
|
||||||
|
|
||||||
def test_update_product(self, client, auth_headers, test_product):
|
def test_update_product(self, client, auth_headers, test_product):
|
||||||
"""Test updating product"""
|
"""Test updating product"""
|
||||||
@@ -125,6 +139,34 @@ class TestProductsAPI:
|
|||||||
assert data["title"] == "Updated Product Title"
|
assert data["title"] == "Updated Product Title"
|
||||||
assert data["price"] == "25.99"
|
assert data["price"] == "25.99"
|
||||||
|
|
||||||
|
def test_update_nonexistent_product(self, client, auth_headers):
|
||||||
|
"""Test updating nonexistent product"""
|
||||||
|
update_data = {"title": "Updated Product Title"}
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/product/NONEXISTENT",
|
||||||
|
headers=auth_headers,
|
||||||
|
json=update_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
data = response.json()
|
||||||
|
assert data["error_code"] == "PRODUCT_NOT_FOUND"
|
||||||
|
|
||||||
|
def test_update_product_invalid_data(self, client, auth_headers, test_product):
|
||||||
|
"""Test updating product with invalid data"""
|
||||||
|
update_data = {"title": "", "price": "invalid_price"}
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1/product/{test_product.product_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json=update_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert data["error_code"] in ["INVALID_PRODUCT_DATA", "PRODUCT_VALIDATION_FAILED"]
|
||||||
|
|
||||||
def test_delete_product(self, client, auth_headers, test_product):
|
def test_delete_product(self, client, auth_headers, test_product):
|
||||||
"""Test deleting product"""
|
"""Test deleting product"""
|
||||||
response = client.delete(
|
response = client.delete(
|
||||||
@@ -134,6 +176,14 @@ class TestProductsAPI:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "deleted successfully" in response.json()["message"]
|
assert "deleted successfully" in response.json()["message"]
|
||||||
|
|
||||||
|
def test_delete_nonexistent_product(self, client, auth_headers):
|
||||||
|
"""Test deleting nonexistent product"""
|
||||||
|
response = client.delete("/api/v1/product/NONEXISTENT", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
data = response.json()
|
||||||
|
assert data["error_code"] == "PRODUCT_NOT_FOUND"
|
||||||
|
|
||||||
def test_get_product_without_auth(self, client):
|
def test_get_product_without_auth(self, client):
|
||||||
"""Test that product endpoints require authentication"""
|
"""Test that product endpoints require authentication"""
|
||||||
response = client.get("/api/v1/product")
|
response = client.get("/api/v1/product")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# tests/test_shop_endpoints.py
|
# tests/integration/api/v1/test_shop_endpoints.py
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# tests/test_stats_endpoints.py
|
# tests/integration/api/v1/test_stats_endpoints.py
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# tests/test_stock_endpoints.py
|
# tests/integration/api/v1/test_stock_endpoints.py
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from models.database.stock import Stock
|
from models.database.stock import Stock
|
||||||
|
|||||||
Reference in New Issue
Block a user