# app/modules/tenancy/services/admin_service.py """ Admin service for managing users and stores. This module provides classes and functions for: - User management and status control - Store creation with owner user generation - Store verification and activation - Platform statistics Note: Marketplace import job monitoring has been moved to the marketplace module. """ import logging import secrets import string from datetime import UTC, datetime from sqlalchemy import func, or_ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, joinedload from app.modules.tenancy.exceptions import ( AdminOperationException, CannotModifySelfException, MerchantNotFoundException, StoreAlreadyExistsException, StoreNotFoundException, StoreValidationException, StoreVerificationException, UserAlreadyExistsException, UserCannotBeDeletedException, UserNotFoundException, UserRoleChangeException, UserStatusChangeException, ) from app.modules.tenancy.models import Merchant, Platform, Role, Store, User from app.modules.tenancy.schemas.store import StoreCreate from middleware.auth import AuthManager logger = logging.getLogger(__name__) class AdminService: """Service class for admin operations following the application's service pattern.""" # ============================================================================ # USER MANAGEMENT # ============================================================================ def get_all_users(self, db: Session, skip: int = 0, limit: int = 100) -> list[User]: """Get paginated list of all users.""" try: return db.query(User).offset(skip).limit(limit).all() except SQLAlchemyError 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 ) -> tuple[User, str]: """Toggle user active status.""" user = self._get_user_by_id_or_raise(db, user_id) # Prevent self-modification if user.id == current_admin_id: raise CannotModifySelfException(user_id, "deactivate account") # Check if user is another admin if user.is_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", ) try: original_status = user.is_active user.is_active = not user.is_active user.updated_at = datetime.now(UTC) db.flush() db.refresh(user) 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 SQLAlchemyError as e: 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 list_users( self, db: Session, page: int = 1, per_page: int = 10, search: str | None = None, role: str | None = None, scope: str | None = None, is_active: bool | None = None, ) -> tuple[list[User], int, int]: """ Get paginated list of users with filtering. Args: scope: Optional scope filter. 'merchant' returns users who are merchant owners or store team members. Returns: Tuple of (users, total_count, total_pages) """ import math from app.modules.tenancy.models import Merchant, StoreUser query = db.query(User) # Apply scope filter if scope == "merchant": owner_ids = db.query(Merchant.owner_user_id).distinct() team_ids = db.query(StoreUser.user_id).distinct() query = query.filter(User.id.in_(owner_ids.union(team_ids))) # Apply filters if search: search_term = f"%{search.lower()}%" query = query.filter( or_( User.username.ilike(search_term), User.email.ilike(search_term), User.first_name.ilike(search_term), User.last_name.ilike(search_term), ) ) if role: query = query.filter(User.role == role) if is_active is not None: query = query.filter(User.is_active == is_active) # Get total count total = query.count() pages = math.ceil(total / per_page) if total > 0 else 1 # Apply pagination skip = (page - 1) * per_page users = ( query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all() ) return users, total, pages def create_user( self, db: Session, email: str, username: str, password: str, first_name: str | None = None, last_name: str | None = None, role: str = "customer", current_admin_id: int | None = None, ) -> User: """ Create a new user. Raises: UserAlreadyExistsException: If email or username already exists """ # Check if email exists if db.query(User).filter(User.email == email).first(): raise UserAlreadyExistsException("Email already registered", field="email") # Check if username exists if db.query(User).filter(User.username == username).first(): raise UserAlreadyExistsException("Username already taken", field="username") # Create user auth_manager = AuthManager() user = User( email=email, username=username, hashed_password=auth_manager.hash_password(password), first_name=first_name, last_name=last_name, role=role, is_active=True, ) db.add(user) db.flush() db.refresh(user) logger.info(f"Admin {current_admin_id} created user {user.username}") return user def get_user_details(self, db: Session, user_id: int) -> User: """ Get user with relationships loaded. Raises: UserNotFoundException: If user not found """ user = ( db.query(User) .options( joinedload(User.owned_merchants), joinedload(User.store_memberships) ) .filter(User.id == user_id) .first() ) if not user: raise UserNotFoundException(str(user_id)) return user def update_user( self, db: Session, user_id: int, current_admin_id: int, email: str | None = None, username: str | None = None, first_name: str | None = None, last_name: str | None = None, role: str | None = None, is_active: bool | None = None, ) -> User: """ Update user information. Raises: UserNotFoundException: If user not found UserAlreadyExistsException: If email/username already taken UserRoleChangeException: If trying to change own admin role """ user = self._get_user_by_id_or_raise(db, user_id) # Prevent changing own admin status if user.id == current_admin_id and role and role not in ("super_admin", "platform_admin"): raise UserRoleChangeException( user_id=user_id, current_role=user.role, target_role=role, reason="Cannot change your own admin role", ) # Check email uniqueness if changing if email and email != user.email: if db.query(User).filter(User.email == email).first(): raise UserAlreadyExistsException( "Email already registered", field="email" ) # Check username uniqueness if changing if username and username != user.username: if db.query(User).filter(User.username == username).first(): raise UserAlreadyExistsException( "Username already taken", field="username" ) # Update fields if email is not None: user.email = email if username is not None: user.username = username if first_name is not None: user.first_name = first_name if last_name is not None: user.last_name = last_name if role is not None: user.role = role if is_active is not None: user.is_active = is_active user.updated_at = datetime.now(UTC) db.flush() db.refresh(user) logger.info(f"Admin {current_admin_id} updated user {user.username}") return user def delete_user(self, db: Session, user_id: int, current_admin_id: int) -> str: """ Delete a user. Raises: UserNotFoundException: If user not found CannotModifySelfException: If trying to delete yourself UserCannotBeDeletedException: If user owns merchants """ user = ( db.query(User) .options(joinedload(User.owned_merchants)) .filter(User.id == user_id) .first() ) if not user: raise UserNotFoundException(str(user_id)) # Prevent deleting yourself if user.id == current_admin_id: raise CannotModifySelfException(user_id, "delete account") # Prevent deleting users who own merchants if user.owned_merchants: raise UserCannotBeDeletedException( user_id=user_id, reason=f"User owns {len(user.owned_merchants)} merchant(ies). Transfer ownership first.", owned_count=len(user.owned_merchants), ) from app.core.soft_delete import soft_delete username = user.username soft_delete(db, user, deleted_by_id=current_admin_id) logger.info(f"Admin {current_admin_id} deleted user {username}") return f"User {username} deleted successfully" def search_users( self, db: Session, query: str, limit: int = 10, ) -> list[dict]: """ Search users by username or email. Used for autocomplete in ownership transfer. """ search_term = f"%{query.lower()}%" users = ( db.query(User) .filter( or_(User.username.ilike(search_term), User.email.ilike(search_term)) ) .limit(limit) .all() ) return [ { "id": user.id, "username": user.username, "email": user.email, "is_active": user.is_active, } for user in users ] # ============================================================================ # STORE MANAGEMENT # ============================================================================ def create_store(self, db: Session, store_data: StoreCreate) -> Store: """ Create a store (storefront/brand) under an existing merchant. The store inherits owner and contact information from its parent merchant. Args: db: Database session store_data: Store creation data including merchant_id Returns: The created Store object with merchant relationship loaded Raises: ValidationException: If merchant not found or store code/subdomain exists AdminOperationException: If creation fails """ try: # Validate merchant exists merchant = ( db.query(Merchant).filter(Merchant.id == store_data.merchant_id).first() ) if not merchant: raise MerchantNotFoundException( store_data.merchant_id, identifier_type="id" ) # Check if store code already exists existing_store = ( db.query(Store) .filter( func.upper(Store.store_code) == store_data.store_code.upper() ) .first() ) if existing_store: raise StoreAlreadyExistsException(store_data.store_code) # Check if subdomain already exists existing_subdomain = ( db.query(Store) .filter(func.lower(Store.subdomain) == store_data.subdomain.lower()) .first() ) if existing_subdomain: raise StoreValidationException( f"Subdomain '{store_data.subdomain}' is already taken", field="subdomain", ) # Create store linked to merchant store = Store( merchant_id=merchant.id, store_code=store_data.store_code.upper(), 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=False, # Needs verification by admin ) db.add(store) db.flush() # Get store.id # Create default roles for store self._create_default_roles(db, store.id) # Assign store to platforms if provided if store_data.platform_ids: from app.modules.tenancy.models import StorePlatform for platform_id in store_data.platform_ids: # Verify platform exists platform = db.query(Platform).filter(Platform.id == platform_id).first() if platform: store_platform = StorePlatform( store_id=store.id, platform_id=platform_id, is_active=True, ) db.add(store_platform) # noqa: PERF006 logger.debug( f"Assigned store {store.store_code} to platform {platform.code}" ) db.flush() db.refresh(store) logger.info( f"Store {store.store_code} created under merchant {merchant.name} (ID: {merchant.id})" ) return store except (StoreAlreadyExistsException, MerchantNotFoundException, StoreValidationException): raise except SQLAlchemyError as e: logger.error(f"Failed to create store: {str(e)}") raise AdminOperationException( operation="create_store", reason=f"Failed to create store: {str(e)}", ) def get_all_stores( self, db: Session, skip: int = 0, limit: int = 100, search: str | None = None, is_active: bool | None = None, is_verified: bool | None = None, merchant_id: int | None = None, include_deleted: bool = False, only_deleted: bool = False, ) -> tuple[list[Store], int]: """Get paginated list of all stores with filtering.""" try: # Eagerly load merchant relationship to avoid N+1 queries query = db.query(Store).options(joinedload(Store.merchant)) # Soft-delete visibility if include_deleted or only_deleted: query = query.execution_options(include_deleted=True) if only_deleted: query = query.filter(Store.deleted_at.isnot(None)) # Filter by merchant if merchant_id is not None: query = query.filter(Store.merchant_id == merchant_id) # Apply search filter if search: search_term = f"%{search}%" query = query.filter( or_( Store.name.ilike(search_term), Store.store_code.ilike(search_term), Store.subdomain.ilike(search_term), ) ) # Apply status filters if is_active is not None: query = query.filter(Store.is_active == is_active) if is_verified is not None: query = query.filter(Store.is_verified == is_verified) # Get total count (without joinedload for performance) count_query = db.query(Store) if include_deleted or only_deleted: count_query = count_query.execution_options(include_deleted=True) if only_deleted: count_query = count_query.filter(Store.deleted_at.isnot(None)) if merchant_id is not None: count_query = count_query.filter(Store.merchant_id == merchant_id) if search: search_term = f"%{search}%" count_query = count_query.filter( or_( Store.name.ilike(search_term), Store.store_code.ilike(search_term), Store.subdomain.ilike(search_term), ) ) if is_active is not None: count_query = count_query.filter(Store.is_active == is_active) if is_verified is not None: count_query = count_query.filter(Store.is_verified == is_verified) total = count_query.count() # Get paginated results stores = query.offset(skip).limit(limit).all() return stores, total except SQLAlchemyError as e: logger.error(f"Failed to retrieve stores: {str(e)}") raise AdminOperationException( operation="get_all_stores", reason="Database query failed" ) def get_store_by_id(self, db: Session, store_id: int) -> Store: """Get store by ID.""" return self._get_store_by_id_or_raise(db, store_id) def verify_store(self, db: Session, store_id: int) -> tuple[Store, str]: """Toggle store verification status.""" store = self._get_store_by_id_or_raise(db, store_id) try: original_status = store.is_verified store.is_verified = not store.is_verified store.updated_at = datetime.now(UTC) if store.is_verified: store.verified_at = datetime.now(UTC) db.flush() db.refresh(store) status_action = "verified" if store.is_verified else "unverified" message = f"Store {store.store_code} has been {status_action}" logger.info(message) return store, message except SQLAlchemyError as e: logger.error(f"Failed to verify store {store_id}: {str(e)}") raise StoreVerificationException( store_id=store_id, reason="Database update failed", current_verification_status=original_status, ) def toggle_store_status(self, db: Session, store_id: int) -> tuple[Store, str]: """Toggle store active status.""" store = self._get_store_by_id_or_raise(db, store_id) try: store.is_active = not store.is_active store.updated_at = datetime.now(UTC) db.flush() db.refresh(store) status_action = "activated" if store.is_active else "deactivated" message = f"Store {store.store_code} has been {status_action}" logger.info(message) return store, message except SQLAlchemyError as e: logger.error(f"Failed to toggle store {store_id} status: {str(e)}") raise AdminOperationException( operation="toggle_store_status", reason="Database update failed", target_type="store", target_id=str(store_id), ) def delete_store(self, db: Session, store_id: int) -> str: """Delete store and all associated data.""" store = self._get_store_by_id_or_raise(db, store_id) try: from app.core.soft_delete import soft_delete_cascade store_code = store.store_code soft_delete_cascade(db, store, deleted_by_id=None, cascade_rels=[ ("products", []), ("customers", []), ("orders", []), ("store_users", []), ]) logger.warning(f"Store {store_code} and all associated data deleted") return f"Store {store_code} successfully deleted" except SQLAlchemyError as e: logger.error(f"Failed to delete store {store_id}: {str(e)}") raise AdminOperationException( operation="delete_store", reason="Database deletion failed" ) def update_store( self, db: Session, store_id: int, store_update, # StoreUpdate schema ) -> Store: """ Update store information (Admin only). Can update: - Store details (name, description, subdomain) - Business contact info (contact_email, phone, etc.) - Status (is_active, is_verified) Cannot update: - store_code (immutable) - merchant_id (store cannot be moved between merchants) Note: Ownership is managed at the Merchant level. Use merchant_service.transfer_ownership() for ownership changes. Args: db: Database session store_id: ID of store to update store_update: StoreUpdate schema with updated data Returns: Updated store object Raises: StoreNotFoundException: If store not found ValidationException: If subdomain already taken """ store = self._get_store_by_id_or_raise(db, store_id) try: # Get update data update_data = store_update.model_dump(exclude_unset=True) # Handle reset_contact_to_merchant flag if update_data.pop("reset_contact_to_merchant", False): # Reset all contact fields to None (inherit from merchant) update_data["contact_email"] = None update_data["contact_phone"] = None update_data["website"] = None update_data["business_address"] = None update_data["tax_number"] = None # Convert empty strings to None for contact fields (empty = inherit) contact_fields = [ "contact_email", "contact_phone", "website", "business_address", "tax_number", ] for field in contact_fields: if field in update_data and update_data[field] == "": update_data[field] = None # Check subdomain uniqueness if changing if ( "subdomain" in update_data and update_data["subdomain"] != store.subdomain ): existing = ( db.query(Store) .filter( Store.subdomain == update_data["subdomain"], Store.id != store_id, ) .first() ) if existing: raise StoreValidationException( f"Subdomain '{update_data['subdomain']}' is already taken", field="subdomain", ) # Update store fields for field, value in update_data.items(): setattr(store, field, value) store.updated_at = datetime.now(UTC) db.flush() db.refresh(store) logger.info( f"Store {store_id} ({store.store_code}) updated by admin. " f"Fields updated: {', '.join(update_data.keys())}" ) return store except StoreValidationException: raise except SQLAlchemyError as e: logger.error(f"Failed to update store {store_id}: {str(e)}") raise AdminOperationException( operation="update_store", reason=f"Database update failed: {str(e)}" ) # NOTE: Store ownership transfer is now handled at the Merchant level. # Use merchant_service.transfer_ownership() instead. # NOTE: Marketplace import job operations have been moved to the marketplace module. # Use app.modules.marketplace routes for import job management. # ============================================================================ # STATISTICS # ============================================================================ def get_store_statistics(self, db: Session) -> dict: """ Get store statistics for admin dashboard. Returns: Dict with total, verified, pending, and inactive counts. """ try: total = db.query(Store).count() verified = db.query(Store).filter(Store.is_verified == True).count() # noqa: E712 active = db.query(Store).filter(Store.is_active == True).count() # noqa: E712 inactive = total - active pending = db.query(Store).filter( Store.is_active == True, Store.is_verified == False # noqa: E712 ).count() return { "total": total, "verified": verified, "pending": pending, "inactive": inactive, } except SQLAlchemyError as e: logger.error(f"Failed to get store statistics: {str(e)}") raise AdminOperationException( operation="get_store_statistics", reason="Database query failed" ) def get_recent_stores(self, db: Session, limit: int = 5) -> list[dict]: """Get recently created stores.""" try: stores = ( db.query(Store).order_by(Store.created_at.desc()).limit(limit).all() ) return [ { "id": v.id, "store_code": v.store_code, "name": v.name, "subdomain": v.subdomain, "is_active": v.is_active, "is_verified": v.is_verified, "created_at": v.created_at, } for v in stores ] except SQLAlchemyError as e: logger.error(f"Failed to get recent stores: {str(e)}") return [] # NOTE: get_recent_import_jobs has been moved to the marketplace module # ============================================================================ # PRIVATE HELPER METHODS # ============================================================================ def get_user_by_id(self, db: Session, user_id: int) -> User | None: """ Get user by ID. Public method for cross-module consumers that need to look up a user. Returns None if not found (does not raise). Args: db: Database session user_id: User ID Returns: User object or None """ return db.query(User).filter(User.id == user_id).first() def get_user_by_email(self, db: Session, email: str) -> User | None: """Get user by email.""" return db.query(User).filter(User.email == email).first() def get_user_by_username(self, db: Session, username: str) -> User | None: """Get user by username.""" return db.query(User).filter(User.username == username).first() 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_store_by_id_or_raise(self, db: Session, store_id: int) -> Store: """Get store by ID or raise StoreNotFoundException.""" 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 _generate_temp_password(self, length: int = 12) -> str: """Generate secure temporary password.""" alphabet = string.ascii_letters + string.digits + "!@#$%^&*" return "".join(secrets.choice(alphabet) for _ in range(length)) def _create_default_roles(self, db: Session, store_id: int): """Create default roles for a new store.""" default_roles = [ {"name": "Owner", "permissions": ["*"]}, # Full access { "name": "Manager", "permissions": [ "products.*", "orders.*", "customers.view", "inventory.*", "team.view", ], }, { "name": "Editor", "permissions": [ "products.view", "products.edit", "orders.view", "inventory.view", ], }, { "name": "Viewer", "permissions": [ "products.view", "orders.view", "customers.view", "inventory.view", ], }, ] roles = [ Role( store_id=store_id, name=role_data["name"], permissions=role_data["permissions"], ) for role_data in default_roles ] db.add_all(roles) def get_user_statistics(self, db: Session) -> dict: """ Get user statistics for dashboards. Returns: Dict with total_users, active_users, inactive_users, admin_users, activation_rate """ from sqlalchemy import func total_users = db.query(func.count(User.id)).scalar() or 0 active_users = ( db.query(func.count(User.id)) .filter(User.is_active == True) # noqa: E712 .scalar() or 0 ) inactive_users = total_users - active_users admin_users = ( db.query(func.count(User.id)) .filter(User.role.in_(["super_admin", "platform_admin"])) .scalar() or 0 ) return { "total_users": total_users, "active_users": active_users, "inactive_users": inactive_users, "admin_users": admin_users, "activation_rate": ( (active_users / total_users * 100) if total_users > 0 else 0 ), } # Create service instance admin_service = AdminService()