Add role-based filtering and imporve code

This commit is contained in:
Urtzi Alfaro
2025-10-15 16:12:49 +02:00
parent 96ad5c6692
commit 8f9e9a7edc
158 changed files with 11033 additions and 1544 deletions

View File

@@ -7,21 +7,24 @@ import structlog
from typing import Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import HTTPException, status
from datetime import datetime, timezone
import httpx
from app.repositories import SubscriptionRepository, TenantRepository, TenantMemberRepository
from app.models.tenants import Subscription, Tenant, TenantMember
from shared.database.exceptions import DatabaseError
from shared.database.base import create_database_manager
from shared.subscription.plans import SubscriptionPlanMetadata, get_training_job_quota, get_forecast_quota
logger = structlog.get_logger()
class SubscriptionLimitService:
"""Service for validating subscription limits and features"""
def __init__(self, database_manager=None):
def __init__(self, database_manager=None, redis_client=None):
self.database_manager = database_manager or create_database_manager()
self.redis = redis_client
async def _init_repositories(self, session):
"""Initialize repositories with session"""
@@ -277,19 +280,19 @@ class SubscriptionLimitService:
return {"can_upgrade": False, "reason": "Error validating upgrade"}
async def get_usage_summary(self, tenant_id: str) -> Dict[str, Any]:
"""Get a summary of current usage vs limits for a tenant"""
"""Get a summary of current usage vs limits for a tenant - ALL 9 METRICS"""
try:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
subscription = await self.subscription_repo.get_active_subscription(tenant_id)
if not subscription:
# FIX: Return mock subscription for demo tenants instead of error
logger.info("No subscription found, returning mock data", tenant_id=tenant_id)
return {
"plan": "demo",
"monthly_price": 0,
"status": "active",
"billing_cycle": "monthly",
"usage": {
"users": {
"current": 1,
@@ -308,52 +311,121 @@ class SubscriptionLimitService:
"limit": 50,
"unlimited": False,
"usage_percentage": 0.0
},
"recipes": {
"current": 0,
"limit": 100,
"unlimited": False,
"usage_percentage": 0.0
},
"suppliers": {
"current": 0,
"limit": 20,
"unlimited": False,
"usage_percentage": 0.0
},
"training_jobs_today": {
"current": 0,
"limit": 2,
"unlimited": False,
"usage_percentage": 0.0
},
"forecasts_today": {
"current": 0,
"limit": 10,
"unlimited": False,
"usage_percentage": 0.0
},
"api_calls_this_hour": {
"current": 0,
"limit": 100,
"unlimited": False,
"usage_percentage": 0.0
},
"file_storage_used_gb": {
"current": 0.0,
"limit": 1.0,
"unlimited": False,
"usage_percentage": 0.0
}
},
"features": {},
"next_billing_date": None,
"trial_ends_at": None
}
# Get current usage
# Get current usage - Team & Organization
members = await self.member_repo.get_tenant_members(tenant_id, active_only=True)
current_users = len(members)
current_locations = 1 # TODO: Implement actual location count from locations service
# Get actual ingredient/product count from inventory service
# Get current usage - Products & Inventory
current_products = await self._get_ingredient_count(tenant_id)
current_recipes = await self._get_recipe_count(tenant_id)
current_suppliers = await self._get_supplier_count(tenant_id)
# Get current usage - IA & Analytics (Redis-based daily quotas)
training_jobs_usage = await self._get_training_jobs_today(tenant_id, subscription.plan)
forecasts_usage = await self._get_forecasts_today(tenant_id, subscription.plan)
# Get current usage - API & Storage (Redis-based)
api_calls_usage = await self._get_api_calls_this_hour(tenant_id, subscription.plan)
storage_usage = await self._get_file_storage_usage_gb(tenant_id, subscription.plan)
# Get limits from subscription
recipes_limit = await self._get_limit_from_plan(subscription.plan, 'recipes')
suppliers_limit = await self._get_limit_from_plan(subscription.plan, 'suppliers')
# TODO: Implement actual location count
current_locations = 1
return {
"plan": subscription.plan,
"monthly_price": subscription.monthly_price,
"status": subscription.status,
"billing_cycle": subscription.billing_cycle or "monthly",
"usage": {
# Team & Organization
"users": {
"current": current_users,
"limit": subscription.max_users,
"unlimited": subscription.max_users == -1,
"usage_percentage": 0 if subscription.max_users == -1 else (current_users / subscription.max_users) * 100
"usage_percentage": 0 if subscription.max_users == -1 else self._calculate_percentage(current_users, subscription.max_users)
},
"locations": {
"current": current_locations,
"limit": subscription.max_locations,
"unlimited": subscription.max_locations == -1,
"usage_percentage": 0 if subscription.max_locations == -1 else (current_locations / subscription.max_locations) * 100
"usage_percentage": 0 if subscription.max_locations == -1 else self._calculate_percentage(current_locations, subscription.max_locations)
},
# Products & Inventory
"products": {
"current": current_products,
"limit": subscription.max_products,
"unlimited": subscription.max_products == -1,
"usage_percentage": 0 if subscription.max_products == -1 else (current_products / subscription.max_products) * 100 if subscription.max_products > 0 else 0
}
"usage_percentage": 0 if subscription.max_products == -1 else self._calculate_percentage(current_products, subscription.max_products)
},
"recipes": {
"current": current_recipes,
"limit": recipes_limit,
"unlimited": recipes_limit is None,
"usage_percentage": self._calculate_percentage(current_recipes, recipes_limit)
},
"suppliers": {
"current": current_suppliers,
"limit": suppliers_limit,
"unlimited": suppliers_limit is None,
"usage_percentage": self._calculate_percentage(current_suppliers, suppliers_limit)
},
# IA & Analytics (Daily quotas)
"training_jobs_today": training_jobs_usage,
"forecasts_today": forecasts_usage,
# API & Storage
"api_calls_this_hour": api_calls_usage,
"file_storage_used_gb": storage_usage
},
"features": subscription.features or {},
"next_billing_date": subscription.next_billing_date.isoformat() if subscription.next_billing_date else None,
"trial_ends_at": subscription.trial_ends_at.isoformat() if subscription.trial_ends_at else None
}
except Exception as e:
logger.error("Failed to get usage summary",
tenant_id=tenant_id,
@@ -386,6 +458,153 @@ class SubscriptionLimitService:
# Return 0 as fallback to avoid breaking subscription display
return 0
async def _get_recipe_count(self, tenant_id: str) -> int:
"""Get recipe count from recipes service"""
try:
from app.core.config import settings
# Legacy alias for backward compatibility
SubscriptionService = SubscriptionLimitService
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{settings.RECIPES_SERVICE_URL}/api/v1/tenants/{tenant_id}/recipes/count",
headers={"X-Internal-Request": "true"}
)
response.raise_for_status()
data = response.json()
count = data.get("count", 0)
logger.info("Retrieved recipe count", tenant_id=tenant_id, count=count)
return count
except Exception as e:
logger.error("Error getting recipe count", tenant_id=tenant_id, error=str(e))
return 0
async def _get_supplier_count(self, tenant_id: str) -> int:
"""Get supplier count from suppliers service"""
try:
from app.core.config import settings
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{settings.SUPPLIERS_SERVICE_URL}/api/v1/tenants/{tenant_id}/suppliers/count",
headers={"X-Internal-Request": "true"}
)
response.raise_for_status()
data = response.json()
count = data.get("count", 0)
logger.info("Retrieved supplier count", tenant_id=tenant_id, count=count)
return count
except Exception as e:
logger.error("Error getting supplier count", tenant_id=tenant_id, error=str(e))
return 0
async def _get_redis_quota(self, quota_key: str) -> int:
"""Get current count from Redis quota key"""
try:
if not self.redis:
return 0
current = await self.redis.get(quota_key)
return int(current) if current else 0
except Exception as e:
logger.error("Error getting Redis quota", key=quota_key, error=str(e))
return 0
async def _get_training_jobs_today(self, tenant_id: str, plan: str) -> Dict[str, Any]:
"""Get training jobs usage for today"""
try:
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
quota_key = f"quota:daily:training_jobs:{tenant_id}:{date_str}"
current_count = await self._get_redis_quota(quota_key)
limit = get_training_job_quota(plan)
return {
"current": current_count,
"limit": limit,
"unlimited": limit is None,
"usage_percentage": self._calculate_percentage(current_count, limit)
}
except Exception as e:
logger.error("Error getting training jobs today", tenant_id=tenant_id, error=str(e))
return {"current": 0, "limit": None, "unlimited": True, "usage_percentage": 0.0}
async def _get_forecasts_today(self, tenant_id: str, plan: str) -> Dict[str, Any]:
"""Get forecast generation usage for today"""
try:
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
quota_key = f"quota:daily:forecast_generation:{tenant_id}:{date_str}"
current_count = await self._get_redis_quota(quota_key)
limit = get_forecast_quota(plan)
return {
"current": current_count,
"limit": limit,
"unlimited": limit is None,
"usage_percentage": self._calculate_percentage(current_count, limit)
}
except Exception as e:
logger.error("Error getting forecasts today", tenant_id=tenant_id, error=str(e))
return {"current": 0, "limit": None, "unlimited": True, "usage_percentage": 0.0}
async def _get_api_calls_this_hour(self, tenant_id: str, plan: str) -> Dict[str, Any]:
"""Get API calls usage for current hour"""
try:
hour_str = datetime.now(timezone.utc).strftime('%Y-%m-%d-%H')
quota_key = f"quota:hourly:api_calls:{tenant_id}:{hour_str}"
current_count = await self._get_redis_quota(quota_key)
plan_metadata = SubscriptionPlanMetadata.PLANS.get(plan, {})
limit = plan_metadata.get('limits', {}).get('api_calls_per_hour')
return {
"current": current_count,
"limit": limit,
"unlimited": limit is None,
"usage_percentage": self._calculate_percentage(current_count, limit)
}
except Exception as e:
logger.error("Error getting API calls this hour", tenant_id=tenant_id, error=str(e))
return {"current": 0, "limit": None, "unlimited": True, "usage_percentage": 0.0}
async def _get_file_storage_usage_gb(self, tenant_id: str, plan: str) -> Dict[str, Any]:
"""Get file storage usage in GB"""
try:
storage_key = f"storage:total_bytes:{tenant_id}"
total_bytes = await self._get_redis_quota(storage_key)
total_gb = round(total_bytes / (1024 ** 3), 2) if total_bytes > 0 else 0.0
plan_metadata = SubscriptionPlanMetadata.PLANS.get(plan, {})
limit = plan_metadata.get('limits', {}).get('file_storage_gb')
return {
"current": total_gb,
"limit": limit,
"unlimited": limit is None,
"usage_percentage": self._calculate_percentage(total_gb, limit)
}
except Exception as e:
logger.error("Error getting file storage usage", tenant_id=tenant_id, error=str(e))
return {"current": 0.0, "limit": None, "unlimited": True, "usage_percentage": 0.0}
def _calculate_percentage(self, current: float, limit: Optional[int]) -> float:
"""Calculate usage percentage"""
if limit is None or limit == -1:
return 0.0
if limit == 0:
return 0.0
return round((current / limit) * 100, 1)
async def _get_limit_from_plan(self, plan: str, limit_key: str) -> Optional[int]:
"""Get limit value from plan metadata"""
plan_metadata = SubscriptionPlanMetadata.PLANS.get(plan, {})
limit = plan_metadata.get('limits', {}).get(limit_key)
return limit if limit != -1 else None

View File

@@ -19,6 +19,7 @@ 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
from shared.clients.nominatim_client import NominatimClient
logger = structlog.get_logger()
@@ -55,7 +56,51 @@ class EnhancedTenantService:
tenant_repo = uow.register_repository("tenants", TenantRepository, Tenant)
member_repo = uow.register_repository("members", TenantMemberRepository, TenantMember)
subscription_repo = uow.register_repository("subscriptions", SubscriptionRepository, Subscription)
# Geocode address using Nominatim
latitude = getattr(bakery_data, 'latitude', None)
longitude = getattr(bakery_data, 'longitude', None)
if not latitude or not longitude:
try:
from app.core.config import settings
nominatim_client = NominatimClient(settings)
location = await nominatim_client.geocode_address(
street=bakery_data.address,
city=bakery_data.city,
postal_code=bakery_data.postal_code,
country="Spain"
)
if location:
latitude = float(location["lat"])
longitude = float(location["lon"])
logger.info(
"Address geocoded successfully",
address=bakery_data.address,
city=bakery_data.city,
latitude=latitude,
longitude=longitude
)
else:
logger.warning(
"Could not geocode address, using default Madrid coordinates",
address=bakery_data.address,
city=bakery_data.city
)
latitude = 40.4168
longitude = -3.7038
except Exception as e:
logger.warning(
"Geocoding failed, using default coordinates",
address=bakery_data.address,
error=str(e)
)
latitude = 40.4168
longitude = -3.7038
# Prepare tenant data
tenant_data = {
"name": bakery_data.name,
@@ -66,8 +111,8 @@ class EnhancedTenantService:
"phone": bakery_data.phone,
"owner_id": owner_id,
"email": getattr(bakery_data, 'email', None),
"latitude": getattr(bakery_data, 'latitude', None),
"longitude": getattr(bakery_data, 'longitude', None),
"latitude": latitude,
"longitude": longitude,
"is_active": True
}