New alert service

This commit is contained in:
Urtzi Alfaro
2025-12-05 20:07:01 +01:00
parent 1fe3a73549
commit 667e6e0404
393 changed files with 26002 additions and 61033 deletions

View File

@@ -0,0 +1,227 @@
"""
Tenant Hierarchy API - Handles parent-child tenant relationships
"""
from fastapi import APIRouter, Depends, HTTPException, status, Path
from typing import List, Dict, Any
from uuid import UUID
from app.schemas.tenants import TenantResponse
from app.services.tenant_service import EnhancedTenantService
from app.repositories.tenant_repository import TenantRepository
from shared.auth.decorators import get_current_user_dep
from shared.routing.route_builder import RouteBuilder
from shared.database.base import create_database_manager
from shared.monitoring.metrics import track_endpoint_metrics
import structlog
logger = structlog.get_logger()
router = APIRouter()
route_builder = RouteBuilder("tenants")
# Dependency injection for enhanced tenant service
def get_enhanced_tenant_service():
try:
from app.core.config import settings
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
return EnhancedTenantService(database_manager)
except Exception as e:
logger.error("Failed to create enhanced tenant service", error=str(e))
raise HTTPException(status_code=500, detail="Service initialization failed")
@router.get(route_builder.build_base_route("{tenant_id}/children", include_tenant_prefix=False), response_model=List[TenantResponse])
@track_endpoint_metrics("tenant_children_list")
async def get_tenant_children(
tenant_id: UUID = Path(..., description="Parent Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""
Get all child tenants for a parent tenant.
This endpoint returns all active child tenants associated with the specified parent tenant.
"""
try:
logger.info(
"Get tenant children request received",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id"),
user_type=current_user.get("type", "user"),
is_service=current_user.get("type") == "service",
role=current_user.get("role"),
service_name=current_user.get("service", "none")
)
# Skip access check for service-to-service calls
is_service_call = current_user.get("type") == "service"
if not is_service_call:
# Verify user has access to the parent tenant
access_info = await tenant_service.verify_user_access(current_user["user_id"], str(tenant_id))
if not access_info.has_access:
logger.warning(
"Access denied to parent tenant",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id")
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to parent tenant"
)
else:
logger.debug(
"Service-to-service call - bypassing access check",
service=current_user.get("service"),
tenant_id=str(tenant_id)
)
# Get child tenants from repository
from app.models.tenants import Tenant
async with tenant_service.database_manager.get_session() as session:
tenant_repo = TenantRepository(Tenant, session)
child_tenants = await tenant_repo.get_child_tenants(str(tenant_id))
logger.debug(
"Get tenant children successful",
tenant_id=str(tenant_id),
child_count=len(child_tenants)
)
# Convert to plain dicts while still in session to avoid lazy-load issues
child_dicts = []
for child in child_tenants:
# Handle subscription_tier safely - avoid lazy load
try:
# Try to get subscription_tier if subscriptions are already loaded
sub_tier = child.__dict__.get('_subscription_tier_cache', 'enterprise')
except:
sub_tier = 'enterprise' # Default for enterprise children
child_dict = {
'id': str(child.id),
'name': child.name,
'subdomain': child.subdomain,
'business_type': child.business_type,
'business_model': child.business_model,
'address': child.address,
'city': child.city,
'postal_code': child.postal_code,
'latitude': child.latitude,
'longitude': child.longitude,
'phone': child.phone,
'email': child.email,
'timezone': child.timezone,
'owner_id': str(child.owner_id),
'parent_tenant_id': str(child.parent_tenant_id) if child.parent_tenant_id else None,
'tenant_type': child.tenant_type,
'hierarchy_path': child.hierarchy_path,
'subscription_tier': sub_tier, # Use the safely retrieved value
'ml_model_trained': child.ml_model_trained,
'last_training_date': child.last_training_date,
'is_active': child.is_active,
'is_demo': child.is_demo,
'demo_session_id': child.demo_session_id,
'created_at': child.created_at,
'updated_at': child.updated_at
}
child_dicts.append(child_dict)
# Convert to Pydantic models outside the session without from_attributes
child_responses = [TenantResponse(**child_dict) for child_dict in child_dicts]
return child_responses
except HTTPException:
raise
except Exception as e:
logger.error("Get tenant children failed",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id"),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Get tenant children failed"
)
@router.get(route_builder.build_base_route("{tenant_id}/children/count", include_tenant_prefix=False))
@track_endpoint_metrics("tenant_children_count")
async def get_tenant_children_count(
tenant_id: UUID = Path(..., description="Parent Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""
Get count of child tenants for a parent tenant.
This endpoint returns the number of active child tenants associated with the specified parent tenant.
"""
try:
logger.info(
"Get tenant children count request received",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id")
)
# Skip access check for service-to-service calls
is_service_call = current_user.get("type") == "service"
if not is_service_call:
# Verify user has access to the parent tenant
access_info = await tenant_service.verify_user_access(current_user["user_id"], str(tenant_id))
if not access_info.has_access:
logger.warning(
"Access denied to parent tenant",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id")
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to parent tenant"
)
else:
logger.debug(
"Service-to-service call - bypassing access check",
service=current_user.get("service"),
tenant_id=str(tenant_id)
)
# Get child count from repository
from app.models.tenants import Tenant
async with tenant_service.database_manager.get_session() as session:
tenant_repo = TenantRepository(Tenant, session)
child_count = await tenant_repo.get_child_tenant_count(str(tenant_id))
logger.debug(
"Get tenant children count successful",
tenant_id=str(tenant_id),
child_count=child_count
)
return {
"parent_tenant_id": str(tenant_id),
"child_count": child_count
}
except HTTPException:
raise
except Exception as e:
logger.error("Get tenant children count failed",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id"),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Get tenant children count failed"
)
# Register the router in the main app
def register_hierarchy_routes(app):
"""Register hierarchy routes with the main application"""
from shared.routing.route_builder import RouteBuilder
route_builder = RouteBuilder("tenants")
# Include the hierarchy routes with proper tenant prefix
app.include_router(
router,
prefix="/api/v1",
tags=["tenant-hierarchy"]
)

View File

@@ -140,6 +140,49 @@ async def update_tenant(
detail="Tenant update failed"
)
@router.get(route_builder.build_base_route("user/{user_id}/tenants", include_tenant_prefix=False), response_model=List[TenantResponse])
@track_endpoint_metrics("user_tenants_list")
async def get_user_tenants(
user_id: str = Path(..., description="User ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Get all tenants accessible by a user"""
logger.info(
"Get user tenants request received",
user_id=user_id,
requesting_user=current_user.get("user_id")
)
if current_user.get("user_id") != user_id and current_user.get("type") != "service":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Can only access own tenants"
)
try:
tenants = await tenant_service.get_user_tenants(user_id)
logger.debug(
"Get user tenants successful",
user_id=user_id,
tenant_count=len(tenants)
)
return tenants
except HTTPException:
raise
except Exception as e:
logger.error("Get user tenants failed",
user_id=user_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get user tenants"
)
@router.delete(route_builder.build_base_route("{tenant_id}", include_tenant_prefix=False))
@track_endpoint_metrics("tenant_delete")
async def delete_tenant(

View File

@@ -90,4 +90,14 @@ class TenantSettings(BaseServiceSettings):
STRIPE_SECRET_KEY: str = os.getenv("STRIPE_SECRET_KEY", "")
STRIPE_WEBHOOK_SECRET: str = os.getenv("STRIPE_WEBHOOK_SECRET", "")
# ============================================================
# SCHEDULER CONFIGURATION
# ============================================================
# Usage tracking scheduler
USAGE_TRACKING_ENABLED: bool = os.getenv("USAGE_TRACKING_ENABLED", "true").lower() == "true"
USAGE_TRACKING_HOUR: int = int(os.getenv("USAGE_TRACKING_HOUR", "2"))
USAGE_TRACKING_MINUTE: int = int(os.getenv("USAGE_TRACKING_MINUTE", "0"))
USAGE_TRACKING_TIMEZONE: str = os.getenv("USAGE_TRACKING_TIMEZONE", "UTC")
settings = TenantSettings()

View File

@@ -0,0 +1,247 @@
"""
Usage Tracking Scheduler
Tracks daily usage snapshots for all active tenants
"""
import asyncio
import structlog
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import select, func
logger = structlog.get_logger()
class UsageTrackingScheduler:
"""Scheduler for daily usage tracking"""
def __init__(self, db_manager, redis_client, config):
self.db_manager = db_manager
self.redis = redis_client
self.config = config
self._running = False
self._task: Optional[asyncio.Task] = None
def seconds_until_target_time(self) -> float:
"""Calculate seconds until next target time (default 2am UTC)"""
now = datetime.now(timezone.utc)
target = now.replace(
hour=self.config.USAGE_TRACKING_HOUR,
minute=self.config.USAGE_TRACKING_MINUTE,
second=0,
microsecond=0
)
if target <= now:
target += timedelta(days=1)
return (target - now).total_seconds()
async def _get_tenant_usage(self, session, tenant_id: str) -> dict:
"""Get current usage counts for a tenant"""
usage = {}
try:
# Import models here to avoid circular imports
from app.models.tenants import TenantMember
# Users count
result = await session.execute(
select(func.count()).select_from(TenantMember).where(TenantMember.tenant_id == tenant_id)
)
usage['users'] = result.scalar() or 0
# Get counts from other services via their databases
# For now, we'll track basic metrics. More metrics can be added by querying other service databases
# Training jobs today (from Redis quota tracking)
today_key = f"quota:training_jobs:{tenant_id}:{datetime.now(timezone.utc).strftime('%Y-%m-%d')}"
training_count = await self.redis.get(today_key)
usage['training_jobs'] = int(training_count) if training_count else 0
# Forecasts today (from Redis quota tracking)
forecast_key = f"quota:forecasts:{tenant_id}:{datetime.now(timezone.utc).strftime('%Y-%m-%d')}"
forecast_count = await self.redis.get(forecast_key)
usage['forecasts'] = int(forecast_count) if forecast_count else 0
# API calls this hour (from Redis quota tracking)
hour_key = f"quota:api_calls:{tenant_id}:{datetime.now(timezone.utc).strftime('%Y-%m-%d-%H')}"
api_count = await self.redis.get(hour_key)
usage['api_calls'] = int(api_count) if api_count else 0
# Storage (placeholder - implement based on file storage system)
usage['storage'] = 0.0
except Exception as e:
logger.error("Error getting usage for tenant", tenant_id=tenant_id, error=str(e), exc_info=True)
return {}
return usage
async def _track_metrics(self, tenant_id: str, usage: dict):
"""Track metrics to Redis"""
from app.api.usage_forecast import track_usage_snapshot
for metric_name, value in usage.items():
try:
await track_usage_snapshot(tenant_id, metric_name, value)
except Exception as e:
logger.error(
"Failed to track metric",
tenant_id=tenant_id,
metric=metric_name,
error=str(e)
)
async def _run_cycle(self):
"""Execute one tracking cycle"""
start_time = datetime.now(timezone.utc)
logger.info("Starting daily usage tracking cycle")
try:
async with self.db_manager.get_session() as session:
# Import models here to avoid circular imports
from app.models.tenants import Tenant, Subscription
from sqlalchemy import select
# Get all active tenants
result = await session.execute(
select(Tenant, Subscription)
.join(Subscription, Tenant.id == Subscription.tenant_id)
.where(Tenant.is_active == True)
.where(Subscription.status.in_(['active', 'trialing', 'cancelled']))
)
tenants_data = result.all()
total_tenants = len(tenants_data)
success_count = 0
error_count = 0
logger.info(f"Found {total_tenants} active tenants to track")
# Process each tenant
for tenant, subscription in tenants_data:
try:
usage = await self._get_tenant_usage(session, tenant.id)
if usage:
await self._track_metrics(tenant.id, usage)
success_count += 1
else:
logger.warning(
"No usage data available for tenant",
tenant_id=tenant.id
)
error_count += 1
except Exception as e:
logger.error(
"Error tracking tenant usage",
tenant_id=tenant.id,
error=str(e),
exc_info=True
)
error_count += 1
end_time = datetime.now(timezone.utc)
duration = (end_time - start_time).total_seconds()
logger.info(
"Daily usage tracking completed",
total_tenants=total_tenants,
success=success_count,
errors=error_count,
duration_seconds=duration
)
except Exception as e:
logger.error("Usage tracking cycle failed", error=str(e), exc_info=True)
async def _run_scheduler(self):
"""Main scheduler loop"""
logger.info(
"Usage tracking scheduler loop started",
target_hour=self.config.USAGE_TRACKING_HOUR,
target_minute=self.config.USAGE_TRACKING_MINUTE
)
# Initial delay to target time
delay = self.seconds_until_target_time()
logger.info(f"Waiting {delay/3600:.2f} hours until next run at {self.config.USAGE_TRACKING_HOUR:02d}:{self.config.USAGE_TRACKING_MINUTE:02d} UTC")
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
logger.info("Scheduler cancelled during initial delay")
return
while self._running:
try:
await self._run_cycle()
except Exception as e:
logger.error("Scheduler cycle error", error=str(e), exc_info=True)
# Wait 24 hours until next run
try:
await asyncio.sleep(86400)
except asyncio.CancelledError:
logger.info("Scheduler cancelled during sleep")
break
def start(self):
"""Start the scheduler"""
if not self.config.USAGE_TRACKING_ENABLED:
logger.info("Usage tracking scheduler disabled by configuration")
return
if self._running:
logger.warning("Usage tracking scheduler already running")
return
self._running = True
self._task = asyncio.create_task(self._run_scheduler())
logger.info("Usage tracking scheduler started successfully")
async def stop(self):
"""Stop the scheduler gracefully"""
if not self._running:
logger.debug("Scheduler not running, nothing to stop")
return
logger.info("Stopping usage tracking scheduler")
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
logger.info("Scheduler task cancelled successfully")
logger.info("Usage tracking scheduler stopped")
# Global instance
_scheduler: Optional[UsageTrackingScheduler] = None
async def start_scheduler(db_manager, redis_client, config):
"""Start the usage tracking scheduler"""
global _scheduler
try:
_scheduler = UsageTrackingScheduler(db_manager, redis_client, config)
_scheduler.start()
logger.info("Usage tracking scheduler module initialized")
except Exception as e:
logger.error("Failed to start usage tracking scheduler", error=str(e), exc_info=True)
raise
async def stop_scheduler():
"""Stop the usage tracking scheduler"""
global _scheduler
if _scheduler:
await _scheduler.stop()
_scheduler = None
logger.info("Usage tracking scheduler module stopped")

View File

@@ -7,7 +7,7 @@ from fastapi import FastAPI
from sqlalchemy import text
from app.core.config import settings
from app.core.database import database_manager
from app.api import tenants, tenant_members, tenant_operations, webhooks, internal_demo, plans, subscription, tenant_settings, whatsapp_admin, usage_forecast, enterprise_upgrade, tenant_locations
from app.api import tenants, tenant_members, tenant_operations, webhooks, internal_demo, plans, subscription, tenant_settings, whatsapp_admin, usage_forecast, enterprise_upgrade, tenant_locations, tenant_hierarchy
from shared.service_base import StandardFastAPIService
@@ -71,10 +71,30 @@ class TenantService(StandardFastAPIService):
from app.models.tenant_settings import TenantSettings
self.logger.info("Tenant models imported successfully")
# Initialize Redis
from shared.redis_utils import initialize_redis, get_redis_client
await initialize_redis(settings.REDIS_URL, db=settings.REDIS_DB, max_connections=20)
redis_client = await get_redis_client()
self.logger.info("Redis initialized successfully")
# Start usage tracking scheduler
from app.jobs.usage_tracking_scheduler import start_scheduler
await start_scheduler(self.database_manager, redis_client, settings)
self.logger.info("Usage tracking scheduler started")
async def on_shutdown(self, app: FastAPI):
"""Custom shutdown logic for tenant service"""
# Stop usage tracking scheduler
from app.jobs.usage_tracking_scheduler import stop_scheduler
await stop_scheduler()
self.logger.info("Usage tracking scheduler stopped")
# Close Redis connection
from shared.redis_utils import close_redis
await close_redis()
self.logger.info("Redis connection closed")
# Database cleanup is handled by the base class
pass
def get_service_features(self):
"""Return tenant-specific features"""
@@ -124,6 +144,7 @@ service.add_router(tenant_operations.router, tags=["tenant-operations"])
service.add_router(webhooks.router, tags=["webhooks"])
service.add_router(enterprise_upgrade.router, tags=["enterprise"]) # Enterprise tier upgrade endpoints
service.add_router(tenant_locations.router, tags=["tenant-locations"]) # Tenant locations endpoints
service.add_router(tenant_hierarchy.router, tags=["tenant-hierarchy"]) # Tenant hierarchy endpoints
service.add_router(internal_demo.router, tags=["internal"])
if __name__ == "__main__":

View File

@@ -4,11 +4,8 @@ Business logic services for tenant operations
"""
from .tenant_service import TenantService, EnhancedTenantService
from .messaging import publish_tenant_created, publish_member_added
__all__ = [
"TenantService",
"EnhancedTenantService",
"publish_tenant_created",
"publish_member_added"
"EnhancedTenantService"
]

View File

@@ -1,74 +0,0 @@
# services/tenant/app/services/messaging.py
"""
Tenant service messaging for event publishing
"""
from shared.messaging.rabbitmq import RabbitMQClient
from app.core.config import settings
import structlog
from datetime import datetime
from typing import Dict, Any
logger = structlog.get_logger()
# Single global instance
data_publisher = RabbitMQClient(settings.RABBITMQ_URL, "data-service")
async def publish_tenant_created(tenant_id: str, owner_id: str, tenant_name: str):
"""Publish tenant created event"""
try:
await data_publisher.publish_event(
"tenant.created",
{
"tenant_id": tenant_id,
"owner_id": owner_id,
"tenant_name": tenant_name,
"timestamp": datetime.utcnow().isoformat()
}
)
except Exception as e:
logger.error(f"Failed to publish tenant.created event: {e}")
async def publish_member_added(tenant_id: str, user_id: str, role: str):
"""Publish member added event"""
try:
await data_publisher.publish_event(
"tenant.member.added",
{
"tenant_id": tenant_id,
"user_id": user_id,
"role": role,
"timestamp": datetime.utcnow().isoformat()
}
)
except Exception as e:
logger.error(f"Failed to publish tenant.member.added event: {e}")
async def publish_tenant_deleted_event(tenant_id: str, deletion_stats: Dict[str, Any]):
"""Publish tenant deletion event to message queue"""
try:
await data_publisher.publish_event(
exchange="tenant_events",
routing_key="tenant.deleted",
message={
"event_type": "tenant_deleted",
"tenant_id": tenant_id,
"timestamp": datetime.utcnow().isoformat(),
"deletion_stats": deletion_stats
}
)
except Exception as e:
logger.error("Failed to publish tenant deletion event", error=str(e))
async def publish_tenant_deleted(tenant_id: str, tenant_name: str):
"""Publish tenant deleted event (simple version)"""
try:
await data_publisher.publish_event(
"tenant.deleted",
{
"tenant_id": tenant_id,
"tenant_name": tenant_name,
"timestamp": datetime.utcnow().isoformat()
}
)
except Exception as e:
logger.error(f"Failed to publish tenant.deleted event: {e}")

View File

@@ -15,7 +15,6 @@ from app.schemas.tenants import (
BakeryRegistration, TenantResponse, TenantAccessResponse,
TenantUpdate, TenantMemberResponse
)
from app.services.messaging import publish_tenant_created, publish_member_added
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
from shared.database.base import create_database_manager
from shared.database.unit_of_work import UnitOfWork
@@ -27,8 +26,9 @@ logger = structlog.get_logger()
class EnhancedTenantService:
"""Enhanced tenant management business logic using repository pattern with dependency injection"""
def __init__(self, database_manager=None):
def __init__(self, database_manager=None, event_publisher=None):
self.database_manager = database_manager or create_database_manager()
self.event_publisher = event_publisher
async def _init_repositories(self, session):
"""Initialize repositories with session"""
@@ -165,11 +165,21 @@ class EnhancedTenantService:
# Commit the transaction
await uow.commit()
# Publish event
try:
await publish_tenant_created(str(tenant.id), owner_id, bakery_data.name)
except Exception as e:
logger.warning("Failed to publish tenant created event", error=str(e))
# Publish tenant created event
if self.event_publisher:
try:
await self.event_publisher.publish_business_event(
event_type="tenant.created",
tenant_id=str(tenant.id),
data={
"tenant_id": str(tenant.id),
"owner_id": owner_id,
"name": bakery_data.name,
"created_at": datetime.now(timezone.utc).isoformat()
}
)
except Exception as e:
logger.warning("Failed to publish tenant created event", error=str(e))
# Automatically create location-context with city information
# This is non-blocking - failure won't prevent tenant creation
@@ -557,11 +567,22 @@ class EnhancedTenantService:
member = await self.member_repo.create_membership(membership_data)
# Publish event
try:
await publish_member_added(tenant_id, user_id, role)
except Exception as e:
logger.warning("Failed to publish member added event", error=str(e))
# Publish member added event
if self.event_publisher:
try:
await self.event_publisher.publish_business_event(
event_type="tenant.member.added",
tenant_id=tenant_id,
data={
"tenant_id": tenant_id,
"user_id": user_id,
"role": role,
"invited_by": invited_by,
"added_at": datetime.now(timezone.utc).isoformat()
}
)
except Exception as e:
logger.warning("Failed to publish member added event", error=str(e))
logger.info("Team member added successfully",
tenant_id=tenant_id,
@@ -1015,10 +1036,29 @@ class EnhancedTenantService:
detail=f"Failed to delete tenant: {str(e)}"
)
# Publish deletion event for other services
# Publish deletion event for other services using unified messaging
try:
from app.services.messaging import publish_tenant_deleted
await publish_tenant_deleted(tenant_id, tenant.name)
from shared.messaging import initialize_service_publisher, EVENT_TYPES
from app.core.config import settings
# Create a temporary publisher to send the event using the unified helper
temp_rabbitmq_client, temp_publisher = await initialize_service_publisher("tenant-service", settings.RABBITMQ_URL)
if temp_publisher:
try:
await temp_publisher.publish_business_event(
event_type=EVENT_TYPES.TENANT.TENANT_DELETED,
tenant_id=tenant_id,
data={
"tenant_id": tenant_id,
"tenant_name": tenant.name,
"deleted_at": datetime.now(timezone.utc).isoformat()
}
)
finally:
if temp_rabbitmq_client:
await temp_rabbitmq_client.disconnect()
else:
logger.warning("Could not connect to RabbitMQ to publish tenant deletion event")
except Exception as e:
logger.warning("Failed to publish tenant deletion event",
tenant_id=tenant_id,