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

@@ -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"
)

View File

@@ -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(

View File

@@ -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(