Files
bakery-ia/services/tenant/app/repositories/tenant_repository.py

411 lines
16 KiB
Python
Raw Normal View History

2025-08-08 09:08:41 +02:00
"""
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"
2025-09-30 21:58:10 +02:00
if "ml_model_trained" not in tenant_data:
tenant_data["ml_model_trained"] = False
2025-08-08 09:08:41 +02:00
# 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,
2025-09-30 21:58:10 +02:00
ml_model_trained: bool,
2025-08-08 09:08:41 +02:00
last_training_date: datetime = None
) -> Optional[Tenant]:
"""Update tenant model training status"""
try:
update_data = {
2025-09-30 21:58:10 +02:00
"ml_model_trained": ml_model_trained,
2025-08-08 09:08:41 +02:00
"updated_at": datetime.utcnow()
}
if last_training_date:
update_data["last_training_date"] = last_training_date
2025-09-30 21:58:10 +02:00
elif ml_model_trained:
2025-08-08 09:08:41 +02:00
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,
2025-09-30 21:58:10 +02:00
ml_model_trained=ml_model_trained,
2025-08-08 09:08:41 +02:00
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
2025-09-30 21:58:10 +02:00
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,
2025-08-08 09:08:41 +02:00
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"""
2025-09-30 21:58:10 +02:00
return await self.activate_record(tenant_id)