Add role-based filtering and imporve code
This commit is contained in:
286
services/tenant/app/api/plans.py
Normal file
286
services/tenant/app/api/plans.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Subscription Plans API
|
||||
Public endpoint for fetching available subscription plans
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Dict, Any
|
||||
import structlog
|
||||
|
||||
from shared.subscription.plans import (
|
||||
SubscriptionTier,
|
||||
SubscriptionPlanMetadata,
|
||||
PlanPricing,
|
||||
QuotaLimits,
|
||||
PlanFeatures
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(prefix="/plans", tags=["subscription-plans"])
|
||||
|
||||
|
||||
@router.get("", response_model=Dict[str, Any])
|
||||
async def get_available_plans():
|
||||
"""
|
||||
Get all available subscription plans with complete metadata
|
||||
|
||||
**Public endpoint** - No authentication required
|
||||
|
||||
Returns:
|
||||
Dictionary containing plan metadata for all tiers
|
||||
|
||||
Example Response:
|
||||
```json
|
||||
{
|
||||
"plans": {
|
||||
"starter": {
|
||||
"name": "Starter",
|
||||
"description": "Perfect for small bakeries getting started",
|
||||
"monthly_price": 49.00,
|
||||
"yearly_price": 490.00,
|
||||
"features": [...],
|
||||
"limits": {...}
|
||||
},
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
plans_data = {}
|
||||
|
||||
for tier in SubscriptionTier:
|
||||
metadata = SubscriptionPlanMetadata.PLANS[tier]
|
||||
|
||||
# Convert Decimal to float for JSON serialization
|
||||
plans_data[tier.value] = {
|
||||
"name": metadata["name"],
|
||||
"description": metadata["description"],
|
||||
"tagline": metadata["tagline"],
|
||||
"popular": metadata["popular"],
|
||||
"monthly_price": float(metadata["monthly_price"]),
|
||||
"yearly_price": float(metadata["yearly_price"]),
|
||||
"trial_days": metadata["trial_days"],
|
||||
"features": metadata["features"],
|
||||
"limits": {
|
||||
"users": metadata["limits"]["users"],
|
||||
"locations": metadata["limits"]["locations"],
|
||||
"products": metadata["limits"]["products"],
|
||||
"forecasts_per_day": metadata["limits"]["forecasts_per_day"],
|
||||
},
|
||||
"support": metadata["support"],
|
||||
"recommended_for": metadata["recommended_for"],
|
||||
"contact_sales": metadata.get("contact_sales", False),
|
||||
}
|
||||
|
||||
logger.info("subscription_plans_fetched", tier_count=len(plans_data))
|
||||
|
||||
return {"plans": plans_data}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("failed_to_fetch_plans", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to fetch subscription plans"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tier}", response_model=Dict[str, Any])
|
||||
async def get_plan_by_tier(tier: str):
|
||||
"""
|
||||
Get metadata for a specific subscription tier
|
||||
|
||||
**Public endpoint** - No authentication required
|
||||
|
||||
Args:
|
||||
tier: Subscription tier (starter, professional, enterprise)
|
||||
|
||||
Returns:
|
||||
Plan metadata for the specified tier
|
||||
|
||||
Raises:
|
||||
404: If tier is not found
|
||||
"""
|
||||
try:
|
||||
# Validate tier
|
||||
tier_enum = SubscriptionTier(tier.lower())
|
||||
|
||||
metadata = SubscriptionPlanMetadata.PLANS[tier_enum]
|
||||
|
||||
plan_data = {
|
||||
"tier": tier_enum.value,
|
||||
"name": metadata["name"],
|
||||
"description": metadata["description"],
|
||||
"tagline": metadata["tagline"],
|
||||
"popular": metadata["popular"],
|
||||
"monthly_price": float(metadata["monthly_price"]),
|
||||
"yearly_price": float(metadata["yearly_price"]),
|
||||
"trial_days": metadata["trial_days"],
|
||||
"features": metadata["features"],
|
||||
"limits": {
|
||||
"users": metadata["limits"]["users"],
|
||||
"locations": metadata["limits"]["locations"],
|
||||
"products": metadata["limits"]["products"],
|
||||
"forecasts_per_day": metadata["limits"]["forecasts_per_day"],
|
||||
},
|
||||
"support": metadata["support"],
|
||||
"recommended_for": metadata["recommended_for"],
|
||||
"contact_sales": metadata.get("contact_sales", False),
|
||||
}
|
||||
|
||||
logger.info("subscription_plan_fetched", tier=tier)
|
||||
|
||||
return plan_data
|
||||
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Subscription tier '{tier}' not found"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("failed_to_fetch_plan", tier=tier, error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to fetch subscription plan"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tier}/features")
|
||||
async def get_plan_features(tier: str):
|
||||
"""
|
||||
Get all features available in a subscription tier
|
||||
|
||||
**Public endpoint** - No authentication required
|
||||
|
||||
Args:
|
||||
tier: Subscription tier (starter, professional, enterprise)
|
||||
|
||||
Returns:
|
||||
List of feature keys available in the tier
|
||||
"""
|
||||
try:
|
||||
tier_enum = SubscriptionTier(tier.lower())
|
||||
features = PlanFeatures.get_features(tier_enum.value)
|
||||
|
||||
return {
|
||||
"tier": tier_enum.value,
|
||||
"features": features,
|
||||
"feature_count": len(features)
|
||||
}
|
||||
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Subscription tier '{tier}' not found"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tier}/limits")
|
||||
async def get_plan_limits(tier: str):
|
||||
"""
|
||||
Get all quota limits for a subscription tier
|
||||
|
||||
**Public endpoint** - No authentication required
|
||||
|
||||
Args:
|
||||
tier: Subscription tier (starter, professional, enterprise)
|
||||
|
||||
Returns:
|
||||
All quota limits for the tier
|
||||
"""
|
||||
try:
|
||||
tier_enum = SubscriptionTier(tier.lower())
|
||||
|
||||
limits = {
|
||||
"tier": tier_enum.value,
|
||||
"team_and_organization": {
|
||||
"max_users": QuotaLimits.MAX_USERS[tier_enum],
|
||||
"max_locations": QuotaLimits.MAX_LOCATIONS[tier_enum],
|
||||
},
|
||||
"product_and_inventory": {
|
||||
"max_products": QuotaLimits.MAX_PRODUCTS[tier_enum],
|
||||
"max_recipes": QuotaLimits.MAX_RECIPES[tier_enum],
|
||||
"max_suppliers": QuotaLimits.MAX_SUPPLIERS[tier_enum],
|
||||
},
|
||||
"ml_and_analytics": {
|
||||
"training_jobs_per_day": QuotaLimits.TRAINING_JOBS_PER_DAY[tier_enum],
|
||||
"forecast_generation_per_day": QuotaLimits.FORECAST_GENERATION_PER_DAY[tier_enum],
|
||||
"dataset_size_rows": QuotaLimits.DATASET_SIZE_ROWS[tier_enum],
|
||||
"forecast_horizon_days": QuotaLimits.FORECAST_HORIZON_DAYS[tier_enum],
|
||||
"historical_data_access_days": QuotaLimits.HISTORICAL_DATA_ACCESS_DAYS[tier_enum],
|
||||
},
|
||||
"import_export": {
|
||||
"bulk_import_rows": QuotaLimits.BULK_IMPORT_ROWS[tier_enum],
|
||||
"bulk_export_rows": QuotaLimits.BULK_EXPORT_ROWS[tier_enum],
|
||||
},
|
||||
"integrations": {
|
||||
"pos_sync_interval_minutes": QuotaLimits.POS_SYNC_INTERVAL_MINUTES[tier_enum],
|
||||
"api_calls_per_hour": QuotaLimits.API_CALLS_PER_HOUR[tier_enum],
|
||||
"webhook_endpoints": QuotaLimits.WEBHOOK_ENDPOINTS[tier_enum],
|
||||
},
|
||||
"storage": {
|
||||
"file_storage_gb": QuotaLimits.FILE_STORAGE_GB[tier_enum],
|
||||
"report_retention_days": QuotaLimits.REPORT_RETENTION_DAYS[tier_enum],
|
||||
}
|
||||
}
|
||||
|
||||
return limits
|
||||
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Subscription tier '{tier}' not found"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/compare")
|
||||
async def compare_plans():
|
||||
"""
|
||||
Get plan comparison data for all tiers
|
||||
|
||||
**Public endpoint** - No authentication required
|
||||
|
||||
Returns:
|
||||
Comparison matrix of all plans with key features and limits
|
||||
"""
|
||||
try:
|
||||
comparison = {
|
||||
"tiers": ["starter", "professional", "enterprise"],
|
||||
"pricing": {},
|
||||
"key_features": {},
|
||||
"key_limits": {}
|
||||
}
|
||||
|
||||
for tier in SubscriptionTier:
|
||||
metadata = SubscriptionPlanMetadata.PLANS[tier]
|
||||
|
||||
# Pricing
|
||||
comparison["pricing"][tier.value] = {
|
||||
"monthly": float(metadata["monthly_price"]),
|
||||
"yearly": float(metadata["yearly_price"]),
|
||||
"savings_percentage": round(
|
||||
((float(metadata["monthly_price"]) * 12) - float(metadata["yearly_price"])) /
|
||||
(float(metadata["monthly_price"]) * 12) * 100
|
||||
)
|
||||
}
|
||||
|
||||
# Key features (first 10)
|
||||
comparison["key_features"][tier.value] = metadata["features"][:10]
|
||||
|
||||
# Key limits
|
||||
comparison["key_limits"][tier.value] = {
|
||||
"users": metadata["limits"]["users"],
|
||||
"locations": metadata["limits"]["locations"],
|
||||
"products": metadata["limits"]["products"],
|
||||
"forecasts_per_day": metadata["limits"]["forecasts_per_day"],
|
||||
"training_jobs_per_day": QuotaLimits.TRAINING_JOBS_PER_DAY[tier],
|
||||
}
|
||||
|
||||
return comparison
|
||||
|
||||
except Exception as e:
|
||||
logger.error("failed_to_compare_plans", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to generate plan comparison"
|
||||
)
|
||||
@@ -8,6 +8,7 @@ from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Path, Query
|
||||
from typing import List, Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
import shared.redis_utils
|
||||
|
||||
from app.schemas.tenants import (
|
||||
BakeryRegistration, TenantResponse, TenantAccessResponse,
|
||||
@@ -20,14 +21,22 @@ from shared.auth.decorators import (
|
||||
get_current_user_dep,
|
||||
require_admin_role_dep
|
||||
)
|
||||
from shared.auth.access_control import owner_role_required, admin_role_required
|
||||
from shared.routing.route_builder import RouteBuilder
|
||||
from shared.database.base import create_database_manager
|
||||
from shared.monitoring.metrics import track_endpoint_metrics
|
||||
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
route_builder = RouteBuilder("tenants")
|
||||
|
||||
# Initialize audit logger
|
||||
audit_logger = create_audit_logger("tenant-service")
|
||||
|
||||
# Global Redis client
|
||||
_redis_client = None
|
||||
|
||||
# Dependency injection for enhanced tenant service
|
||||
def get_enhanced_tenant_service():
|
||||
try:
|
||||
@@ -38,11 +47,25 @@ def get_enhanced_tenant_service():
|
||||
logger.error("Failed to create enhanced tenant service", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Service initialization failed")
|
||||
|
||||
async def get_tenant_redis_client():
|
||||
"""Get or create Redis client"""
|
||||
global _redis_client
|
||||
try:
|
||||
if _redis_client is None:
|
||||
from app.core.config import settings
|
||||
_redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
|
||||
logger.info("Redis client initialized using shared utilities")
|
||||
return _redis_client
|
||||
except Exception as e:
|
||||
logger.warning("Failed to initialize Redis client, service will work with limited functionality", error=str(e))
|
||||
return None
|
||||
|
||||
def get_subscription_limit_service():
|
||||
try:
|
||||
from app.core.config import settings
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
return SubscriptionLimitService(database_manager)
|
||||
redis_client = get_tenant_redis_client()
|
||||
return SubscriptionLimitService(database_manager, redis_client)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create subscription limit service", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Service initialization failed")
|
||||
@@ -325,6 +348,7 @@ async def update_tenant_model_status(
|
||||
|
||||
@router.post(route_builder.build_base_route("{tenant_id}/deactivate", include_tenant_prefix=False))
|
||||
@track_endpoint_metrics("tenant_deactivate")
|
||||
@owner_role_required
|
||||
async def deactivate_tenant(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
@@ -339,6 +363,25 @@ async def deactivate_tenant(
|
||||
)
|
||||
|
||||
if success:
|
||||
# Log audit event for tenant deactivation
|
||||
try:
|
||||
from app.core.database import get_db_session
|
||||
async with get_db_session() as db:
|
||||
await audit_logger.log_event(
|
||||
db_session=db,
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user["user_id"],
|
||||
action=AuditAction.DEACTIVATE.value,
|
||||
resource_type="tenant",
|
||||
resource_id=str(tenant_id),
|
||||
severity=AuditSeverity.CRITICAL.value,
|
||||
description=f"Owner {current_user.get('email', current_user['user_id'])} deactivated tenant",
|
||||
endpoint="/{tenant_id}/deactivate",
|
||||
method="POST"
|
||||
)
|
||||
except Exception as audit_error:
|
||||
logger.warning("Failed to log audit event", error=str(audit_error))
|
||||
|
||||
return {"success": True, "message": "Tenant deactivated successfully"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -359,6 +402,7 @@ async def deactivate_tenant(
|
||||
|
||||
@router.post(route_builder.build_base_route("{tenant_id}/activate", include_tenant_prefix=False))
|
||||
@track_endpoint_metrics("tenant_activate")
|
||||
@owner_role_required
|
||||
async def activate_tenant(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
@@ -373,6 +417,25 @@ async def activate_tenant(
|
||||
)
|
||||
|
||||
if success:
|
||||
# Log audit event for tenant activation
|
||||
try:
|
||||
from app.core.database import get_db_session
|
||||
async with get_db_session() as db:
|
||||
await audit_logger.log_event(
|
||||
db_session=db,
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user["user_id"],
|
||||
action=AuditAction.ACTIVATE.value,
|
||||
resource_type="tenant",
|
||||
resource_id=str(tenant_id),
|
||||
severity=AuditSeverity.HIGH.value,
|
||||
description=f"Owner {current_user.get('email', current_user['user_id'])} activated tenant",
|
||||
endpoint="/{tenant_id}/activate",
|
||||
method="POST"
|
||||
)
|
||||
except Exception as audit_error:
|
||||
logger.warning("Failed to log audit event", error=str(audit_error))
|
||||
|
||||
return {"success": True, "message": "Tenant activated successfully"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -644,91 +707,10 @@ async def upgrade_subscription_plan(
|
||||
detail="Failed to upgrade subscription plan"
|
||||
)
|
||||
|
||||
@router.get("/api/v1/plans")
|
||||
async def get_available_plans():
|
||||
"""Get all available subscription plans with features and pricing - Public endpoint"""
|
||||
|
||||
try:
|
||||
# This could be moved to a config service or database
|
||||
plans = {
|
||||
"starter": {
|
||||
"name": "Starter",
|
||||
"description": "Ideal para panaderías pequeñas o nuevas",
|
||||
"monthly_price": 49.0,
|
||||
"max_users": 5,
|
||||
"max_locations": 1,
|
||||
"max_products": 50,
|
||||
"features": {
|
||||
"inventory_management": "basic",
|
||||
"demand_prediction": "basic",
|
||||
"production_reports": "basic",
|
||||
"analytics": "basic",
|
||||
"support": "email",
|
||||
"trial_days": 14,
|
||||
"locations": "1_location",
|
||||
"ai_model_configuration": "basic"
|
||||
},
|
||||
"trial_available": True
|
||||
},
|
||||
"professional": {
|
||||
"name": "Professional",
|
||||
"description": "Ideal para panaderías y cadenas en crecimiento",
|
||||
"monthly_price": 129.0,
|
||||
"max_users": 15,
|
||||
"max_locations": 2,
|
||||
"max_products": -1, # Unlimited
|
||||
"features": {
|
||||
"inventory_management": "advanced",
|
||||
"demand_prediction": "ai_92_percent",
|
||||
"production_management": "complete",
|
||||
"pos_integrated": True,
|
||||
"logistics": "basic",
|
||||
"analytics": "advanced",
|
||||
"support": "priority_24_7",
|
||||
"trial_days": 14,
|
||||
"locations": "1_2_locations",
|
||||
"ai_model_configuration": "advanced"
|
||||
},
|
||||
"trial_available": True,
|
||||
"popular": True
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "Enterprise",
|
||||
"description": "Ideal para cadenas con obradores centrales",
|
||||
"monthly_price": 399.0,
|
||||
"max_users": -1, # Unlimited
|
||||
"max_locations": -1, # Unlimited
|
||||
"max_products": -1, # Unlimited
|
||||
"features": {
|
||||
"inventory_management": "multi_location",
|
||||
"demand_prediction": "ai_personalized",
|
||||
"production_optimization": "capacity",
|
||||
"erp_integration": True,
|
||||
"logistics": "advanced",
|
||||
"analytics": "predictive",
|
||||
"api_access": "personalized",
|
||||
"account_manager": True,
|
||||
"demo": "personalized",
|
||||
"locations": "unlimited_obradores",
|
||||
"ai_model_configuration": "enterprise"
|
||||
},
|
||||
"trial_available": False,
|
||||
"contact_sales": True
|
||||
}
|
||||
}
|
||||
|
||||
return {"plans": plans}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get available plans", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get available plans"
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# PAYMENT OPERATIONS
|
||||
# ============================================================================
|
||||
# Note: /plans endpoint moved to app/api/plans.py for better organization
|
||||
|
||||
@router.post(route_builder.build_base_route("subscriptions/register-with-subscription", include_tenant_prefix=False))
|
||||
async def register_with_subscription(
|
||||
|
||||
@@ -11,6 +11,7 @@ from uuid import UUID
|
||||
from app.schemas.tenants import TenantResponse, TenantUpdate
|
||||
from app.services.tenant_service import EnhancedTenantService
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import admin_role_required
|
||||
from shared.routing.route_builder import RouteBuilder
|
||||
from shared.database.base import create_database_manager
|
||||
from shared.monitoring.metrics import track_endpoint_metrics
|
||||
@@ -48,13 +49,14 @@ async def get_tenant(
|
||||
return tenant
|
||||
|
||||
@router.put(route_builder.build_base_route("{tenant_id}", include_tenant_prefix=False), response_model=TenantResponse)
|
||||
@admin_role_required
|
||||
async def update_tenant(
|
||||
update_data: TenantUpdate,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Update tenant information - ATOMIC operation"""
|
||||
"""Update tenant information - ATOMIC operation (Admin+ only)"""
|
||||
|
||||
try:
|
||||
result = await tenant_service.update_tenant(
|
||||
|
||||
@@ -39,7 +39,11 @@ class TenantSettings(BaseServiceSettings):
|
||||
|
||||
# Redis Database (dedicated for tenant data)
|
||||
REDIS_DB: int = 4
|
||||
|
||||
|
||||
# Service URLs for usage tracking
|
||||
RECIPES_SERVICE_URL: str = os.getenv("RECIPES_SERVICE_URL", "http://recipes-service:8004")
|
||||
SUPPLIERS_SERVICE_URL: str = os.getenv("SUPPLIERS_SERVICE_URL", "http://suppliers-service:8005")
|
||||
|
||||
# Subscription Plans
|
||||
DEFAULT_PLAN: str = os.getenv("DEFAULT_PLAN", "basic")
|
||||
TRIAL_PERIOD_DAYS: int = int(os.getenv("TRIAL_PERIOD_DAYS", "14"))
|
||||
|
||||
@@ -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
|
||||
from app.api import tenants, tenant_members, tenant_operations, webhooks, internal_demo, plans
|
||||
from shared.service_base import StandardFastAPIService
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ service.setup_standard_endpoints()
|
||||
service.setup_custom_endpoints()
|
||||
|
||||
# Include routers
|
||||
service.add_router(plans.router, tags=["subscription-plans"]) # Public endpoint
|
||||
service.add_router(tenants.router, tags=["tenants"])
|
||||
service.add_router(tenant_members.router, tags=["tenant-members"])
|
||||
service.add_router(tenant_operations.router, tags=["tenant-operations"])
|
||||
|
||||
@@ -4,6 +4,13 @@ Tenant Service Models Package
|
||||
Import all models to ensure they are registered with SQLAlchemy Base.
|
||||
"""
|
||||
|
||||
# Import AuditLog model for this service
|
||||
from shared.security import create_audit_log_model
|
||||
from shared.database.base import Base
|
||||
|
||||
# Create audit log model for this service
|
||||
AuditLog = create_audit_log_model(Base)
|
||||
|
||||
# Import all models to register them with the Base metadata
|
||||
from .tenants import Tenant, TenantMember, Subscription
|
||||
|
||||
@@ -12,4 +19,5 @@ __all__ = [
|
||||
"Tenant",
|
||||
"TenantMember",
|
||||
"Subscription",
|
||||
"AuditLog",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user