# app/modules/tenancy/services/store_service.py """ Store service for managing store operations. This module provides classes and functions for: - Store creation and management - Store access control and validation - Store filtering and search Note: Product catalog operations have been moved to app.modules.catalog.services. """ import logging from sqlalchemy import func from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from app.modules.tenancy.exceptions import ( InvalidStoreDataException, StoreAlreadyExistsException, StoreNotFoundException, StoreValidationException, UnauthorizedStoreAccessException, ) from app.modules.tenancy.models import Store, User from app.modules.tenancy.schemas.store import StoreCreate logger = logging.getLogger(__name__) class StoreService: """Service class for store operations following the application's service pattern.""" def create_store( self, db: Session, store_data: StoreCreate, current_user: User ) -> Store: """ Create a new store under a merchant. DEPRECATED: This method is for self-service store creation by merchant owners. For admin operations, use admin_service.create_store() instead. The new architecture: - Merchants are the business entities with owners and contact info - Stores are storefronts/brands under merchants - The merchant_id is required in store_data Args: db: Database session store_data: Store creation data (must include merchant_id) current_user: User creating the store (must be merchant owner or admin) Returns: Created store object Raises: StoreAlreadyExistsException: If store code already exists UnauthorizedStoreAccessException: If user is not merchant owner InvalidStoreDataException: If store data is invalid """ from app.modules.tenancy.models import Merchant try: # Validate merchant_id is provided if not hasattr(store_data, "merchant_id") or not store_data.merchant_id: raise InvalidStoreDataException( "merchant_id is required to create a store", field="merchant_id" ) # Get merchant and verify ownership merchant = ( db.query(Merchant).filter(Merchant.id == store_data.merchant_id).first() ) if not merchant: raise InvalidStoreDataException( f"Merchant with ID {store_data.merchant_id} not found", field="merchant_id", ) # Check if user is merchant owner or admin if ( not current_user.is_admin and merchant.owner_user_id != current_user.id ): raise UnauthorizedStoreAccessException( f"merchant-{store_data.merchant_id}", current_user.id ) # Normalize store code to uppercase normalized_store_code = store_data.store_code.upper() # Check if store code already exists (case-insensitive check) if self._store_code_exists(db, normalized_store_code): raise StoreAlreadyExistsException(normalized_store_code) # Create store linked to merchant new_store = Store( merchant_id=merchant.id, store_code=normalized_store_code, subdomain=store_data.subdomain.lower(), name=store_data.name, description=store_data.description, letzshop_csv_url_fr=store_data.letzshop_csv_url_fr, letzshop_csv_url_en=store_data.letzshop_csv_url_en, letzshop_csv_url_de=store_data.letzshop_csv_url_de, is_active=True, is_verified=current_user.is_admin, ) db.add(new_store) db.flush() # Get ID without committing - endpoint handles commit logger.info( f"New store created: {new_store.store_code} under merchant {merchant.name} by {current_user.username}" ) return new_store except ( StoreAlreadyExistsException, UnauthorizedStoreAccessException, InvalidStoreDataException, ): raise # Re-raise custom exceptions - endpoint handles rollback except SQLAlchemyError as e: logger.error(f"Error creating store: {str(e)}") raise StoreValidationException("Failed to create store") def get_stores( self, db: Session, current_user: User, skip: int = 0, limit: int = 100, active_only: bool = True, verified_only: bool = False, ) -> tuple[list[Store], int]: """ Get stores with filtering. Args: db: Database session current_user: Current user requesting stores skip: Number of records to skip limit: Maximum number of records to return active_only: Filter for active stores only verified_only: Filter for verified stores only Returns: Tuple of (stores_list, total_count) """ try: query = db.query(Store) # Non-admin users can only see active and verified stores, plus their own if not current_user.is_admin: # Get store IDs the user owns through merchants from app.modules.tenancy.models import Merchant owned_store_ids = ( db.query(Store.id) .join(Merchant) .filter(Merchant.owner_user_id == current_user.id) .subquery() ) query = query.filter( (Store.is_active == True) & ((Store.is_verified == True) | (Store.id.in_(owned_store_ids))) ) else: # Admin can apply filters if active_only: query = query.filter(Store.is_active == True) if verified_only: query = query.filter(Store.is_verified == True) total = query.count() stores = query.offset(skip).limit(limit).all() return stores, total except SQLAlchemyError as e: logger.error(f"Error getting stores: {str(e)}") raise StoreValidationException("Failed to retrieve stores") def get_store_by_code( self, db: Session, store_code: str, current_user: User ) -> Store: """ Get store by store code with access control. Args: db: Database session store_code: Store code to find current_user: Current user requesting the store Returns: Store object Raises: StoreNotFoundException: If store not found UnauthorizedStoreAccessException: If access denied """ try: store = ( db.query(Store) .filter(func.upper(Store.store_code) == store_code.upper()) .first() ) if not store: raise StoreNotFoundException(store_code) # Check access permissions if not self._can_access_store(store, current_user): raise UnauthorizedStoreAccessException(store_code, current_user.id) return store except (StoreNotFoundException, UnauthorizedStoreAccessException): raise # Re-raise custom exceptions except SQLAlchemyError as e: logger.error(f"Error getting store {store_code}: {str(e)}") raise StoreValidationException("Failed to retrieve store") def get_store_by_id(self, db: Session, store_id: int) -> Store: """ Get store by ID (admin use - no access control). Args: db: Database session store_id: Store ID to find Returns: Store object with merchant and owner loaded Raises: StoreNotFoundException: If store not found """ from sqlalchemy.orm import joinedload from app.modules.tenancy.models import Merchant store = ( db.query(Store) .options(joinedload(Store.merchant).joinedload(Merchant.owner)) .filter(Store.id == store_id) .first() ) if not store: raise StoreNotFoundException(str(store_id), identifier_type="id") return store def get_store_by_id_optional(self, db: Session, store_id: int) -> Store | None: """ Get store by ID, returns None if not found. Args: db: Database session store_id: Store ID to find Returns: Store object or None if not found """ return db.query(Store).filter(Store.id == store_id).first() def get_active_store_by_code(self, db: Session, store_code: str) -> Store: """ Get active store by store_code for public access (no auth required). This method is specifically designed for public endpoints where: - No authentication is required - Only active stores should be returned - Inactive/disabled stores are hidden Args: db: Database session store_code: Store code (case-insensitive) Returns: Store object with merchant and owner loaded Raises: StoreNotFoundException: If store not found or inactive """ from sqlalchemy.orm import joinedload from app.modules.tenancy.models import Merchant store = ( db.query(Store) .options(joinedload(Store.merchant).joinedload(Merchant.owner)) .filter( func.upper(Store.store_code) == store_code.upper(), Store.is_active == True, ) .first() ) if not store: logger.warning(f"Store not found or inactive: {store_code}") raise StoreNotFoundException(store_code, identifier_type="code") return store def get_store_by_identifier(self, db: Session, identifier: str) -> Store: """ Get store by ID or store_code (admin use - no access control). Args: db: Database session identifier: Either store ID (int as string) or store_code (string) Returns: Store object with merchant and owner loaded Raises: StoreNotFoundException: If store not found """ from sqlalchemy.orm import joinedload from app.modules.tenancy.models import Merchant # Try as integer ID first try: store_id = int(identifier) return self.get_store_by_id(db, store_id) except (ValueError, TypeError): pass # Not an integer, treat as store_code except StoreNotFoundException: pass # ID not found, try as store_code # Try as store_code (case-insensitive) store = ( db.query(Store) .options(joinedload(Store.merchant).joinedload(Merchant.owner)) .filter(func.upper(Store.store_code) == identifier.upper()) .first() ) if not store: raise StoreNotFoundException(identifier, identifier_type="code") return store def toggle_verification(self, db: Session, store_id: int) -> tuple[Store, str]: """ Toggle store verification status. Args: db: Database session store_id: Store ID Returns: Tuple of (updated store, status message) Raises: StoreNotFoundException: If store not found """ store = self.get_store_by_id(db, store_id) store.is_verified = not store.is_verified # No commit here - endpoint handles transaction status = "verified" if store.is_verified else "unverified" logger.info(f"Store {store.store_code} {status}") return store, f"Store {store.store_code} is now {status}" def set_verification( self, db: Session, store_id: int, is_verified: bool ) -> tuple[Store, str]: """ Set store verification status to specific value. Args: db: Database session store_id: Store ID is_verified: Target verification status Returns: Tuple of (updated store, status message) Raises: StoreNotFoundException: If store not found """ store = self.get_store_by_id(db, store_id) store.is_verified = is_verified # No commit here - endpoint handles transaction status = "verified" if is_verified else "unverified" logger.info(f"Store {store.store_code} set to {status}") return store, f"Store {store.store_code} is now {status}" def toggle_status(self, db: Session, store_id: int) -> tuple[Store, str]: """ Toggle store active status. Args: db: Database session store_id: Store ID Returns: Tuple of (updated store, status message) Raises: StoreNotFoundException: If store not found """ store = self.get_store_by_id(db, store_id) store.is_active = not store.is_active # No commit here - endpoint handles transaction status = "active" if store.is_active else "inactive" logger.info(f"Store {store.store_code} {status}") return store, f"Store {store.store_code} is now {status}" def set_status( self, db: Session, store_id: int, is_active: bool ) -> tuple[Store, str]: """ Set store active status to specific value. Args: db: Database session store_id: Store ID is_active: Target active status Returns: Tuple of (updated store, status message) Raises: StoreNotFoundException: If store not found """ store = self.get_store_by_id(db, store_id) store.is_active = is_active # No commit here - endpoint handles transaction status = "active" if is_active else "inactive" logger.info(f"Store {store.store_code} set to {status}") return store, f"Store {store.store_code} is now {status}" # ======================================================================== # Cross-module public API methods # ======================================================================== def get_stores_by_merchant_id( self, db: Session, merchant_id: int, active_only: bool = False ) -> list[Store]: """ Get all stores for a merchant. Args: db: Database session merchant_id: Merchant ID active_only: Only return active stores Returns: List of Store objects """ query = db.query(Store).filter(Store.merchant_id == merchant_id) if active_only: query = query.filter(Store.is_active == True) # noqa: E712 return query.order_by(Store.id).all() def get_store_by_code_or_subdomain( self, db: Session, code: str ) -> Store | None: """ Get store by store_code or subdomain. Args: db: Database session code: Store code or subdomain Returns: Store object or None """ return ( db.query(Store) .filter( (func.upper(Store.store_code) == code.upper()) | (func.lower(Store.subdomain) == code.lower()) ) .first() ) def get_total_store_count( self, db: Session, active_only: bool = False ) -> int: """ Get total count of stores. Args: db: Database session active_only: Only count active stores Returns: Store count """ query = db.query(func.count(Store.id)) if active_only: query = query.filter(Store.is_active == True) # noqa: E712 return query.scalar() or 0 def get_store_count_by_status( self, db: Session, active: bool | None = None, verified: bool | None = None, ) -> int: """ Count stores filtered by active/verified status. Args: db: Database session active: Filter by active status verified: Filter by verified status Returns: Store count matching filters """ query = db.query(func.count(Store.id)) if active is not None: query = query.filter(Store.is_active == active) if verified is not None: query = query.filter(Store.is_verified == verified) return query.scalar() or 0 def list_all_stores(self, db: Session, active_only: bool = False) -> list[Store]: """Get all stores, optionally filtering by active status.""" query = db.query(Store) if active_only: query = query.filter(Store.is_active == True) # noqa: E712 return query.order_by(Store.id).all() def is_letzshop_slug_claimed(self, db: Session, letzshop_slug: str) -> bool: """Check if a Letzshop store slug is already claimed.""" return ( db.query(Store) .filter( Store.letzshop_store_slug == letzshop_slug, Store.is_active == True, # noqa: E712 ) .first() is not None ) def is_store_code_taken(self, db: Session, store_code: str) -> bool: """Check if a store code already exists.""" return ( db.query(Store) .filter(Store.store_code == store_code) .first() is not None ) def is_subdomain_taken(self, db: Session, subdomain: str) -> bool: """Check if a subdomain already exists.""" return ( db.query(Store) .filter(Store.subdomain == subdomain) .first() is not None ) # Private helper methods def _store_code_exists(self, db: Session, store_code: str) -> bool: """Check if store code already exists (case-insensitive).""" return ( db.query(Store) .filter(func.upper(Store.store_code) == store_code.upper()) .first() is not None ) def _can_access_store(self, store: Store, user: User) -> bool: """Check if user can access store.""" # Admins can always access if user.is_admin: return True # Merchant owners can access their stores if store.merchant and store.merchant.owner_user_id == user.id: return True # Others can only access active and verified stores return store.is_active and store.is_verified def _is_store_owner(self, store: Store, user: User) -> bool: """Check if user is store owner (via merchant ownership).""" return store.merchant and store.merchant.owner_user_id == user.id def can_update_store(self, store: Store, user: User) -> bool: """ Check if user has permission to update store settings. Permission granted to: - Admins (always) - Store owners (merchant owner) - Team members with appropriate role (owner role in StoreUser) """ # Admins can always update if user.is_admin: return True # Check if user is store owner via merchant if self._is_store_owner(store, user): return True # Check if user is owner via StoreUser relationship return bool(user.is_owner_of(store.id)) def update_store( self, db: Session, store_id: int, store_update, current_user: User, ) -> "Store": """ Update store profile with permission checking. Raises: StoreNotFoundException: If store not found InsufficientPermissionsException: If user lacks permission """ from app.modules.tenancy.exceptions import InsufficientPermissionsException store = self.get_store_by_id(db, store_id) # Check permissions in service layer if not self.can_update_store(store, current_user): raise InsufficientPermissionsException( required_permission="store:profile:update" ) # Apply updates update_data = store_update.model_dump(exclude_unset=True) for field, value in update_data.items(): if hasattr(store, field): setattr(store, field, value) db.add(store) db.flush() db.refresh(store) return store def update_marketplace_settings( self, db: Session, store_id: int, marketplace_config: dict, current_user: User, ) -> dict: """ Update marketplace integration settings with permission checking. Raises: StoreNotFoundException: If store not found InsufficientPermissionsException: If user lacks permission """ from app.modules.tenancy.exceptions import InsufficientPermissionsException store = self.get_store_by_id(db, store_id) # Check permissions in service layer if not self.can_update_store(store, current_user): raise InsufficientPermissionsException( required_permission="store:settings:update" ) # Update Letzshop URLs if "letzshop_csv_url_fr" in marketplace_config: store.letzshop_csv_url_fr = marketplace_config["letzshop_csv_url_fr"] if "letzshop_csv_url_en" in marketplace_config: store.letzshop_csv_url_en = marketplace_config["letzshop_csv_url_en"] if "letzshop_csv_url_de" in marketplace_config: store.letzshop_csv_url_de = marketplace_config["letzshop_csv_url_de"] db.add(store) db.flush() db.refresh(store) return { "message": "Marketplace settings updated successfully", "letzshop_csv_url_fr": store.letzshop_csv_url_fr, "letzshop_csv_url_en": store.letzshop_csv_url_en, "letzshop_csv_url_de": store.letzshop_csv_url_de, } # Create service instance following the same pattern as other services store_service = StoreService()