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

@@ -16,9 +16,13 @@ RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY shared/requirements-tracing.txt /tmp/
COPY services/tenant/requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r /tmp/requirements-tracing.txt
RUN pip install --no-cache-dir -r requirements.txt
# Copy shared libraries from the shared stage

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(

View File

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

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

View File

@@ -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",
]

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
}

View File

@@ -1,28 +0,0 @@
"""add_metadata_column_to_tenants
Revision ID: 865dc00c1244
Revises: 44b6798d898c
Create Date: 2025-10-11 12:47:19.499034+02:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '865dc00c1244'
down_revision: Union[str, None] = '44b6798d898c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add metadata_ JSON column to tenants table
op.add_column('tenants', sa.Column('metadata_', sa.JSON(), nullable=True))
def downgrade() -> None:
# Remove metadata_ column from tenants table
op.drop_column('tenants', 'metadata_')

View File

@@ -1,18 +1,18 @@
"""initial_schema_20251009_2039
"""initial_schema_20251015_1230
Revision ID: 44b6798d898c
Revision ID: 4e1ddc13dd0f
Revises:
Create Date: 2025-10-09 20:39:18.137489+02:00
Create Date: 2025-10-15 12:30:04.847858+02:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '44b6798d898c'
revision: str = '4e1ddc13dd0f'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@@ -20,6 +20,38 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('audit_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('resource_type', sa.String(length=100), nullable=False),
sa.Column('resource_id', sa.String(length=255), nullable=True),
sa.Column('severity', sa.String(length=20), nullable=False),
sa.Column('service_name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('changes', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('audit_metadata', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('endpoint', sa.String(length=255), nullable=True),
sa.Column('method', sa.String(length=10), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_audit_resource_type_action', 'audit_logs', ['resource_type', 'action'], unique=False)
op.create_index('idx_audit_service_created', 'audit_logs', ['service_name', 'created_at'], unique=False)
op.create_index('idx_audit_severity_created', 'audit_logs', ['severity', 'created_at'], unique=False)
op.create_index('idx_audit_tenant_created', 'audit_logs', ['tenant_id', 'created_at'], unique=False)
op.create_index('idx_audit_user_created', 'audit_logs', ['user_id', 'created_at'], unique=False)
op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False)
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_id'), 'audit_logs', ['resource_id'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_type'), 'audit_logs', ['resource_type'], unique=False)
op.create_index(op.f('ix_audit_logs_service_name'), 'audit_logs', ['service_name'], unique=False)
op.create_index(op.f('ix_audit_logs_severity'), 'audit_logs', ['severity'], unique=False)
op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False)
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
op.create_table('tenants',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
@@ -43,6 +75,7 @@ def upgrade() -> None:
sa.Column('demo_expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('ml_model_trained', sa.Boolean(), nullable=True),
sa.Column('last_training_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('metadata_', sa.JSON(), nullable=True),
sa.Column('owner_id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
@@ -101,4 +134,18 @@ def downgrade() -> None:
op.drop_index(op.f('ix_tenants_demo_session_id'), table_name='tenants')
op.drop_index(op.f('ix_tenants_base_demo_tenant_id'), table_name='tenants')
op.drop_table('tenants')
op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_tenant_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_severity'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_service_name'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_resource_type'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_resource_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_action'), table_name='audit_logs')
op.drop_index('idx_audit_user_created', table_name='audit_logs')
op.drop_index('idx_audit_tenant_created', table_name='audit_logs')
op.drop_index('idx_audit_severity_created', table_name='audit_logs')
op.drop_index('idx_audit_service_created', table_name='audit_logs')
op.drop_index('idx_audit_resource_type_action', table_name='audit_logs')
op.drop_table('audit_logs')
# ### end Alembic commands ###