""" Tenant Repository Repository for tenant operations """ from typing import Optional, List, Dict, Any from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, text, and_ from datetime import datetime, timedelta import structlog import uuid from .base import TenantBaseRepository from app.models.tenants import Tenant from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError logger = structlog.get_logger() class TenantRepository(TenantBaseRepository): """Repository for tenant operations""" def __init__(self, model_class, session: AsyncSession, cache_ttl: Optional[int] = 600): # Tenants are relatively stable, longer cache time (10 minutes) super().__init__(model_class, session, cache_ttl) async def create_tenant(self, tenant_data: Dict[str, Any]) -> Tenant: """Create a new tenant with validation""" try: # Validate tenant data validation_result = self._validate_tenant_data( tenant_data, ["name", "address", "postal_code", "owner_id"] ) if not validation_result["is_valid"]: raise ValidationError(f"Invalid tenant data: {validation_result['errors']}") # Generate subdomain if not provided if "subdomain" not in tenant_data or not tenant_data["subdomain"]: subdomain = await self._generate_unique_subdomain(tenant_data["name"]) tenant_data["subdomain"] = subdomain else: # Check if provided subdomain is unique existing_tenant = await self.get_by_subdomain(tenant_data["subdomain"]) if existing_tenant: raise DuplicateRecordError(f"Subdomain {tenant_data['subdomain']} already exists") # Set default values if "business_type" not in tenant_data: tenant_data["business_type"] = "bakery" if "city" not in tenant_data: tenant_data["city"] = "Madrid" if "is_active" not in tenant_data: tenant_data["is_active"] = True if "subscription_tier" not in tenant_data: tenant_data["subscription_tier"] = "basic" if "ml_model_trained" not in tenant_data: tenant_data["ml_model_trained"] = False # Create tenant tenant = await self.create(tenant_data) logger.info("Tenant created successfully", tenant_id=tenant.id, name=tenant.name, subdomain=tenant.subdomain, owner_id=tenant.owner_id) return tenant except (ValidationError, DuplicateRecordError): raise except Exception as e: logger.error("Failed to create tenant", name=tenant_data.get("name"), error=str(e)) raise DatabaseError(f"Failed to create tenant: {str(e)}") async def get_by_subdomain(self, subdomain: str) -> Optional[Tenant]: """Get tenant by subdomain""" try: return await self.get_by_field("subdomain", subdomain) except Exception as e: logger.error("Failed to get tenant by subdomain", subdomain=subdomain, error=str(e)) raise DatabaseError(f"Failed to get tenant: {str(e)}") async def get_tenants_by_owner(self, owner_id: str) -> List[Tenant]: """Get all tenants owned by a user""" try: return await self.get_multi( filters={"owner_id": owner_id, "is_active": True}, order_by="created_at", order_desc=True ) except Exception as e: logger.error("Failed to get tenants by owner", owner_id=owner_id, error=str(e)) raise DatabaseError(f"Failed to get tenants: {str(e)}") async def get_active_tenants(self, skip: int = 0, limit: int = 100) -> List[Tenant]: """Get all active tenants""" return await self.get_active_records(skip=skip, limit=limit) async def search_tenants( self, search_term: str, business_type: str = None, city: str = None, skip: int = 0, limit: int = 50 ) -> List[Tenant]: """Search tenants by name, address, or other criteria""" try: # Build search conditions conditions = ["is_active = true"] params = {"skip": skip, "limit": limit} # Add text search conditions.append("(LOWER(name) LIKE LOWER(:search_term) OR LOWER(address) LIKE LOWER(:search_term))") params["search_term"] = f"%{search_term}%" # Add business type filter if business_type: conditions.append("business_type = :business_type") params["business_type"] = business_type # Add city filter if city: conditions.append("LOWER(city) = LOWER(:city)") params["city"] = city query_text = f""" SELECT * FROM tenants WHERE {' AND '.join(conditions)} ORDER BY name ASC LIMIT :limit OFFSET :skip """ result = await self.session.execute(text(query_text), params) tenants = [] for row in result.fetchall(): record_dict = dict(row._mapping) tenant = self.model(**record_dict) tenants.append(tenant) return tenants except Exception as e: logger.error("Failed to search tenants", search_term=search_term, error=str(e)) return [] async def update_tenant_model_status( self, tenant_id: str, ml_model_trained: bool, last_training_date: datetime = None ) -> Optional[Tenant]: """Update tenant model training status""" try: update_data = { "ml_model_trained": ml_model_trained, "updated_at": datetime.utcnow() } if last_training_date: update_data["last_training_date"] = last_training_date elif ml_model_trained: update_data["last_training_date"] = datetime.utcnow() updated_tenant = await self.update(tenant_id, update_data) logger.info("Tenant model status updated", tenant_id=tenant_id, ml_model_trained=ml_model_trained, last_training_date=last_training_date) return updated_tenant except Exception as e: logger.error("Failed to update tenant model status", tenant_id=tenant_id, error=str(e)) raise DatabaseError(f"Failed to update model status: {str(e)}") async def update_subscription_tier( self, tenant_id: str, subscription_tier: str ) -> Optional[Tenant]: """Update tenant subscription tier""" try: valid_tiers = ["basic", "professional", "enterprise"] if subscription_tier not in valid_tiers: raise ValidationError(f"Invalid subscription tier. Must be one of: {valid_tiers}") updated_tenant = await self.update(tenant_id, { "subscription_tier": subscription_tier, "updated_at": datetime.utcnow() }) logger.info("Tenant subscription tier updated", tenant_id=tenant_id, subscription_tier=subscription_tier) return updated_tenant except ValidationError: raise except Exception as e: logger.error("Failed to update subscription tier", tenant_id=tenant_id, error=str(e)) raise DatabaseError(f"Failed to update subscription: {str(e)}") async def get_tenants_by_location( self, latitude: float, longitude: float, radius_km: float = 10.0, limit: int = 50 ) -> List[Tenant]: """Get tenants within a geographic radius""" try: # Using Haversine formula for distance calculation query_text = """ SELECT *, (6371 * acos( cos(radians(:latitude)) * cos(radians(latitude)) * cos(radians(longitude) - radians(:longitude)) + sin(radians(:latitude)) * sin(radians(latitude)) )) AS distance_km FROM tenants WHERE is_active = true AND latitude IS NOT NULL AND longitude IS NOT NULL HAVING distance_km <= :radius_km ORDER BY distance_km ASC LIMIT :limit """ result = await self.session.execute(text(query_text), { "latitude": latitude, "longitude": longitude, "radius_km": radius_km, "limit": limit }) tenants = [] for row in result.fetchall(): # Create tenant object (excluding the calculated distance_km field) record_dict = dict(row._mapping) record_dict.pop("distance_km", None) # Remove calculated field tenant = self.model(**record_dict) tenants.append(tenant) return tenants except Exception as e: logger.error("Failed to get tenants by location", latitude=latitude, longitude=longitude, radius_km=radius_km, error=str(e)) return [] async def get_tenant_statistics(self) -> Dict[str, Any]: """Get global tenant statistics""" try: # Get basic counts total_tenants = await self.count() active_tenants = await self.count(filters={"is_active": True}) # Get tenants by business type business_type_query = text(""" SELECT business_type, COUNT(*) as count FROM tenants WHERE is_active = true GROUP BY business_type ORDER BY count DESC """) result = await self.session.execute(business_type_query) business_type_stats = {row.business_type: row.count for row in result.fetchall()} # Get tenants by subscription tier tier_query = text(""" SELECT subscription_tier, COUNT(*) as count FROM tenants WHERE is_active = true GROUP BY subscription_tier ORDER BY count DESC """) tier_result = await self.session.execute(tier_query) tier_stats = {row.subscription_tier: row.count for row in tier_result.fetchall()} # Get model training statistics model_query = text(""" SELECT COUNT(CASE WHEN ml_model_trained = true THEN 1 END) as trained_count, COUNT(CASE WHEN ml_model_trained = false THEN 1 END) as untrained_count, AVG(EXTRACT(EPOCH FROM (NOW() - last_training_date))/86400) as avg_days_since_training FROM tenants WHERE is_active = true """) model_result = await self.session.execute(model_query) model_row = model_result.fetchone() # Get recent registrations (last 30 days) thirty_days_ago = datetime.utcnow() - timedelta(days=30) recent_registrations = await self.count(filters={ "created_at": f">= '{thirty_days_ago.isoformat()}'" }) return { "total_tenants": total_tenants, "active_tenants": active_tenants, "inactive_tenants": total_tenants - active_tenants, "tenants_by_business_type": business_type_stats, "tenants_by_subscription": tier_stats, "model_training": { "trained_tenants": int(model_row.trained_count or 0), "untrained_tenants": int(model_row.untrained_count or 0), "avg_days_since_training": float(model_row.avg_days_since_training or 0) } if model_row else { "trained_tenants": 0, "untrained_tenants": 0, "avg_days_since_training": 0.0 }, "recent_registrations_30d": recent_registrations } except Exception as e: logger.error("Failed to get tenant statistics", error=str(e)) return { "total_tenants": 0, "active_tenants": 0, "inactive_tenants": 0, "tenants_by_business_type": {}, "tenants_by_subscription": {}, "model_training": { "trained_tenants": 0, "untrained_tenants": 0, "avg_days_since_training": 0.0 }, "recent_registrations_30d": 0 } async def _generate_unique_subdomain(self, name: str) -> str: """Generate a unique subdomain from tenant name""" try: # Clean the name to create a subdomain subdomain = name.lower().replace(' ', '-') # Remove accents subdomain = subdomain.replace('á', 'a').replace('é', 'e').replace('í', 'i').replace('ó', 'o').replace('ú', 'u') subdomain = subdomain.replace('ñ', 'n') # Keep only alphanumeric and hyphens subdomain = ''.join(c for c in subdomain if c.isalnum() or c == '-') # Remove multiple consecutive hyphens while '--' in subdomain: subdomain = subdomain.replace('--', '-') # Remove leading/trailing hyphens subdomain = subdomain.strip('-') # Ensure minimum length if len(subdomain) < 3: subdomain = f"tenant-{subdomain}" # Check if subdomain exists existing_tenant = await self.get_by_subdomain(subdomain) if not existing_tenant: return subdomain # If it exists, add a unique suffix counter = 1 while True: candidate = f"{subdomain}-{counter}" existing_tenant = await self.get_by_subdomain(candidate) if not existing_tenant: return candidate counter += 1 # Prevent infinite loop if counter > 9999: return f"{subdomain}-{uuid.uuid4().hex[:6]}" except Exception as e: logger.error("Failed to generate unique subdomain", name=name, error=str(e)) # Fallback to UUID-based subdomain return f"tenant-{uuid.uuid4().hex[:8]}" async def deactivate_tenant(self, tenant_id: str) -> Optional[Tenant]: """Deactivate a tenant""" return await self.deactivate_record(tenant_id) async def activate_tenant(self, tenant_id: str) -> Optional[Tenant]: """Activate a tenant""" return await self.activate_record(tenant_id)