Exception handling enhancement
This commit is contained in:
@@ -1,19 +1,27 @@
|
||||
# app/services/admin_service.py
|
||||
"""Summary description ....
|
||||
"""
|
||||
Admin service for managing users, shops, and import jobs.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- ....
|
||||
- ....
|
||||
- ....
|
||||
- User management and status control
|
||||
- Shop verification and activation
|
||||
- Marketplace import job monitoring
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
UserNotFoundException,
|
||||
UserStatusChangeException,
|
||||
CannotModifySelfException,
|
||||
ShopNotFoundException,
|
||||
ShopVerificationException,
|
||||
AdminOperationException,
|
||||
)
|
||||
from models.schemas.marketplace import MarketplaceImportJobResponse
|
||||
from models.database.marketplace import MarketplaceImportJob
|
||||
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]:
|
||||
"""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(
|
||||
self, db: Session, user_id: int, current_admin_id: int
|
||||
self, db: Session, user_id: int, current_admin_id: int
|
||||
) -> Tuple[User, str]:
|
||||
"""
|
||||
Toggle user active status.
|
||||
@@ -44,30 +59,50 @@ class AdminService:
|
||||
Tuple of (updated_user, status_message)
|
||||
|
||||
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()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
user = self._get_user_by_id_or_raise(db, user_id)
|
||||
|
||||
# Prevent self-modification
|
||||
if user.id == current_admin_id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Cannot deactivate your own account"
|
||||
raise CannotModifySelfException(user_id, "deactivate 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
|
||||
user.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
try:
|
||||
original_status = user.is_active
|
||||
user.is_active = not user.is_active
|
||||
user.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
status = "activated" if user.is_active else "deactivated"
|
||||
logger.info(
|
||||
f"User {user.username} has been {status} by admin {current_admin_id}"
|
||||
)
|
||||
return user, f"User {user.username} has been {status}"
|
||||
status_action = "activated" if user.is_active else "deactivated"
|
||||
message = f"User {user.username} has been {status_action}"
|
||||
|
||||
logger.info(f"{message} by admin {current_admin_id}")
|
||||
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(
|
||||
self, db: Session, skip: int = 0, limit: int = 100
|
||||
self, db: Session, skip: int = 0, limit: int = 100
|
||||
) -> Tuple[List[Shop], int]:
|
||||
"""
|
||||
Get paginated list of all shops with total count.
|
||||
@@ -80,9 +115,16 @@ class AdminService:
|
||||
Returns:
|
||||
Tuple of (shops_list, total_count)
|
||||
"""
|
||||
total = db.query(Shop).count()
|
||||
shops = db.query(Shop).offset(skip).limit(limit).all()
|
||||
return shops, total
|
||||
try:
|
||||
total = db.query(Shop).count()
|
||||
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]:
|
||||
"""
|
||||
@@ -96,20 +138,37 @@ class AdminService:
|
||||
Tuple of (updated_shop, status_message)
|
||||
|
||||
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()
|
||||
if not shop:
|
||||
raise HTTPException(status_code=404, detail="Shop not found")
|
||||
shop = self._get_shop_by_id_or_raise(db, shop_id)
|
||||
|
||||
shop.is_verified = not shop.is_verified
|
||||
shop.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(shop)
|
||||
try:
|
||||
original_status = shop.is_verified
|
||||
shop.is_verified = not shop.is_verified
|
||||
shop.updated_at = datetime.utcnow()
|
||||
|
||||
status = "verified" if shop.is_verified else "unverified"
|
||||
logger.info(f"Shop {shop.shop_code} has been {status}")
|
||||
return shop, f"Shop {shop.shop_code} has been {status}"
|
||||
# Add verification timestamp if implementing audit trail
|
||||
if shop.is_verified:
|
||||
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]:
|
||||
"""
|
||||
@@ -123,29 +182,42 @@ class AdminService:
|
||||
Tuple of (updated_shop, status_message)
|
||||
|
||||
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()
|
||||
if not shop:
|
||||
raise HTTPException(status_code=404, detail="Shop not found")
|
||||
shop = self._get_shop_by_id_or_raise(db, shop_id)
|
||||
|
||||
shop.is_active = not shop.is_active
|
||||
shop.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(shop)
|
||||
try:
|
||||
original_status = shop.is_active
|
||||
shop.is_active = not shop.is_active
|
||||
shop.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(shop)
|
||||
|
||||
status = "activated" if shop.is_active else "deactivated"
|
||||
logger.info(f"Shop {shop.shop_code} has been {status}")
|
||||
return shop, f"Shop {shop.shop_code} has been {status}"
|
||||
status_action = "activated" if shop.is_active else "deactivated"
|
||||
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 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(
|
||||
self,
|
||||
db: Session,
|
||||
marketplace: Optional[str] = None,
|
||||
shop_name: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
self,
|
||||
db: Session,
|
||||
marketplace: Optional[str] = None,
|
||||
shop_name: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[MarketplaceImportJobResponse]:
|
||||
"""
|
||||
Get filtered and paginated marketplace import jobs.
|
||||
@@ -161,59 +233,129 @@ class AdminService:
|
||||
Returns:
|
||||
List of MarketplaceImportJobResponse objects
|
||||
"""
|
||||
query = db.query(MarketplaceImportJob)
|
||||
try:
|
||||
query = db.query(MarketplaceImportJob)
|
||||
|
||||
# Apply filters
|
||||
if marketplace:
|
||||
query = query.filter(
|
||||
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
|
||||
# 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}%"))
|
||||
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
|
||||
jobs = (
|
||||
query.order_by(MarketplaceImportJob.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
return [self._convert_job_to_response(job) for job in jobs]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve marketplace import jobs: {str(e)}")
|
||||
raise AdminOperationException(
|
||||
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 [
|
||||
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
|
||||
]
|
||||
|
||||
# Legacy methods for backward compatibility (mark as deprecated)
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
# app/services/auth_service.py
|
||||
"""Summary description ....
|
||||
"""
|
||||
Authentication service for user registration and login.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- ....
|
||||
- ....
|
||||
- ....
|
||||
- User registration with validation
|
||||
- User authentication and JWT token generation
|
||||
- Password management and security
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
UserAlreadyExistsException,
|
||||
InvalidCredentialsException,
|
||||
UserNotActiveException,
|
||||
ValidationException,
|
||||
)
|
||||
from middleware.auth import AuthManager
|
||||
from models.schemas.auth import UserLogin, UserRegister
|
||||
from models.database.user import User
|
||||
@@ -39,36 +45,41 @@ class AuthService:
|
||||
Created user object
|
||||
|
||||
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
|
||||
existing_email = db.query(User).filter(User.email == user_data.email).first()
|
||||
if existing_email:
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
try:
|
||||
# Check if email already exists
|
||||
if self._email_exists(db, user_data.email):
|
||||
raise UserAlreadyExistsException("Email already registered", field="email")
|
||||
|
||||
# Check if username already exists
|
||||
existing_username = (
|
||||
db.query(User).filter(User.username == user_data.username).first()
|
||||
)
|
||||
if existing_username:
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
# Check if username already exists
|
||||
if self._username_exists(db, user_data.username):
|
||||
raise UserAlreadyExistsException("Username already taken", field="username")
|
||||
|
||||
# Hash password and create user
|
||||
hashed_password = self.auth_manager.hash_password(user_data.password)
|
||||
new_user = User(
|
||||
email=user_data.email,
|
||||
username=user_data.username,
|
||||
hashed_password=hashed_password,
|
||||
role="user",
|
||||
is_active=True,
|
||||
)
|
||||
# Hash password and create user
|
||||
hashed_password = self.auth_manager.hash_password(user_data.password)
|
||||
new_user = User(
|
||||
email=user_data.email,
|
||||
username=user_data.username,
|
||||
hashed_password=hashed_password,
|
||||
role="user",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
logger.info(f"New user registered: {new_user.username}")
|
||||
return new_user
|
||||
logger.info(f"New user registered: {new_user.username}")
|
||||
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]:
|
||||
"""
|
||||
@@ -82,53 +93,94 @@ class AuthService:
|
||||
Dictionary containing access token data and user object
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication fails
|
||||
InvalidCredentialsException: If authentication fails
|
||||
UserNotActiveException: If user account is not active
|
||||
"""
|
||||
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"
|
||||
try:
|
||||
user = self.auth_manager.authenticate_user(
|
||||
db, user_credentials.username, user_credentials.password
|
||||
)
|
||||
if not user:
|
||||
raise InvalidCredentialsException("Incorrect username or password")
|
||||
|
||||
# Create access token
|
||||
token_data = self.auth_manager.create_access_token(user)
|
||||
# Check if user is active
|
||||
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]:
|
||||
"""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]:
|
||||
"""Get user by username."""
|
||||
return db.query(User).filter(User.username == username).first()
|
||||
|
||||
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
|
||||
try:
|
||||
return db.query(User).filter(User.username == username).first()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user by username: {str(e)}")
|
||||
return None
|
||||
|
||||
def authenticate_user(
|
||||
self, db: Session, username: str, password: str
|
||||
) -> Optional[User]:
|
||||
"""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]:
|
||||
"""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:
|
||||
"""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()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# app/services/marketplace_service.py
|
||||
"""Summary description ....
|
||||
"""
|
||||
Marketplace service for managing import jobs and marketplace integrations.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- ....
|
||||
- ....
|
||||
- ....
|
||||
- Import job creation and management
|
||||
- Shop access validation
|
||||
- Import job status tracking and updates
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -14,6 +15,15 @@ from typing import List, Optional
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
ShopNotFoundException,
|
||||
UnauthorizedShopAccessException,
|
||||
ImportJobNotFoundException,
|
||||
ImportJobNotOwnedException,
|
||||
ImportJobCannotBeCancelledException,
|
||||
ImportJobCannotBeDeletedException,
|
||||
ValidationException,
|
||||
)
|
||||
from models.schemas.marketplace import (MarketplaceImportJobResponse,
|
||||
MarketplaceImportRequest)
|
||||
from models.database.marketplace import MarketplaceImportJob
|
||||
@@ -26,171 +36,283 @@ logger = logging.getLogger(__name__)
|
||||
class MarketplaceService:
|
||||
"""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:
|
||||
"""Validate that the shop exists and user has access to it."""
|
||||
# Explicit type hint to help type checker shop: Optional[Shop]
|
||||
# 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")
|
||||
"""
|
||||
Validate that the shop exists and user has access to it.
|
||||
|
||||
# Check permissions: admin can import for any shop, others only for their own
|
||||
if user.role != "admin" and shop.owner_id != user.id:
|
||||
raise PermissionError("Access denied to this shop")
|
||||
Args:
|
||||
db: Database session
|
||||
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(
|
||||
self, db: Session, request: MarketplaceImportRequest, user: User
|
||||
self, db: Session, request: MarketplaceImportRequest, user: User
|
||||
) -> MarketplaceImportJob:
|
||||
"""Create a new marketplace import job."""
|
||||
# Validate shop access first
|
||||
shop = self.validate_shop_access(db, request.shop_code, user)
|
||||
"""
|
||||
Create a new marketplace 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(),
|
||||
)
|
||||
Args:
|
||||
db: Database session
|
||||
request: Import request data
|
||||
user: User creating the job
|
||||
|
||||
db.add(import_job)
|
||||
db.commit()
|
||||
db.refresh(import_job)
|
||||
Returns:
|
||||
Created MarketplaceImportJob object
|
||||
|
||||
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}"
|
||||
)
|
||||
Raises:
|
||||
ShopNotFoundException: If shop doesn't exist
|
||||
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(
|
||||
self, db: Session, job_id: int, user: User
|
||||
self, db: Session, job_id: int, user: User
|
||||
) -> MarketplaceImportJob:
|
||||
"""Get a marketplace import job by ID with access control."""
|
||||
job = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.filter(MarketplaceImportJob.id == job_id)
|
||||
.first()
|
||||
)
|
||||
if not job:
|
||||
raise ValueError("Marketplace import job not found")
|
||||
"""
|
||||
Get a marketplace import job by ID with access control.
|
||||
|
||||
# Users can only see their own jobs, admins can see all
|
||||
if user.role != "admin" and job.user_id != user.id:
|
||||
raise PermissionError("Access denied to this import job")
|
||||
Args:
|
||||
db: Database session
|
||||
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(
|
||||
self,
|
||||
db: Session,
|
||||
user: User,
|
||||
marketplace: Optional[str] = None,
|
||||
shop_name: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
self,
|
||||
db: Session,
|
||||
user: User,
|
||||
marketplace: Optional[str] = None,
|
||||
shop_name: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> 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
|
||||
if user.role != "admin":
|
||||
query = query.filter(MarketplaceImportJob.user_id == user.id)
|
||||
Args:
|
||||
db: Database session
|
||||
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
|
||||
if marketplace:
|
||||
query = query.filter(
|
||||
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
|
||||
Returns:
|
||||
List of MarketplaceImportJob objects
|
||||
"""
|
||||
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
|
||||
jobs = (
|
||||
query.order_by(MarketplaceImportJob.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return jobs
|
||||
|
||||
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(
|
||||
self, db: Session, job_id: int, status: str, **kwargs
|
||||
self, db: Session, job_id: int, status: str, **kwargs
|
||||
) -> MarketplaceImportJob:
|
||||
"""Update marketplace import job status and other fields."""
|
||||
job = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.filter(MarketplaceImportJob.id == job_id)
|
||||
.first()
|
||||
)
|
||||
if not job:
|
||||
raise ValueError("Marketplace import job not found")
|
||||
"""
|
||||
Update marketplace import job status and other fields.
|
||||
|
||||
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
|
||||
if "imported_count" in kwargs:
|
||||
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"]
|
||||
Returns:
|
||||
Updated MarketplaceImportJob object
|
||||
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
Raises:
|
||||
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}")
|
||||
return job
|
||||
if not 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:
|
||||
"""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
|
||||
if user.role != "admin":
|
||||
query = query.filter(MarketplaceImportJob.user_id == user.id)
|
||||
Args:
|
||||
db: Database session
|
||||
user: User to get stats for
|
||||
|
||||
total_jobs = query.count()
|
||||
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()
|
||||
Returns:
|
||||
Dictionary containing job statistics
|
||||
"""
|
||||
try:
|
||||
query = db.query(MarketplaceImportJob)
|
||||
|
||||
return {
|
||||
"total_jobs": total_jobs,
|
||||
"pending_jobs": pending_jobs,
|
||||
"running_jobs": running_jobs,
|
||||
"completed_jobs": completed_jobs,
|
||||
"failed_jobs": failed_jobs,
|
||||
}
|
||||
# Users can only see their own jobs, admins can see all
|
||||
if user.role != "admin":
|
||||
query = query.filter(MarketplaceImportJob.user_id == user.id)
|
||||
|
||||
total_jobs = query.count()
|
||||
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(
|
||||
self, job: MarketplaceImportJob
|
||||
self, job: MarketplaceImportJob
|
||||
) -> MarketplaceImportJobResponse:
|
||||
"""Convert database model to API response model."""
|
||||
return MarketplaceImportJobResponse(
|
||||
@@ -213,38 +335,82 @@ class MarketplaceService:
|
||||
)
|
||||
|
||||
def cancel_import_job(
|
||||
self, db: Session, job_id: int, user: User
|
||||
self, db: Session, job_id: int, user: User
|
||||
) -> 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"]:
|
||||
raise ValueError(f"Cannot cancel job with status: {job.status}")
|
||||
Args:
|
||||
db: Database session
|
||||
job_id: Import job ID
|
||||
user: User requesting cancellation
|
||||
|
||||
job.status = "cancelled"
|
||||
job.completed_at = datetime.utcnow()
|
||||
Returns:
|
||||
Updated MarketplaceImportJob object
|
||||
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
Raises:
|
||||
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}")
|
||||
return job
|
||||
if job.status not in ["pending", "running"]:
|
||||
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:
|
||||
"""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
|
||||
if job.status in ["pending", "running"]:
|
||||
raise ValueError(
|
||||
f"Cannot delete job with status: {job.status}. Cancel it first."
|
||||
)
|
||||
Args:
|
||||
db: Database session
|
||||
job_id: Import job ID
|
||||
user: User requesting deletion
|
||||
|
||||
db.delete(job)
|
||||
db.commit()
|
||||
Returns:
|
||||
True if deletion successful
|
||||
|
||||
logger.info(f"Deleted marketplace import job {job_id}")
|
||||
return True
|
||||
Raises:
|
||||
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
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
# app/services/product_service.py
|
||||
"""Summary description ....
|
||||
"""
|
||||
Product service for managing product operations and data processing.
|
||||
|
||||
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
|
||||
from datetime import datetime
|
||||
from typing import Generator, List, Optional
|
||||
from typing import Generator, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
ProductNotFoundException,
|
||||
ProductAlreadyExistsException,
|
||||
InvalidProductDataException,
|
||||
ProductValidationException,
|
||||
ValidationException,
|
||||
)
|
||||
from models.schemas.product import ProductCreate, ProductUpdate
|
||||
from models.schemas.stock import StockLocationResponse, StockSummaryResponse
|
||||
from models.database.product import Product
|
||||
@@ -32,28 +41,52 @@ class ProductService:
|
||||
self.price_processor = PriceProcessor()
|
||||
|
||||
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:
|
||||
# Process and validate GTIN if provided
|
||||
if product_data.gtin:
|
||||
normalized_gtin = self.gtin_processor.normalize(product_data.gtin)
|
||||
if not normalized_gtin:
|
||||
raise ValueError("Invalid GTIN format")
|
||||
raise InvalidProductDataException("Invalid GTIN format", field="gtin")
|
||||
product_data.gtin = normalized_gtin
|
||||
|
||||
# Process price if provided
|
||||
if product_data.price:
|
||||
parsed_price, currency = self.price_processor.parse_price_currency(
|
||||
product_data.price
|
||||
)
|
||||
if parsed_price:
|
||||
product_data.price = parsed_price
|
||||
product_data.currency = currency
|
||||
try:
|
||||
parsed_price, currency = self.price_processor.parse_price_currency(
|
||||
product_data.price
|
||||
)
|
||||
if parsed_price:
|
||||
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
|
||||
if not product_data.marketplace:
|
||||
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.add(db_product)
|
||||
db.commit()
|
||||
@@ -62,176 +95,327 @@ class ProductService:
|
||||
logger.info(f"Created product {db_product.product_id}")
|
||||
return db_product
|
||||
|
||||
except (InvalidProductDataException, ProductValidationException):
|
||||
db.rollback()
|
||||
raise # Re-raise custom exceptions
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
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:
|
||||
db.rollback()
|
||||
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]:
|
||||
"""Get a product by its ID."""
|
||||
return db.query(Product).filter(Product.product_id == product_id).first()
|
||||
|
||||
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."""
|
||||
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:
|
||||
try:
|
||||
return db.query(Product).filter(Product.product_id == product_id).first()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting product {product_id}: {str(e)}")
|
||||
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
|
||||
]
|
||||
def get_product_by_id_or_raise(self, db: Session, product_id: str) -> Product:
|
||||
"""
|
||||
Get a product by its ID or raise exception.
|
||||
|
||||
return StockSummaryResponse(
|
||||
gtin=gtin, total_quantity=total_quantity, locations=locations
|
||||
)
|
||||
Args:
|
||||
db: Database session
|
||||
product_id: Product ID to find
|
||||
|
||||
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."""
|
||||
# CSV header
|
||||
yield (
|
||||
"product_id,title,description,link,image_link,availability,price,currency,brand,"
|
||||
"gtin,marketplace,shop_name\n"
|
||||
)
|
||||
Returns:
|
||||
Product object
|
||||
|
||||
batch_size = 1000
|
||||
offset = 0
|
||||
Raises:
|
||||
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)
|
||||
|
||||
# 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:
|
||||
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'
|
||||
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))
|
||||
)
|
||||
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:
|
||||
"""Check if product exists by ID."""
|
||||
return (
|
||||
db.query(Product).filter(Product.product_id == product_id).first()
|
||||
is not None
|
||||
)
|
||||
try:
|
||||
return (
|
||||
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
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
# app/services/shop_service.py
|
||||
"""Summary description ....
|
||||
"""
|
||||
Shop service for managing shop operations and product catalog.
|
||||
|
||||
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
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func
|
||||
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.database.product import Product
|
||||
from models.database.shop import Shop, ShopProduct
|
||||
@@ -26,7 +37,7 @@ class ShopService:
|
||||
"""Service class for shop operations following the application's service pattern."""
|
||||
|
||||
def create_shop(
|
||||
self, db: Session, shop_data: ShopCreate, current_user: User
|
||||
self, db: Session, shop_data: ShopCreate, current_user: User
|
||||
) -> Shop:
|
||||
"""
|
||||
Create a new shop.
|
||||
@@ -40,49 +51,60 @@ class ShopService:
|
||||
Created shop object
|
||||
|
||||
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
|
||||
normalized_shop_code = shop_data.shop_code.upper()
|
||||
try:
|
||||
# Validate shop data
|
||||
self._validate_shop_data(shop_data)
|
||||
|
||||
# Check if shop code already exists (case-insensitive check against existing data)
|
||||
existing_shop = (
|
||||
db.query(Shop)
|
||||
.filter(func.upper(Shop.shop_code) == normalized_shop_code)
|
||||
.first()
|
||||
)
|
||||
# Check user's shop limit (if applicable)
|
||||
self._check_shop_limit(db, current_user)
|
||||
|
||||
if existing_shop:
|
||||
raise HTTPException(status_code=400, detail="Shop code already exists")
|
||||
# Normalize shop code to uppercase
|
||||
normalized_shop_code = shop_data.shop_code.upper()
|
||||
|
||||
# Create shop with uppercase code
|
||||
shop_dict = shop_data.model_dump() # Fixed deprecated .dict() method
|
||||
shop_dict["shop_code"] = normalized_shop_code # Store as uppercase
|
||||
# Check if shop code already exists (case-insensitive check)
|
||||
if self._shop_code_exists(db, normalized_shop_code):
|
||||
raise ShopAlreadyExistsException(normalized_shop_code)
|
||||
|
||||
new_shop = Shop(
|
||||
**shop_dict,
|
||||
owner_id=current_user.id,
|
||||
is_active=True,
|
||||
is_verified=(current_user.role == "admin"),
|
||||
)
|
||||
# Create shop with uppercase code
|
||||
shop_dict = shop_data.model_dump()
|
||||
shop_dict["shop_code"] = normalized_shop_code # Store as uppercase
|
||||
|
||||
db.add(new_shop)
|
||||
db.commit()
|
||||
db.refresh(new_shop)
|
||||
new_shop = Shop(
|
||||
**shop_dict,
|
||||
owner_id=current_user.id,
|
||||
is_active=True,
|
||||
is_verified=(current_user.role == "admin"),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"New shop created: {new_shop.shop_code} by {current_user.username}"
|
||||
)
|
||||
return new_shop
|
||||
db.add(new_shop)
|
||||
db.commit()
|
||||
db.refresh(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(
|
||||
self,
|
||||
db: Session,
|
||||
current_user: User,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
active_only: bool = True,
|
||||
verified_only: bool = False,
|
||||
self,
|
||||
db: Session,
|
||||
current_user: User,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
active_only: bool = True,
|
||||
verified_only: bool = False,
|
||||
) -> Tuple[List[Shop], int]:
|
||||
"""
|
||||
Get shops with filtering.
|
||||
@@ -98,25 +120,30 @@ class ShopService:
|
||||
Returns:
|
||||
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
|
||||
if current_user.role != "admin":
|
||||
query = query.filter(
|
||||
(Shop.is_active == True)
|
||||
& ((Shop.is_verified == True) | (Shop.owner_id == current_user.id))
|
||||
)
|
||||
else:
|
||||
# Admin can apply filters
|
||||
if active_only:
|
||||
query = query.filter(Shop.is_active == True)
|
||||
if verified_only:
|
||||
query = query.filter(Shop.is_verified == True)
|
||||
# Non-admin users can only see active and verified shops, plus their own
|
||||
if current_user.role != "admin":
|
||||
query = query.filter(
|
||||
(Shop.is_active == True)
|
||||
& ((Shop.is_verified == True) | (Shop.owner_id == current_user.id))
|
||||
)
|
||||
else:
|
||||
# Admin can apply filters
|
||||
if active_only:
|
||||
query = query.filter(Shop.is_active == True)
|
||||
if verified_only:
|
||||
query = query.filter(Shop.is_verified == True)
|
||||
|
||||
total = query.count()
|
||||
shops = query.offset(skip).limit(limit).all()
|
||||
total = query.count()
|
||||
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:
|
||||
"""
|
||||
@@ -131,28 +158,33 @@ class ShopService:
|
||||
Shop object
|
||||
|
||||
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]
|
||||
shop: Optional[Shop] = (
|
||||
db.query(Shop)
|
||||
.filter(func.upper(Shop.shop_code) == shop_code.upper())
|
||||
.first()
|
||||
)
|
||||
if not shop:
|
||||
raise HTTPException(status_code=404, detail="Shop not found")
|
||||
try:
|
||||
shop = (
|
||||
db.query(Shop)
|
||||
.filter(func.upper(Shop.shop_code) == shop_code.upper())
|
||||
.first()
|
||||
)
|
||||
|
||||
# Non-admin users can only see active verified shops or their own shops
|
||||
if current_user.role != "admin":
|
||||
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")
|
||||
if not shop:
|
||||
raise ShopNotFoundException(shop_code)
|
||||
|
||||
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(
|
||||
self, db: Session, shop: Shop, shop_product: ShopProductCreate
|
||||
self, db: Session, shop: Shop, shop_product: ShopProductCreate
|
||||
) -> ShopProduct:
|
||||
"""
|
||||
Add existing product to shop catalog with shop-specific settings.
|
||||
@@ -166,59 +198,51 @@ class ShopService:
|
||||
Created ShopProduct object
|
||||
|
||||
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
|
||||
product = (
|
||||
db.query(Product)
|
||||
.filter(Product.product_id == shop_product.product_id)
|
||||
.first()
|
||||
)
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Product not found in marketplace catalog"
|
||||
try:
|
||||
# Check if product exists
|
||||
product = self._get_product_by_id_or_raise(db, shop_product.product_id)
|
||||
|
||||
# Check if product already in shop
|
||||
if self._product_in_shop(db, shop.id, product.id):
|
||||
raise ShopProductAlreadyExistsException(shop.shop_code, shop_product.product_id)
|
||||
|
||||
# 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
|
||||
existing_shop_product = (
|
||||
db.query(ShopProduct)
|
||||
.filter(
|
||||
ShopProduct.shop_id == shop.id, ShopProduct.product_id == product.id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
db.add(new_shop_product)
|
||||
db.commit()
|
||||
db.refresh(new_shop_product)
|
||||
|
||||
if existing_shop_product:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Product already in shop catalog"
|
||||
)
|
||||
# Load the product relationship
|
||||
db.refresh(new_shop_product)
|
||||
|
||||
# Create shop-product association
|
||||
new_shop_product = ShopProduct(
|
||||
shop_id=shop.id,
|
||||
product_id=product.id,
|
||||
**shop_product.model_dump(exclude={"product_id"}),
|
||||
)
|
||||
logger.info(f"Product {shop_product.product_id} added to shop {shop.shop_code}")
|
||||
return new_shop_product
|
||||
|
||||
db.add(new_shop_product)
|
||||
db.commit()
|
||||
db.refresh(new_shop_product)
|
||||
|
||||
# Load the product relationship
|
||||
db.refresh(new_shop_product)
|
||||
|
||||
logger.info(f"Product {shop_product.product_id} added to shop {shop.shop_code}")
|
||||
return new_shop_product
|
||||
except (ProductNotFoundException, ShopProductAlreadyExistsException):
|
||||
db.rollback()
|
||||
raise # Re-raise custom exceptions
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error adding product to shop: {str(e)}")
|
||||
raise ValidationException("Failed to add product to shop")
|
||||
|
||||
def get_shop_products(
|
||||
self,
|
||||
db: Session,
|
||||
shop: Shop,
|
||||
current_user: User,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
active_only: bool = True,
|
||||
featured_only: bool = False,
|
||||
self,
|
||||
db: Session,
|
||||
shop: Shop,
|
||||
current_user: User,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
active_only: bool = True,
|
||||
featured_only: bool = False,
|
||||
) -> Tuple[List[ShopProduct], int]:
|
||||
"""
|
||||
Get products in shop catalog with filtering.
|
||||
@@ -236,58 +260,137 @@ class ShopService:
|
||||
Tuple of (shop_products_list, total_count)
|
||||
|
||||
Raises:
|
||||
HTTPException: If shop access denied
|
||||
UnauthorizedShopAccessException: If shop access denied
|
||||
"""
|
||||
# Non-owners can only see active verified shops
|
||||
if current_user.role != "admin" and shop.owner_id != current_user.id:
|
||||
if not shop.is_active or not shop.is_verified:
|
||||
raise HTTPException(status_code=404, detail="Shop not found")
|
||||
try:
|
||||
# Check access permissions
|
||||
if not self._can_access_shop(shop, current_user):
|
||||
raise UnauthorizedShopAccessException(shop.shop_code, current_user.id)
|
||||
|
||||
# Query shop products
|
||||
query = db.query(ShopProduct).filter(ShopProduct.shop_id == shop.id)
|
||||
# Query shop products
|
||||
query = db.query(ShopProduct).filter(ShopProduct.shop_id == shop.id)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(ShopProduct.is_active == True)
|
||||
if featured_only:
|
||||
query = query.filter(ShopProduct.is_featured == True)
|
||||
if active_only:
|
||||
query = query.filter(ShopProduct.is_active == True)
|
||||
if featured_only:
|
||||
query = query.filter(ShopProduct.is_featured == True)
|
||||
|
||||
total = query.count()
|
||||
shop_products = query.offset(skip).limit(limit).all()
|
||||
total = query.count()
|
||||
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]:
|
||||
"""Get shop by ID."""
|
||||
return db.query(Shop).filter(Shop.id == shop_id).first()
|
||||
except UnauthorizedShopAccessException:
|
||||
raise # Re-raise custom exceptions
|
||||
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:
|
||||
"""Check if shop code already exists."""
|
||||
return db.query(Shop).filter(Shop.shop_code == shop_code).first() is not None
|
||||
# Private helper methods
|
||||
def _validate_shop_data(self, shop_data: ShopCreate) -> 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]:
|
||||
"""Get product by product_id."""
|
||||
return db.query(Product).filter(Product.product_id == product_id).first()
|
||||
if not shop_data.shop_name or not shop_data.shop_name.strip():
|
||||
raise InvalidShopDataException("Shop name is required", field="shop_name")
|
||||
|
||||
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
|
||||
# Validate shop code format (alphanumeric, underscores, hyphens)
|
||||
import re
|
||||
if not re.match(r'^[A-Za-z0-9_-]+$', shop_data.shop_code):
|
||||
raise InvalidShopDataException(
|
||||
"Shop code can only contain letters, numbers, underscores, and hyphens",
|
||||
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."""
|
||||
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:
|
||||
"""Check if user can view shop."""
|
||||
if user.role == "admin" or self.is_shop_owner(shop, user):
|
||||
return True
|
||||
return shop.is_active and shop.is_verified
|
||||
"""Check if user can view shop. DEPRECATED: Use _can_access_shop."""
|
||||
logger.warning("can_view_shop is deprecated, use _can_access_shop")
|
||||
return self._can_access_shop(shop, user)
|
||||
|
||||
|
||||
# Create service instance following the same pattern as other services
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# app/services/stats_service.py
|
||||
"""Summary description ....
|
||||
"""
|
||||
Statistics service for generating system analytics and metrics.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- ....
|
||||
- ....
|
||||
- ....
|
||||
- Comprehensive system statistics
|
||||
- Marketplace-specific analytics
|
||||
- Performance metrics and data insights
|
||||
- Cached statistics for performance
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -13,6 +15,7 @@ from typing import Any, Dict, List
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import ValidationException
|
||||
from models.database.product import Product
|
||||
from models.database.stock import Stock
|
||||
|
||||
@@ -31,60 +34,39 @@ class StatsService:
|
||||
|
||||
Returns:
|
||||
Dictionary containing all statistics data
|
||||
|
||||
Raises:
|
||||
ValidationException: If statistics generation fails
|
||||
"""
|
||||
# Use more efficient queries with proper indexes
|
||||
total_products = db.query(Product).count()
|
||||
try:
|
||||
# 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 = (
|
||||
db.query(Product.brand)
|
||||
.filter(Product.brand.isnot(None), Product.brand != "")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
# Stock statistics
|
||||
stock_stats = self._get_stock_statistics(db)
|
||||
|
||||
unique_categories = (
|
||||
db.query(Product.google_product_category)
|
||||
.filter(
|
||||
Product.google_product_category.isnot(None),
|
||||
Product.google_product_category != "",
|
||||
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": 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()
|
||||
.count()
|
||||
)
|
||||
return stats_data
|
||||
|
||||
# New marketplace statistics
|
||||
unique_marketplaces = (
|
||||
db.query(Product.marketplace)
|
||||
.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
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting comprehensive stats: {str(e)}")
|
||||
raise ValidationException("Failed to retrieve system statistics")
|
||||
|
||||
def get_marketplace_breakdown_stats(self, db: Session) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -95,40 +77,127 @@ class StatsService:
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing marketplace statistics
|
||||
|
||||
Raises:
|
||||
ValidationException: If marketplace statistics generation fails
|
||||
"""
|
||||
# Query to get stats per marketplace
|
||||
marketplace_stats = (
|
||||
db.query(
|
||||
Product.marketplace,
|
||||
func.count(Product.id).label("total_products"),
|
||||
func.count(func.distinct(Product.shop_name)).label("unique_shops"),
|
||||
func.count(func.distinct(Product.brand)).label("unique_brands"),
|
||||
try:
|
||||
# Query to get stats per marketplace
|
||||
marketplace_stats = (
|
||||
db.query(
|
||||
Product.marketplace,
|
||||
func.count(Product.id).label("total_products"),
|
||||
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 = [
|
||||
{
|
||||
"marketplace": stat.marketplace,
|
||||
"total_products": stat.total_products,
|
||||
"unique_shops": stat.unique_shops,
|
||||
"unique_brands": stat.unique_brands,
|
||||
stats_list = [
|
||||
{
|
||||
"marketplace": stat.marketplace,
|
||||
"total_products": stat.total_products,
|
||||
"unique_shops": stat.unique_shops,
|
||||
"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(
|
||||
f"Generated marketplace breakdown stats for {len(stats_list)} marketplaces"
|
||||
)
|
||||
return stats_list
|
||||
return stats
|
||||
|
||||
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."""
|
||||
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."""
|
||||
return (
|
||||
db.query(Product.brand)
|
||||
@@ -137,7 +206,7 @@ class StatsService:
|
||||
.count()
|
||||
)
|
||||
|
||||
def get_unique_categories_count(self, db: Session) -> int:
|
||||
def _get_unique_categories_count(self, db: Session) -> int:
|
||||
"""Get count of unique categories."""
|
||||
return (
|
||||
db.query(Product.google_product_category)
|
||||
@@ -149,7 +218,7 @@ class StatsService:
|
||||
.count()
|
||||
)
|
||||
|
||||
def get_unique_marketplaces_count(self, db: Session) -> int:
|
||||
def _get_unique_marketplaces_count(self, db: Session) -> int:
|
||||
"""Get count of unique marketplaces."""
|
||||
return (
|
||||
db.query(Product.marketplace)
|
||||
@@ -158,7 +227,7 @@ class StatsService:
|
||||
.count()
|
||||
)
|
||||
|
||||
def get_unique_shops_count(self, db: Session) -> int:
|
||||
def _get_unique_shops_count(self, db: Session) -> int:
|
||||
"""Get count of unique shops."""
|
||||
return (
|
||||
db.query(Product.shop_name)
|
||||
@@ -167,16 +236,24 @@ class StatsService:
|
||||
.count()
|
||||
)
|
||||
|
||||
def get_stock_statistics(self, db: Session) -> Dict[str, int]:
|
||||
"""
|
||||
Get stock-related statistics.
|
||||
def _get_products_with_gtin_count(self, db: Session) -> int:
|
||||
"""Get count of products with GTIN."""
|
||||
return (
|
||||
db.query(Product)
|
||||
.filter(Product.gtin.isnot(None), Product.gtin != "")
|
||||
.count()
|
||||
)
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
def _get_products_with_images_count(self, db: Session) -> int:
|
||||
"""Get count of products with images."""
|
||||
return (
|
||||
db.query(Product)
|
||||
.filter(Product.image_link.isnot(None), Product.image_link != "")
|
||||
.count()
|
||||
)
|
||||
|
||||
Returns:
|
||||
Dictionary containing stock statistics
|
||||
"""
|
||||
def _get_stock_statistics(self, db: Session) -> Dict[str, int]:
|
||||
"""Get stock-related statistics."""
|
||||
total_stock_entries = db.query(Stock).count()
|
||||
total_inventory = db.query(func.sum(Stock.quantity)).scalar() or 0
|
||||
|
||||
@@ -185,7 +262,7 @@ class StatsService:
|
||||
"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."""
|
||||
brands = (
|
||||
db.query(Product.brand)
|
||||
@@ -199,7 +276,7 @@ class StatsService:
|
||||
)
|
||||
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."""
|
||||
shops = (
|
||||
db.query(Product.shop_name)
|
||||
@@ -213,10 +290,51 @@ class StatsService:
|
||||
)
|
||||
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."""
|
||||
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
|
||||
stats_service = StatsService()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# app/services/stock_service.py
|
||||
"""Summary description ....
|
||||
"""
|
||||
Stock service for managing inventory operations.
|
||||
|
||||
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
|
||||
@@ -13,6 +15,15 @@ from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
StockNotFoundException,
|
||||
InsufficientStockException,
|
||||
InvalidStockOperationException,
|
||||
StockValidationException,
|
||||
NegativeStockException,
|
||||
InvalidQuantityException,
|
||||
ValidationException,
|
||||
)
|
||||
from models.schemas.stock import (StockAdd, StockCreate, StockLocationResponse,
|
||||
StockSummaryResponse, StockUpdate)
|
||||
from models.database.product import Product
|
||||
@@ -29,236 +40,544 @@ class StockService:
|
||||
"""Class constructor."""
|
||||
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:
|
||||
"""Set exact stock quantity for a GTIN at a specific location (replaces existing quantity)."""
|
||||
normalized_gtin = self.normalize_gtin(stock_data.gtin)
|
||||
if not normalized_gtin:
|
||||
raise ValueError("Invalid GTIN format")
|
||||
"""
|
||||
Set exact stock quantity for a GTIN at a specific location (replaces existing quantity).
|
||||
|
||||
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
|
||||
existing_stock = (
|
||||
db.query(Stock)
|
||||
.filter(Stock.gtin == normalized_gtin, Stock.location == location)
|
||||
.first()
|
||||
)
|
||||
Returns:
|
||||
Stock object with updated quantity
|
||||
|
||||
if existing_stock:
|
||||
# Update existing stock (SET to exact quantity)
|
||||
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"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
|
||||
Raises:
|
||||
InvalidQuantityException: If quantity is negative
|
||||
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=True)
|
||||
|
||||
# Check if stock entry already exists for this GTIN and location
|
||||
existing_stock = self._get_stock_entry(db, normalized_gtin, location)
|
||||
|
||||
if existing_stock:
|
||||
# Update existing stock (SET to exact quantity)
|
||||
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"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:
|
||||
"""Add quantity to existing stock for a GTIN at a specific location (adds to existing quantity)."""
|
||||
normalized_gtin = self.normalize_gtin(stock_data.gtin)
|
||||
if not normalized_gtin:
|
||||
raise ValueError("Invalid GTIN format")
|
||||
"""
|
||||
Add quantity to existing stock for a GTIN at a specific location (adds to existing quantity).
|
||||
|
||||
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
|
||||
existing_stock = (
|
||||
db.query(Stock)
|
||||
.filter(Stock.gtin == normalized_gtin, Stock.location == location)
|
||||
.first()
|
||||
)
|
||||
Returns:
|
||||
Stock object with updated quantity
|
||||
|
||||
if existing_stock:
|
||||
# Add to existing stock
|
||||
Raises:
|
||||
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
|
||||
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()
|
||||
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}"
|
||||
f"Removed 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
|
||||
|
||||
except (StockNotFoundException, InsufficientStockException, InvalidQuantityException, NegativeStockException):
|
||||
db.rollback()
|
||||
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.refresh(new_stock)
|
||||
db.refresh(stock_entry)
|
||||
|
||||
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:
|
||||
"""Remove quantity from existing stock for a GTIN at a specific location."""
|
||||
normalized_gtin = self.normalize_gtin(stock_data.gtin)
|
||||
except (StockNotFoundException, InvalidQuantityException):
|
||||
db.rollback()
|
||||
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:
|
||||
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
|
||||
existing_stock = (
|
||||
def _validate_and_normalize_location(self, location: str) -> str:
|
||||
"""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)
|
||||
.filter(Stock.gtin == normalized_gtin, Stock.location == location)
|
||||
.filter(Stock.gtin == gtin, Stock.location == location)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not existing_stock:
|
||||
raise ValueError(
|
||||
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."""
|
||||
def _get_stock_by_id_or_raise(self, db: Session, stock_id: int) -> Stock:
|
||||
"""Get stock by ID or raise exception."""
|
||||
stock_entry = db.query(Stock).filter(Stock.id == stock_id).first()
|
||||
if not stock_entry:
|
||||
raise ValueError("Stock entry not found")
|
||||
|
||||
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}"
|
||||
)
|
||||
raise StockNotFoundException(str(stock_id))
|
||||
return stock_entry
|
||||
|
||||
def delete_stock(self, db: Session, stock_id: int) -> bool:
|
||||
"""Delete a stock entry."""
|
||||
stock_entry = db.query(Stock).filter(Stock.id == stock_id).first()
|
||||
if not stock_entry:
|
||||
raise ValueError("Stock entry not found")
|
||||
|
||||
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
|
||||
# Legacy methods for backward compatibility (deprecated)
|
||||
def normalize_gtin(self, gtin_value) -> Optional[str]:
|
||||
"""Normalize GTIN format. DEPRECATED: Use _normalize_gtin."""
|
||||
logger.warning("normalize_gtin is deprecated, use _normalize_gtin")
|
||||
return self._normalize_gtin(gtin_value)
|
||||
|
||||
def get_stock_by_id(self, db: Session, stock_id: int) -> Optional[Stock]:
|
||||
"""Get a stock entry by its ID."""
|
||||
return db.query(Stock).filter(Stock.id == stock_id).first()
|
||||
"""Get stock by ID. DEPRECATED: Use proper exception handling."""
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user