New enterprise feature

This commit is contained in:
Urtzi Alfaro
2025-11-30 09:12:40 +01:00
parent f9d0eec6ec
commit 972db02f6d
176 changed files with 19741 additions and 1361 deletions

View File

@@ -15,6 +15,33 @@ The **Tenant Service** manages the multi-tenant SaaS architecture, handling tena
- **Tenant Branding** - Custom logos, colors (Enterprise tier)
- **Tenant Status** - Active, trial, suspended, cancelled
### 🆕 Enterprise Tier: Tenant Hierarchy Management (NEW)
- **Parent-Child Architecture** - Central production facilities (parents) coordinate multiple retail outlets (children)
- **Hierarchy Path Tracking** - Materialized path for efficient hierarchy queries (e.g., "parent_id.child_id")
- **Tenant Types** - Three types: standalone (single bakery), parent (central bakery), child (retail outlet)
- **Self-Referential Relationships** - SQLAlchemy parent-child relationships with cascade controls
- **Circular Hierarchy Prevention** - Database check constraints prevent invalid parent assignments
- **Network Admin Role** - Special role with full access across parent + all children
- **Hierarchical Access Control** - Parent admins view aggregated metrics from children (privacy-preserving)
### 🆕 Multi-Location Enterprise Support (NEW)
- **TenantLocation Model** - Separate physical locations with geo-coordinates
- **Location Types** - central_production (parent depot), retail_outlet (child stores)
- **Delivery Windows** - Configurable time windows per location for distribution scheduling
- **Operational Hours** - Business hours tracking per location
- **Capacity Tracking** - Production capacity (kg/day) for central facilities, storage capacity for outlets
- **Contact Information** - Location-specific contact person, phone, email
- **Delivery Radius** - Maximum delivery distance from central production (default 50km)
- **Schedule Configuration** - Per-location delivery day preferences (e.g., "Mon,Wed,Fri")
### 🆕 Enterprise Upgrade Path (NEW)
- **In-Place Upgrade** - Convert existing Professional tier tenant to Enterprise parent
- **Central Production Setup** - Automatic creation of central_production location on upgrade
- **Child Outlet Onboarding** - API endpoints for adding retail outlets to parent network
- **Settings Inheritance** - Child tenants inherit configurations from parent with override capability
- **Subscription Linking** - Child subscriptions automatically linked to parent billing
- **Quota Management** - Enforce maximum child tenants per parent (50 for Enterprise tier)
### Subscription Management
- **Stripe Integration** - Full Stripe API integration
- **Subscription Tiers** - Free, Pro, Enterprise plans
@@ -145,6 +172,16 @@ The **Tenant Service** manages the multi-tenant SaaS architecture, handling tena
- `GET /api/v1/tenants/invitations/{invitation_token}` - Get invitation details
- `POST /api/v1/tenants/invitations/{invitation_token}/accept` - Accept invitation
### 🆕 Enterprise Hierarchy Management (NEW)
- `POST /api/v1/tenants/{tenant_id}/upgrade-to-enterprise` - Upgrade tenant to Enterprise parent
- `POST /api/v1/tenants/{parent_id}/add-child-outlet` - Add child outlet to parent network
- `GET /api/v1/tenants/{tenant_id}/hierarchy` - Get tenant hierarchy information
- `GET /api/v1/users/{user_id}/tenant-hierarchy` - Get all tenants user can access (organized hierarchically)
- `GET /api/v1/tenants/{tenant_id}/locations` - List physical locations for tenant
- `POST /api/v1/tenants/{tenant_id}/locations` - Add new location (central_production or retail_outlet)
- `PUT /api/v1/tenants/{tenant_id}/locations/{location_id}` - Update location details
- `DELETE /api/v1/tenants/{tenant_id}/locations/{location_id}` - Remove location
### Billing & Usage
- `GET /api/v1/tenants/{tenant_id}/invoices` - List invoices
- `GET /api/v1/tenants/{tenant_id}/invoices/{invoice_id}` - Get invoice
@@ -194,6 +231,16 @@ CREATE TABLE tenants (
stripe_customer_id VARCHAR(255), -- Stripe customer ID
stripe_subscription_id VARCHAR(255), -- Stripe subscription ID
-- 🆕 Enterprise hierarchy fields (NEW)
parent_tenant_id UUID REFERENCES tenants(id) ON DELETE RESTRICT,
-- NULL for standalone/parent, set for children
tenant_type VARCHAR(50) DEFAULT 'standalone' NOT NULL,
-- standalone, parent, child
hierarchy_path VARCHAR(500), -- Materialized path (e.g., "parent_id.child_id")
CONSTRAINT chk_no_self_parent CHECK (id != parent_tenant_id),
-- Prevent circular hierarchy
-- Settings
timezone VARCHAR(50) DEFAULT 'Europe/Madrid',
language VARCHAR(10) DEFAULT 'es',
@@ -213,6 +260,10 @@ CREATE TABLE tenants (
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(email)
);
CREATE INDEX idx_tenants_parent_tenant_id ON tenants(parent_tenant_id);
CREATE INDEX idx_tenants_tenant_type ON tenants(tenant_type);
CREATE INDEX idx_tenants_hierarchy_path ON tenants(hierarchy_path);
```
**tenant_subscriptions**
@@ -251,7 +302,7 @@ CREATE TABLE tenant_members (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL, -- Link to auth service user
role VARCHAR(50) NOT NULL, -- owner, admin, manager, staff
role VARCHAR(50) NOT NULL, -- owner, admin, manager, staff, network_admin (🆕 NEW)
-- Permissions
permissions JSONB, -- Granular permissions
@@ -268,6 +319,51 @@ CREATE TABLE tenant_members (
);
```
**🆕 tenant_locations (NEW - Enterprise Tier)**
```sql
CREATE TABLE tenant_locations (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE NOT NULL,
-- Location identification
name VARCHAR(200) NOT NULL, -- E.g., "Central Bakery Madrid", "Outlet Barcelona"
location_type VARCHAR(50) NOT NULL, -- central_production, retail_outlet
-- Address
address TEXT NOT NULL,
city VARCHAR(100) DEFAULT 'Madrid',
postal_code VARCHAR(10) NOT NULL,
latitude FLOAT, -- GPS coordinates for routing
longitude FLOAT,
-- Capacity and operational config
capacity INTEGER, -- Production capacity (kg/day) or storage capacity
max_delivery_radius_km FLOAT DEFAULT 50.0, -- Maximum delivery distance from this location
operational_hours JSONB, -- {"monday": "06:00-20:00", ...}
delivery_windows JSONB, -- {"monday": "08:00-12:00,14:00-18:00", ...}
delivery_schedule_config JSONB, -- {"delivery_days": "Mon,Wed,Fri", "time_window": "07:00-10:00"}
-- Contact information
contact_person VARCHAR(200),
contact_phone VARCHAR(20),
contact_email VARCHAR(255),
-- Status
is_active BOOLEAN DEFAULT TRUE,
-- Metadata
metadata_ JSONB, -- Custom location metadata
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_tenant_locations_tenant_id ON tenant_locations(tenant_id);
CREATE INDEX idx_tenant_locations_type ON tenant_locations(location_type);
CREATE INDEX idx_tenant_locations_active ON tenant_locations(is_active);
CREATE INDEX idx_tenant_locations_tenant_type ON tenant_locations(tenant_id, location_type);
```
**tenant_invitations**
```sql
CREATE TABLE tenant_invitations (
@@ -768,6 +864,249 @@ async def handle_payment_failed(stripe_invoice: dict):
await send_account_suspended_notification(tenant.id)
```
### 🆕 Enterprise Upgrade with Hierarchy Setup (NEW)
```python
async def upgrade_tenant_to_enterprise(
tenant_id: UUID,
location_data: dict,
user_id: UUID
) -> Tenant:
"""
Upgrade existing tenant to Enterprise tier with parent-child hierarchy support.
This workflow:
1. Verifies tenant can be upgraded (Professional tier)
2. Updates tenant to 'parent' type
3. Creates central_production location
4. Updates Stripe subscription to enterprise tier
5. Sets hierarchy_path for future children
"""
# Get existing tenant
tenant = await db.get(Tenant, tenant_id)
if not tenant:
raise ValueError("Tenant not found")
# Verify current tier allows upgrade
if tenant.subscription_tier not in ['pro', 'professional']:
raise ValueError("Only Professional tier tenants can be upgraded to Enterprise")
try:
# 1. Update tenant to parent type
tenant.tenant_type = 'parent'
tenant.hierarchy_path = str(tenant_id) # Root of hierarchy
tenant.subscription_tier = 'enterprise'
# 2. Create central production location
central_location = TenantLocation(
tenant_id=tenant_id,
name=location_data.get('location_name', 'Central Production Facility'),
location_type='central_production',
address=location_data.get('address', tenant.address_line1),
city=location_data.get('city', tenant.city),
postal_code=location_data.get('postal_code', tenant.postal_code),
latitude=location_data.get('latitude'),
longitude=location_data.get('longitude'),
capacity=location_data.get('production_capacity_kg', 1000),
is_active=True
)
db.add(central_location)
# 3. Update Stripe subscription to enterprise tier
subscription = await db.query(TenantSubscription).filter(
TenantSubscription.tenant_id == tenant_id,
TenantSubscription.status == 'active'
).first()
if subscription:
new_price_id = get_stripe_price_id('enterprise', subscription.plan_interval)
import stripe
stripe.api_key = os.getenv('STRIPE_SECRET_KEY')
stripe_subscription = stripe.Subscription.retrieve(subscription.stripe_subscription_id)
stripe.Subscription.modify(
subscription.stripe_subscription_id,
items=[{
'id': stripe_subscription['items']['data'][0].id,
'price': new_price_id
}],
proration_behavior='always_invoice',
metadata={'tenant_id': str(tenant_id), 'upgraded_to_enterprise': True}
)
subscription.plan_tier = 'enterprise'
subscription.plan_amount = get_plan_amount('enterprise')
# 4. Update tenant limits for enterprise
tenant.max_locations = -1 # Unlimited locations
tenant.max_users = -1 # Unlimited users
tenant.max_transactions_per_month = -1 # Unlimited
# 5. Log upgrade event
audit = TenantAuditLog(
tenant_id=tenant_id,
user_id=user_id,
action='enterprise_upgrade',
details={
'previous_type': 'standalone',
'new_type': 'parent',
'central_location_id': str(central_location.id),
'production_capacity_kg': central_location.capacity
}
)
db.add(audit)
await db.commit()
# 6. Publish upgrade event
await publish_event('tenants', 'tenant.upgraded_to_enterprise', {
'tenant_id': str(tenant_id),
'tenant_type': 'parent',
'central_location_id': str(central_location.id)
})
logger.info("Tenant upgraded to enterprise",
tenant_id=str(tenant_id),
location_id=str(central_location.id))
return tenant
except Exception as e:
await db.rollback()
logger.error("Enterprise upgrade failed",
tenant_id=str(tenant_id),
error=str(e))
raise
async def add_child_outlet_to_parent(
parent_id: UUID,
child_data: dict,
user_id: UUID
) -> Tenant:
"""
Add a new child outlet to an enterprise parent tenant.
This creates:
1. New child tenant linked to parent
2. Retail outlet location for the child
3. Child subscription inheriting from parent
4. Settings copied from parent with overrides
"""
# Verify parent tenant
parent = await db.get(Tenant, parent_id)
if not parent or parent.tenant_type != 'parent':
raise ValueError("Parent tenant not found or not enterprise type")
# Check child quota (max 50 for enterprise)
child_count = await db.query(Tenant).filter(
Tenant.parent_tenant_id == parent_id
).count()
if child_count >= 50:
raise ValueError("Maximum number of child outlets (50) reached")
try:
# 1. Create child tenant
child_tenant = Tenant(
tenant_name=child_data['name'],
subdomain=child_data['subdomain'],
business_type=parent.business_type,
business_model=parent.business_model,
email=child_data.get('email', parent.email),
phone=child_data.get('phone', parent.phone),
address_line1=child_data['address'],
city=child_data.get('city', parent.city),
postal_code=child_data['postal_code'],
country='España',
parent_tenant_id=parent_id,
tenant_type='child',
hierarchy_path=f"{parent.hierarchy_path}.{uuid.uuid4()}",
owner_id=parent.owner_id, # Same owner as parent
status='active',
subscription_tier='enterprise', # Inherits from parent
is_active=True
)
db.add(child_tenant)
await db.flush() # Get child_tenant.id
# 2. Create retail outlet location
retail_location = TenantLocation(
tenant_id=child_tenant.id,
name=f"Outlet - {child_data['name']}",
location_type='retail_outlet',
address=child_data['address'],
city=child_data.get('city', parent.city),
postal_code=child_data['postal_code'],
latitude=child_data.get('latitude'),
longitude=child_data.get('longitude'),
delivery_windows=child_data.get('delivery_windows'),
delivery_schedule_config={
'delivery_days': child_data.get('delivery_days', 'Mon,Wed,Fri'),
'time_window': '07:00-10:00'
},
is_active=True
)
db.add(retail_location)
# 3. Create linked subscription (child shares parent subscription)
child_subscription = TenantSubscription(
tenant_id=child_tenant.id,
stripe_subscription_id=None, # Linked to parent, no separate billing
stripe_customer_id=parent.stripe_customer_id, # Same customer
plan_tier='enterprise',
plan_interval='month',
plan_amount=Decimal('0.00'), # No additional charge
status='active'
)
db.add(child_subscription)
# 4. Copy owner as member of child tenant
child_member = TenantMember(
tenant_id=child_tenant.id,
user_id=parent.owner_id,
role='admin', # Parent owner becomes admin of child
status='active'
)
db.add(child_member)
# 5. Log event
audit = TenantAuditLog(
tenant_id=parent_id,
user_id=user_id,
action='child_outlet_added',
details={
'child_tenant_id': str(child_tenant.id),
'child_name': child_data['name'],
'retail_location_id': str(retail_location.id)
}
)
db.add(audit)
await db.commit()
# 6. Publish event
await publish_event('tenants', 'tenant.child_outlet_added', {
'parent_tenant_id': str(parent_id),
'child_tenant_id': str(child_tenant.id),
'child_name': child_data['name'],
'location_id': str(retail_location.id)
})
logger.info("Child outlet added to parent",
parent_id=str(parent_id),
child_id=str(child_tenant.id))
return child_tenant
except Exception as e:
await db.rollback()
logger.error("Failed to add child outlet",
parent_id=str(parent_id),
error=str(e))
raise
```
## Events & Messaging
### Published Events (RabbitMQ)
@@ -799,6 +1138,34 @@ async def handle_payment_failed(stripe_invoice: dict):
}
```
**🆕 Tenant Upgraded to Enterprise Event (NEW)**
```json
{
"event_type": "tenant_upgraded_to_enterprise",
"tenant_id": "c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8",
"tenant_type": "parent",
"tenant_name": "Panadería Central - Obrador Madrid",
"central_location_id": "uuid",
"previous_type": "standalone",
"upgrade_timestamp": "2025-11-28T10:00:00Z"
}
```
**🆕 Child Outlet Added Event (NEW)**
```json
{
"event_type": "tenant_child_outlet_added",
"parent_tenant_id": "c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8",
"child_tenant_id": "d4e5f6a7-b8c9-410d-e2f3-a4b5c6d7e8f9",
"child_name": "Outlet Barcelona Gràcia",
"location_id": "uuid",
"location_type": "retail_outlet",
"latitude": 41.3874,
"longitude": 2.1686,
"timestamp": "2025-11-28T11:00:00Z"
}
```
## Custom Metrics (Prometheus)
```python

View File

@@ -0,0 +1,359 @@
"""
Enterprise Upgrade API
Endpoints for upgrading tenants to enterprise tier and managing child outlets
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import Dict, Any, Optional
import uuid
from datetime import datetime, date
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tenants import Tenant
from app.models.tenant_location import TenantLocation
from app.services.tenant_service import EnhancedTenantService
from app.core.config import settings
from shared.auth.tenant_access import verify_tenant_permission_dep
from shared.auth.decorators import get_current_user_dep
from shared.clients.subscription_client import SubscriptionServiceClient, get_subscription_service_client
from shared.subscription.plans import SubscriptionTier, QuotaLimits
from shared.database.base import create_database_manager
import structlog
logger = structlog.get_logger()
router = APIRouter()
# Dependency injection for enhanced tenant service
def get_enhanced_tenant_service():
try:
from app.core.config import settings
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
return EnhancedTenantService(database_manager)
except Exception as e:
logger.error("Failed to create enhanced tenant service", error=str(e))
raise HTTPException(status_code=500, detail="Service initialization failed")
# Pydantic models for request bodies
class EnterpriseUpgradeRequest(BaseModel):
location_name: Optional[str] = Field(default="Central Production Facility")
address: Optional[str] = None
city: Optional[str] = None
postal_code: Optional[str] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
production_capacity_kg: Optional[int] = Field(default=1000)
class ChildOutletRequest(BaseModel):
name: str
subdomain: str
address: str
city: Optional[str] = None
postal_code: str
latitude: Optional[float] = None
longitude: Optional[float] = None
phone: Optional[str] = None
email: Optional[str] = None
delivery_days: Optional[list] = None
@router.post("/tenants/{tenant_id}/upgrade-to-enterprise")
async def upgrade_to_enterprise(
tenant_id: str,
upgrade_data: EnterpriseUpgradeRequest,
subscription_client: SubscriptionServiceClient = Depends(get_subscription_service_client),
current_user: Dict[str, Any] = Depends(get_current_user_dep)
):
"""
Upgrade a tenant to enterprise tier with central production facility
"""
try:
from app.core.database import database_manager
from app.repositories.tenant_repository import TenantRepository
# Get the current tenant
async with database_manager.get_session() as session:
tenant_repo = TenantRepository(Tenant, session)
tenant = await tenant_repo.get_by_id(tenant_id)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
# Verify current subscription allows upgrade to enterprise
current_subscription = await subscription_client.get_subscription(tenant_id)
if current_subscription['plan'] not in [SubscriptionTier.STARTER.value, SubscriptionTier.PROFESSIONAL.value]:
raise HTTPException(status_code=400, detail="Only starter and professional tier tenants can be upgraded to enterprise")
# Verify user has admin/owner role
# This is handled by current_user check
# Update tenant to parent type
async with database_manager.get_session() as session:
tenant_repo = TenantRepository(Tenant, session)
updated_tenant = await tenant_repo.update(
tenant_id,
{
'tenant_type': 'parent',
'hierarchy_path': f"{tenant_id}" # Root path
}
)
await session.commit()
# Create central production location
location_data = {
'tenant_id': tenant_id,
'name': upgrade_data.location_name,
'location_type': 'central_production',
'address': upgrade_data.address or tenant.address,
'city': upgrade_data.city or tenant.city,
'postal_code': upgrade_data.postal_code or tenant.postal_code,
'latitude': upgrade_data.latitude or tenant.latitude,
'longitude': upgrade_data.longitude or tenant.longitude,
'capacity': upgrade_data.production_capacity_kg,
'is_active': True
}
from app.repositories.tenant_location_repository import TenantLocationRepository
from app.core.database import database_manager
# Create async session
async with database_manager.get_session() as session:
location_repo = TenantLocationRepository(session)
created_location = await location_repo.create_location(location_data)
await session.commit()
# Update subscription to enterprise tier
await subscription_client.update_subscription_plan(
tenant_id=tenant_id,
new_plan=SubscriptionTier.ENTERPRISE.value
)
return {
'success': True,
'tenant': updated_tenant,
'production_location': created_location,
'message': 'Tenant successfully upgraded to enterprise tier'
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to upgrade tenant: {str(e)}")
@router.post("/tenants/{parent_id}/add-child-outlet")
async def add_child_outlet(
parent_id: str,
child_data: ChildOutletRequest,
subscription_client: SubscriptionServiceClient = Depends(get_subscription_service_client),
current_user: Dict[str, Any] = Depends(get_current_user_dep)
):
"""
Add a new child outlet to a parent tenant
"""
try:
from app.core.database import database_manager
from app.repositories.tenant_repository import TenantRepository
# Get parent tenant and verify it's a parent
async with database_manager.get_session() as session:
tenant_repo = TenantRepository(Tenant, session)
parent_tenant = await tenant_repo.get_by_id(parent_id)
if not parent_tenant:
raise HTTPException(status_code=400, detail="Parent tenant not found")
parent_dict = {
'id': str(parent_tenant.id),
'name': parent_tenant.name,
'tenant_type': parent_tenant.tenant_type,
'subscription_tier': parent_tenant.subscription_tier,
'business_type': parent_tenant.business_type,
'business_model': parent_tenant.business_model,
'city': parent_tenant.city,
'phone': parent_tenant.phone,
'email': parent_tenant.email,
'owner_id': parent_tenant.owner_id
}
if parent_dict.get('tenant_type') != 'parent':
raise HTTPException(status_code=400, detail="Tenant is not a parent type")
# Validate subscription tier
from shared.clients import get_tenant_client
from shared.subscription.plans import PlanFeatures
tenant_client = get_tenant_client(config=settings, service_name="tenant-service")
subscription = await tenant_client.get_tenant_subscription(parent_id)
if not subscription:
raise HTTPException(
status_code=403,
detail="No active subscription found for parent tenant"
)
tier = subscription.get("plan", "starter")
if not PlanFeatures.validate_tenant_access(tier, "child"):
raise HTTPException(
status_code=403,
detail=f"Creating child outlets requires Enterprise subscription. Current plan: {tier}"
)
# Check if parent has reached child quota
async with database_manager.get_session() as session:
tenant_repo = TenantRepository(Tenant, session)
current_child_count = await tenant_repo.get_child_tenant_count(parent_id)
# Get max children from subscription plan
max_children = QuotaLimits.get_limit("MAX_CHILD_TENANTS", tier)
if max_children is not None and current_child_count >= max_children:
raise HTTPException(
status_code=403,
detail=f"Child tenant limit reached. Current: {current_child_count}, Maximum: {max_children}"
)
# Create new child tenant
child_id = str(uuid.uuid4())
child_tenant_data = {
'id': child_id,
'name': child_data.name,
'subdomain': child_data.subdomain,
'business_type': parent_dict.get('business_type', 'bakery'),
'business_model': parent_dict.get('business_model', 'retail_bakery'),
'address': child_data.address,
'city': child_data.city or parent_dict.get('city'),
'postal_code': child_data.postal_code,
'latitude': child_data.latitude,
'longitude': child_data.longitude,
'phone': child_data.phone or parent_dict.get('phone'),
'email': child_data.email or parent_dict.get('email'),
'parent_tenant_id': parent_id,
'tenant_type': 'child',
'hierarchy_path': f"{parent_id}.{child_id}",
'owner_id': parent_dict.get('owner_id'), # Same owner as parent
'is_active': True
}
# Use database managed session
async with database_manager.get_session() as session:
tenant_repo = TenantRepository(Tenant, session)
created_child = await tenant_repo.create(child_tenant_data)
await session.commit()
created_child_dict = {
'id': str(created_child.id),
'name': created_child.name,
'subdomain': created_child.subdomain
}
# Create retail outlet location for the child
location_data = {
'tenant_id': uuid.UUID(child_id),
'name': f"Outlet - {child_data.name}",
'location_type': 'retail_outlet',
'address': child_data.address,
'city': child_data.city or parent_dict.get('city'),
'postal_code': child_data.postal_code,
'latitude': child_data.latitude,
'longitude': child_data.longitude,
'delivery_windows': child_data.delivery_days,
'is_active': True
}
from app.repositories.tenant_location_repository import TenantLocationRepository
# Create async session
async with database_manager.get_session() as session:
location_repo = TenantLocationRepository(session)
created_location = await location_repo.create_location(location_data)
await session.commit()
location_dict = {
'id': str(created_location.id) if created_location else None,
'name': created_location.name if created_location else None
}
# Copy relevant settings from parent (with child-specific overrides)
# This would typically involve copying settings via tenant settings service
# Create child subscription inheriting from parent
await subscription_client.create_child_subscription(
child_tenant_id=child_id,
parent_tenant_id=parent_id
)
return {
'success': True,
'child_tenant': created_child_dict,
'location': location_dict,
'message': 'Child outlet successfully added'
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to add child outlet: {str(e)}")
@router.get("/tenants/{tenant_id}/hierarchy")
async def get_tenant_hierarchy(
tenant_id: str,
current_user: Dict[str, Any] = Depends(get_current_user_dep)
):
"""
Get tenant hierarchy information
"""
try:
from app.core.database import database_manager
from app.repositories.tenant_repository import TenantRepository
async with database_manager.get_session() as session:
tenant_repo = TenantRepository(Tenant, session)
tenant = await tenant_repo.get_by_id(tenant_id)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
result = {
'tenant_id': tenant_id,
'name': tenant.name,
'tenant_type': tenant.tenant_type,
'parent_tenant_id': tenant.parent_tenant_id,
'hierarchy_path': tenant.hierarchy_path,
'is_parent': tenant.tenant_type == 'parent',
'is_child': tenant.tenant_type == 'child'
}
# If this is a parent, include child count
if tenant.tenant_type == 'parent':
child_count = await tenant_repo.get_child_tenant_count(tenant_id)
result['child_tenant_count'] = child_count
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get hierarchy: {str(e)}")
@router.get("/users/{user_id}/tenant-hierarchy")
async def get_user_accessible_tenant_hierarchy(
user_id: str,
current_user: Dict[str, Any] = Depends(get_current_user_dep)
):
"""
Get all tenants a user has access to, organized in hierarchy
"""
try:
from app.core.database import database_manager
from app.repositories.tenant_repository import TenantRepository
# Fetch all tenants where user has access, organized hierarchically
async with database_manager.get_session() as session:
tenant_repo = TenantRepository(Tenant, session)
user_tenants = await tenant_repo.get_user_tenants_with_hierarchy(user_id)
return {
'user_id': user_id,
'tenants': user_tenants,
'total_count': len(user_tenants)
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get user hierarchy: {str(e)}")

View File

@@ -0,0 +1,628 @@
"""
Tenant Locations API - Handles tenant location operations
"""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status, Path, Query
from typing import List, Dict, Any, Optional
from uuid import UUID
from app.schemas.tenant_locations import (
TenantLocationCreate,
TenantLocationUpdate,
TenantLocationResponse,
TenantLocationsResponse,
TenantLocationTypeFilter
)
from app.repositories.tenant_location_repository import TenantLocationRepository
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import admin_role_required
from shared.monitoring.metrics import track_endpoint_metrics
from shared.routing.route_builder import RouteBuilder
logger = structlog.get_logger()
router = APIRouter()
route_builder = RouteBuilder("tenants")
# Dependency injection for tenant location repository
async def get_tenant_location_repository():
"""Get tenant location repository instance with proper session management"""
try:
from app.core.database import database_manager
# Use async context manager properly to ensure session is closed
async with database_manager.get_session() as session:
yield TenantLocationRepository(session)
except Exception as e:
logger.error("Failed to create tenant location repository", error=str(e))
raise HTTPException(status_code=500, detail="Service initialization failed")
@router.get(route_builder.build_base_route("{tenant_id}/locations", include_tenant_prefix=False), response_model=TenantLocationsResponse)
@track_endpoint_metrics("tenant_locations_list")
async def get_tenant_locations(
tenant_id: UUID = Path(..., description="Tenant ID"),
location_types: str = Query(None, description="Comma-separated list of location types to filter"),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
):
"""
Get all locations for a tenant.
Args:
tenant_id: ID of the tenant to get locations for
location_types: Optional comma-separated list of location types to filter (e.g., "central_production,retail_outlet")
is_active: Optional filter for active locations only
current_user: Current user making the request
location_repo: Tenant location repository instance
"""
try:
logger.info(
"Get tenant locations request received",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id"),
location_types=location_types,
is_active=is_active
)
# Check that the user has access to this tenant
# This would typically be checked via access control middleware
# For now, we'll trust the gateway has validated tenant access
locations = []
if location_types:
# Filter by specific location types
types_list = [t.strip() for t in location_types.split(",")]
locations = await location_repo.get_locations_by_tenant_with_type(str(tenant_id), types_list)
elif is_active is True:
# Get only active locations
locations = await location_repo.get_active_locations_by_tenant(str(tenant_id))
elif is_active is False:
# Get only inactive locations (by getting all and filtering in memory - not efficient but functional)
all_locations = await location_repo.get_locations_by_tenant(str(tenant_id))
locations = [loc for loc in all_locations if not loc.is_active]
else:
# Get all locations
locations = await location_repo.get_locations_by_tenant(str(tenant_id))
logger.debug(
"Get tenant locations successful",
tenant_id=str(tenant_id),
location_count=len(locations)
)
# Convert to response format - handle metadata field to avoid SQLAlchemy conflicts
location_responses = []
for loc in locations:
# Create dict from ORM object manually to handle metadata field properly
loc_dict = {
'id': str(loc.id),
'tenant_id': str(loc.tenant_id),
'name': loc.name,
'location_type': loc.location_type,
'address': loc.address,
'city': loc.city,
'postal_code': loc.postal_code,
'latitude': loc.latitude,
'longitude': loc.longitude,
'contact_person': loc.contact_person,
'contact_phone': loc.contact_phone,
'contact_email': loc.contact_email,
'is_active': loc.is_active,
'delivery_windows': loc.delivery_windows,
'operational_hours': loc.operational_hours,
'capacity': loc.capacity,
'max_delivery_radius_km': loc.max_delivery_radius_km,
'delivery_schedule_config': loc.delivery_schedule_config,
'metadata': loc.metadata_, # Use the actual column name to avoid conflict
'created_at': loc.created_at,
'updated_at': loc.updated_at
}
location_responses.append(TenantLocationResponse.model_validate(loc_dict))
return TenantLocationsResponse(
locations=location_responses,
total=len(location_responses)
)
except HTTPException:
raise
except Exception as e:
logger.error("Get tenant locations failed",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id"),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Get tenant locations failed"
)
@router.get(route_builder.build_base_route("{tenant_id}/locations/{location_id}", include_tenant_prefix=False), response_model=TenantLocationResponse)
@track_endpoint_metrics("tenant_location_get")
async def get_tenant_location(
tenant_id: UUID = Path(..., description="Tenant ID"),
location_id: UUID = Path(..., description="Location ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
):
"""
Get a specific location for a tenant.
Args:
tenant_id: ID of the tenant
location_id: ID of the location to retrieve
current_user: Current user making the request
location_repo: Tenant location repository instance
"""
try:
logger.info(
"Get tenant location request received",
tenant_id=str(tenant_id),
location_id=str(location_id),
user_id=current_user.get("user_id")
)
# Get the specific location
location = await location_repo.get_location_by_id(str(location_id))
if not location:
logger.warning(
"Location not found",
tenant_id=str(tenant_id),
location_id=str(location_id),
user_id=current_user.get("user_id")
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found"
)
# Verify that the location belongs to the specified tenant
if str(location.tenant_id) != str(tenant_id):
logger.warning(
"Location does not belong to tenant",
tenant_id=str(tenant_id),
location_id=str(location_id),
location_tenant_id=str(location.tenant_id),
user_id=current_user.get("user_id")
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found"
)
logger.debug(
"Get tenant location successful",
tenant_id=str(tenant_id),
location_id=str(location_id),
user_id=current_user.get("user_id")
)
# Create dict from ORM object manually to handle metadata field properly
loc_dict = {
'id': str(location.id),
'tenant_id': str(location.tenant_id),
'name': location.name,
'location_type': location.location_type,
'address': location.address,
'city': location.city,
'postal_code': location.postal_code,
'latitude': location.latitude,
'longitude': location.longitude,
'contact_person': location.contact_person,
'contact_phone': location.contact_phone,
'contact_email': location.contact_email,
'is_active': location.is_active,
'delivery_windows': location.delivery_windows,
'operational_hours': location.operational_hours,
'capacity': location.capacity,
'max_delivery_radius_km': location.max_delivery_radius_km,
'delivery_schedule_config': location.delivery_schedule_config,
'metadata': location.metadata_, # Use the actual column name to avoid conflict
'created_at': location.created_at,
'updated_at': location.updated_at
}
return TenantLocationResponse.model_validate(loc_dict)
except HTTPException:
raise
except Exception as e:
logger.error("Get tenant location failed",
tenant_id=str(tenant_id),
location_id=str(location_id),
user_id=current_user.get("user_id"),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Get tenant location failed"
)
@router.post(route_builder.build_base_route("{tenant_id}/locations", include_tenant_prefix=False), response_model=TenantLocationResponse)
@admin_role_required
async def create_tenant_location(
location_data: TenantLocationCreate,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
):
"""
Create a new location for a tenant.
Requires admin or owner privileges.
Args:
location_data: Location data to create
tenant_id: ID of the tenant to create location for
current_user: Current user making the request
location_repo: Tenant location repository instance
"""
try:
logger.info(
"Create tenant location request received",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id")
)
# Verify that the tenant_id in the path matches the one in the data
if str(tenant_id) != location_data.tenant_id:
logger.warning(
"Tenant ID mismatch",
path_tenant_id=str(tenant_id),
data_tenant_id=location_data.tenant_id,
user_id=current_user.get("user_id")
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID in path does not match data"
)
# Prepare location data by excluding unset values
location_dict = location_data.model_dump(exclude_unset=True)
# Ensure tenant_id comes from the path for security
location_dict['tenant_id'] = str(tenant_id)
created_location = await location_repo.create_location(location_dict)
logger.info(
"Created tenant location successfully",
tenant_id=str(tenant_id),
location_id=str(created_location.id),
user_id=current_user.get("user_id")
)
# Create dict from ORM object manually to handle metadata field properly
loc_dict = {
'id': str(created_location.id),
'tenant_id': str(created_location.tenant_id),
'name': created_location.name,
'location_type': created_location.location_type,
'address': created_location.address,
'city': created_location.city,
'postal_code': created_location.postal_code,
'latitude': created_location.latitude,
'longitude': created_location.longitude,
'contact_person': created_location.contact_person,
'contact_phone': created_location.contact_phone,
'contact_email': created_location.contact_email,
'is_active': created_location.is_active,
'delivery_windows': created_location.delivery_windows,
'operational_hours': created_location.operational_hours,
'capacity': created_location.capacity,
'max_delivery_radius_km': created_location.max_delivery_radius_km,
'delivery_schedule_config': created_location.delivery_schedule_config,
'metadata': created_location.metadata_, # Use the actual column name to avoid conflict
'created_at': created_location.created_at,
'updated_at': created_location.updated_at
}
return TenantLocationResponse.model_validate(loc_dict)
except HTTPException:
raise
except Exception as e:
logger.error("Create tenant location failed",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id"),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Create tenant location failed"
)
@router.put(route_builder.build_base_route("{tenant_id}/locations/{location_id}", include_tenant_prefix=False), response_model=TenantLocationResponse)
@admin_role_required
async def update_tenant_location(
update_data: TenantLocationUpdate,
tenant_id: UUID = Path(..., description="Tenant ID"),
location_id: UUID = Path(..., description="Location ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
):
"""
Update a tenant location.
Requires admin or owner privileges.
Args:
update_data: Location data to update
tenant_id: ID of the tenant
location_id: ID of the location to update
current_user: Current user making the request
location_repo: Tenant location repository instance
"""
try:
logger.info(
"Update tenant location request received",
tenant_id=str(tenant_id),
location_id=str(location_id),
user_id=current_user.get("user_id")
)
# Check if the location exists and belongs to the tenant
existing_location = await location_repo.get_location_by_id(str(location_id))
if not existing_location:
logger.warning(
"Location not found for update",
tenant_id=str(tenant_id),
location_id=str(location_id),
user_id=current_user.get("user_id")
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found"
)
if str(existing_location.tenant_id) != str(tenant_id):
logger.warning(
"Location does not belong to tenant for update",
tenant_id=str(tenant_id),
location_id=str(location_id),
location_tenant_id=str(existing_location.tenant_id),
user_id=current_user.get("user_id")
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found"
)
# Prepare update data by excluding unset values
update_dict = update_data.model_dump(exclude_unset=True)
updated_location = await location_repo.update_location(str(location_id), update_dict)
if not updated_location:
logger.error(
"Failed to update location (not found after verification)",
tenant_id=str(tenant_id),
location_id=str(location_id),
user_id=current_user.get("user_id")
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found"
)
logger.info(
"Updated tenant location successfully",
tenant_id=str(tenant_id),
location_id=str(location_id),
user_id=current_user.get("user_id")
)
# Create dict from ORM object manually to handle metadata field properly
loc_dict = {
'id': str(updated_location.id),
'tenant_id': str(updated_location.tenant_id),
'name': updated_location.name,
'location_type': updated_location.location_type,
'address': updated_location.address,
'city': updated_location.city,
'postal_code': updated_location.postal_code,
'latitude': updated_location.latitude,
'longitude': updated_location.longitude,
'contact_person': updated_location.contact_person,
'contact_phone': updated_location.contact_phone,
'contact_email': updated_location.contact_email,
'is_active': updated_location.is_active,
'delivery_windows': updated_location.delivery_windows,
'operational_hours': updated_location.operational_hours,
'capacity': updated_location.capacity,
'max_delivery_radius_km': updated_location.max_delivery_radius_km,
'delivery_schedule_config': updated_location.delivery_schedule_config,
'metadata': updated_location.metadata_, # Use the actual column name to avoid conflict
'created_at': updated_location.created_at,
'updated_at': updated_location.updated_at
}
return TenantLocationResponse.model_validate(loc_dict)
except HTTPException:
raise
except Exception as e:
logger.error("Update tenant location failed",
tenant_id=str(tenant_id),
location_id=str(location_id),
user_id=current_user.get("user_id"),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Update tenant location failed"
)
@router.delete(route_builder.build_base_route("{tenant_id}/locations/{location_id}", include_tenant_prefix=False))
@admin_role_required
async def delete_tenant_location(
tenant_id: UUID = Path(..., description="Tenant ID"),
location_id: UUID = Path(..., description="Location ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
):
"""
Delete a tenant location.
Requires admin or owner privileges.
Args:
tenant_id: ID of the tenant
location_id: ID of the location to delete
current_user: Current user making the request
location_repo: Tenant location repository instance
"""
try:
logger.info(
"Delete tenant location request received",
tenant_id=str(tenant_id),
location_id=str(location_id),
user_id=current_user.get("user_id")
)
# Check if the location exists and belongs to the tenant
existing_location = await location_repo.get_location_by_id(str(location_id))
if not existing_location:
logger.warning(
"Location not found for deletion",
tenant_id=str(tenant_id),
location_id=str(location_id),
user_id=current_user.get("user_id")
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found"
)
if str(existing_location.tenant_id) != str(tenant_id):
logger.warning(
"Location does not belong to tenant for deletion",
tenant_id=str(tenant_id),
location_id=str(location_id),
location_tenant_id=str(existing_location.tenant_id),
user_id=current_user.get("user_id")
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found"
)
deleted = await location_repo.delete_location(str(location_id))
if not deleted:
logger.warning(
"Location not found for deletion (race condition)",
tenant_id=str(tenant_id),
location_id=str(location_id),
user_id=current_user.get("user_id")
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found"
)
logger.info(
"Deleted tenant location successfully",
tenant_id=str(tenant_id),
location_id=str(location_id),
user_id=current_user.get("user_id")
)
return {
"message": "Location deleted successfully",
"location_id": str(location_id)
}
except HTTPException:
raise
except Exception as e:
logger.error("Delete tenant location failed",
tenant_id=str(tenant_id),
location_id=str(location_id),
user_id=current_user.get("user_id"),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Delete tenant location failed"
)
@router.get(route_builder.build_base_route("{tenant_id}/locations/type/{location_type}", include_tenant_prefix=False), response_model=TenantLocationsResponse)
@track_endpoint_metrics("tenant_locations_by_type")
async def get_tenant_locations_by_type(
tenant_id: UUID = Path(..., description="Tenant ID"),
location_type: str = Path(..., description="Location type to filter by", pattern=r'^(central_production|retail_outlet|warehouse|store|branch)$'),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
):
"""
Get all locations of a specific type for a tenant.
Args:
tenant_id: ID of the tenant to get locations for
location_type: Type of location to filter by
current_user: Current user making the request
location_repo: Tenant location repository instance
"""
try:
logger.info(
"Get tenant locations by type request received",
tenant_id=str(tenant_id),
location_type=location_type,
user_id=current_user.get("user_id")
)
# Use the method that returns multiple locations by types
location_list = await location_repo.get_locations_by_tenant_with_type(str(tenant_id), [location_type])
logger.debug(
"Get tenant locations by type successful",
tenant_id=str(tenant_id),
location_type=location_type,
location_count=len(location_list)
)
# Convert to response format - handle metadata field to avoid SQLAlchemy conflicts
location_responses = []
for loc in location_list:
# Create dict from ORM object manually to handle metadata field properly
loc_dict = {
'id': str(loc.id),
'tenant_id': str(loc.tenant_id),
'name': loc.name,
'location_type': loc.location_type,
'address': loc.address,
'city': loc.city,
'postal_code': loc.postal_code,
'latitude': loc.latitude,
'longitude': loc.longitude,
'contact_person': loc.contact_person,
'contact_phone': loc.contact_phone,
'contact_email': loc.contact_email,
'is_active': loc.is_active,
'delivery_windows': loc.delivery_windows,
'operational_hours': loc.operational_hours,
'capacity': loc.capacity,
'max_delivery_radius_km': loc.max_delivery_radius_km,
'delivery_schedule_config': loc.delivery_schedule_config,
'metadata': loc.metadata_, # Use the actual column name to avoid conflict
'created_at': loc.created_at,
'updated_at': loc.updated_at
}
location_responses.append(TenantLocationResponse.model_validate(loc_dict))
return TenantLocationsResponse(
locations=location_responses,
total=len(location_responses)
)
except HTTPException:
raise
except Exception as e:
logger.error("Get tenant locations by type failed",
tenant_id=str(tenant_id),
location_type=location_type,
user_id=current_user.get("user_id"),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Get tenant locations by type failed"
)

View File

@@ -290,13 +290,46 @@ async def get_user_owned_tenants(
# Users can only get their own tenants unless they're admin
user_role = current_user.get('role', '').lower()
if user_id != current_user["user_id"] and user_role != 'admin':
# Handle demo user: frontend uses "demo-user" but backend has actual demo user UUID
is_demo_user = current_user.get("is_demo", False) and user_id == "demo-user"
if user_id != current_user["user_id"] and not is_demo_user and user_role != 'admin':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Can only access your own tenants"
)
tenants = await tenant_service.get_user_tenants(user_id)
# For demo sessions, we need to handle the special case where virtual tenants are not owned by the
# demo user ID but are instead associated with the demo session
if current_user.get("is_demo", False):
# Extract demo session info from headers (gateway should set this when processing demo tokens)
demo_session_id = current_user.get("demo_session_id")
demo_account_type = current_user.get("demo_account_type", "")
if demo_session_id:
# For demo sessions, get virtual tenants associated with the session
# Rather than returning all tenants owned by the shared demo user ID
logger.info("Fetching virtual tenants for demo session",
demo_session_id=demo_session_id,
demo_account_type=demo_account_type)
# Special logic for demo sessions: return virtual tenants associated with this session
virtual_tenants = await tenant_service.get_virtual_tenants_for_session(demo_session_id, demo_account_type)
return virtual_tenants
else:
# Fallback: if no session ID but is a demo user, return based on account type
# Individual bakery demo user should have access to the professional demo tenant
# Enterprise demo session should have access only to enterprise parent tenant and its child
virtual_tenants = await tenant_service.get_demo_tenants_by_session_type(
demo_account_type,
str(current_user["user_id"])
)
return virtual_tenants
# For regular users, use the original logic
actual_user_id = current_user["user_id"] if is_demo_user else user_id
tenants = await tenant_service.get_user_tenants(actual_user_id)
return tenants
@router.get(route_builder.build_base_route("search", include_tenant_prefix=False), response_model=List[TenantResponse])

View File

@@ -7,7 +7,7 @@ from fastapi import FastAPI
from sqlalchemy import text
from app.core.config import settings
from app.core.database import database_manager
from app.api import tenants, tenant_members, tenant_operations, webhooks, internal_demo, plans, subscription, tenant_settings, whatsapp_admin, usage_forecast
from app.api import tenants, tenant_members, tenant_operations, webhooks, internal_demo, plans, subscription, tenant_settings, whatsapp_admin, usage_forecast, enterprise_upgrade, tenant_locations
from shared.service_base import StandardFastAPIService
@@ -122,6 +122,8 @@ 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"])
service.add_router(webhooks.router, tags=["webhooks"])
service.add_router(enterprise_upgrade.router, tags=["enterprise"]) # Enterprise tier upgrade endpoints
service.add_router(tenant_locations.router, tags=["tenant-locations"]) # Tenant locations endpoints
service.add_router(internal_demo.router, tags=["internal"])
if __name__ == "__main__":

View File

@@ -13,6 +13,7 @@ AuditLog = create_audit_log_model(Base)
# Import all models to register them with the Base metadata
from .tenants import Tenant, TenantMember, Subscription
from .tenant_location import TenantLocation
from .coupon import CouponModel, CouponRedemptionModel
from .events import Event, EventTemplate
@@ -21,6 +22,7 @@ __all__ = [
"Tenant",
"TenantMember",
"Subscription",
"TenantLocation",
"AuditLog",
"CouponModel",
"CouponRedemptionModel",

View File

@@ -0,0 +1,59 @@
"""
Tenant Location Model
Represents physical locations for enterprise tenants (central production, retail outlets)
"""
from sqlalchemy import Column, String, Boolean, DateTime, Float, ForeignKey, Text, Integer, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
import uuid
from shared.database.base import Base
class TenantLocation(Base):
"""TenantLocation model - represents physical locations for enterprise tenants"""
__tablename__ = "tenant_locations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
# Location information
name = Column(String(200), nullable=False)
location_type = Column(String(50), nullable=False) # central_production, retail_outlet
address = Column(Text, nullable=False)
city = Column(String(100), default="Madrid")
postal_code = Column(String(10), nullable=False)
latitude = Column(Float, nullable=True)
longitude = Column(Float, nullable=True)
# Location-specific configuration
delivery_windows = Column(JSON, nullable=True) # { "monday": "08:00-12:00,14:00-18:00", ... }
capacity = Column(Integer, nullable=True) # For production capacity in kg/day or storage capacity
max_delivery_radius_km = Column(Float, nullable=True, default=50.0)
# Operational hours
operational_hours = Column(JSON, nullable=True) # { "monday": "06:00-20:00", ... }
is_active = Column(Boolean, default=True)
# Contact information
contact_person = Column(String(200), nullable=True)
contact_phone = Column(String(20), nullable=True)
contact_email = Column(String(255), nullable=True)
# Custom delivery scheduling configuration per location
delivery_schedule_config = Column(JSON, nullable=True) # { "delivery_days": "Mon,Wed,Fri", "time_window": "07:00-10:00" }
# Metadata
metadata_ = Column(JSON, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
tenant = relationship("Tenant", back_populates="locations")
def __repr__(self):
return f"<TenantLocation(id={self.id}, tenant_id={self.tenant_id}, name={self.name}, type={self.location_type})>"

View File

@@ -56,6 +56,11 @@ class Tenant(Base):
# Ownership (user_id without FK - cross-service reference)
owner_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Enterprise tier hierarchy fields
parent_tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=True, index=True)
tenant_type = Column(String(50), default="standalone", nullable=False) # standalone, parent, child
hierarchy_path = Column(String(500), nullable=True) # Materialized path for queries
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
@@ -63,6 +68,9 @@ class Tenant(Base):
# Relationships - only within tenant service
members = relationship("TenantMember", back_populates="tenant", cascade="all, delete-orphan")
subscriptions = relationship("Subscription", back_populates="tenant", cascade="all, delete-orphan")
locations = relationship("TenantLocation", back_populates="tenant", cascade="all, delete-orphan")
child_tenants = relationship("Tenant", back_populates="parent_tenant", remote_side=[id])
parent_tenant = relationship("Tenant", back_populates="child_tenants", remote_side=[parent_tenant_id])
# REMOVED: users relationship - no cross-service SQLAlchemy relationships
@@ -115,7 +123,7 @@ class TenantMember(Base):
user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # No FK - cross-service reference
# Role and permissions specific to this tenant
# Valid values: 'owner', 'admin', 'member', 'viewer'
# Valid values: 'owner', 'admin', 'member', 'viewer', 'network_admin'
role = Column(String(50), default="member")
permissions = Column(Text) # JSON string of permissions

View File

@@ -0,0 +1,218 @@
"""
Tenant Location Repository
Handles database operations for tenant location data
"""
from typing import List, Optional, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from sqlalchemy.orm import selectinload
import structlog
from app.models.tenant_location import TenantLocation
from app.models.tenants import Tenant
from shared.database.exceptions import DatabaseError
from .base import BaseRepository
logger = structlog.get_logger()
class TenantLocationRepository(BaseRepository):
"""Repository for tenant location operations"""
def __init__(self, session: AsyncSession):
super().__init__(TenantLocation, session)
async def create_location(self, location_data: Dict[str, Any]) -> TenantLocation:
"""
Create a new tenant location
Args:
location_data: Dictionary containing location information
Returns:
Created TenantLocation object
"""
try:
# Create new location instance
location = TenantLocation(**location_data)
self.session.add(location)
await self.session.commit()
await self.session.refresh(location)
logger.info(f"Created new tenant location: {location.id} for tenant {location.tenant_id}")
return location
except Exception as e:
await self.session.rollback()
logger.error(f"Failed to create tenant location: {str(e)}")
raise DatabaseError(f"Failed to create tenant location: {str(e)}")
async def get_location_by_id(self, location_id: str) -> Optional[TenantLocation]:
"""
Get a location by its ID
Args:
location_id: UUID of the location
Returns:
TenantLocation object if found, None otherwise
"""
try:
stmt = select(TenantLocation).where(TenantLocation.id == location_id)
result = await self.session.execute(stmt)
location = result.scalar_one_or_none()
return location
except Exception as e:
logger.error(f"Failed to get location by ID: {str(e)}")
raise DatabaseError(f"Failed to get location by ID: {str(e)}")
async def get_locations_by_tenant(self, tenant_id: str) -> List[TenantLocation]:
"""
Get all locations for a specific tenant
Args:
tenant_id: UUID of the tenant
Returns:
List of TenantLocation objects
"""
try:
stmt = select(TenantLocation).where(TenantLocation.tenant_id == tenant_id)
result = await self.session.execute(stmt)
locations = result.scalars().all()
return locations
except Exception as e:
logger.error(f"Failed to get locations by tenant: {str(e)}")
raise DatabaseError(f"Failed to get locations by tenant: {str(e)}")
async def get_location_by_type(self, tenant_id: str, location_type: str) -> Optional[TenantLocation]:
"""
Get a location by tenant and type
Args:
tenant_id: UUID of the tenant
location_type: Type of location (e.g., 'central_production', 'retail_outlet')
Returns:
TenantLocation object if found, None otherwise
"""
try:
stmt = select(TenantLocation).where(
TenantLocation.tenant_id == tenant_id,
TenantLocation.location_type == location_type
)
result = await self.session.execute(stmt)
location = result.scalar_one_or_none()
return location
except Exception as e:
logger.error(f"Failed to get location by type: {str(e)}")
raise DatabaseError(f"Failed to get location by type: {str(e)}")
async def update_location(self, location_id: str, location_data: Dict[str, Any]) -> Optional[TenantLocation]:
"""
Update a tenant location
Args:
location_id: UUID of the location to update
location_data: Dictionary containing updated location information
Returns:
Updated TenantLocation object if successful, None if location not found
"""
try:
stmt = (
update(TenantLocation)
.where(TenantLocation.id == location_id)
.values(**location_data)
)
await self.session.execute(stmt)
# Now fetch the updated location
location_stmt = select(TenantLocation).where(TenantLocation.id == location_id)
result = await self.session.execute(location_stmt)
location = result.scalar_one_or_none()
if location:
await self.session.commit()
logger.info(f"Updated tenant location: {location_id}")
return location
else:
await self.session.rollback()
logger.warning(f"Location not found for update: {location_id}")
return None
except Exception as e:
await self.session.rollback()
logger.error(f"Failed to update location: {str(e)}")
raise DatabaseError(f"Failed to update location: {str(e)}")
async def delete_location(self, location_id: str) -> bool:
"""
Delete a tenant location
Args:
location_id: UUID of the location to delete
Returns:
True if deleted successfully, False if location not found
"""
try:
stmt = delete(TenantLocation).where(TenantLocation.id == location_id)
result = await self.session.execute(stmt)
if result.rowcount > 0:
await self.session.commit()
logger.info(f"Deleted tenant location: {location_id}")
return True
else:
await self.session.rollback()
logger.warning(f"Location not found for deletion: {location_id}")
return False
except Exception as e:
await self.session.rollback()
logger.error(f"Failed to delete location: {str(e)}")
raise DatabaseError(f"Failed to delete location: {str(e)}")
async def get_active_locations_by_tenant(self, tenant_id: str) -> List[TenantLocation]:
"""
Get all active locations for a specific tenant
Args:
tenant_id: UUID of the tenant
Returns:
List of active TenantLocation objects
"""
try:
stmt = select(TenantLocation).where(
TenantLocation.tenant_id == tenant_id,
TenantLocation.is_active == True
)
result = await self.session.execute(stmt)
locations = result.scalars().all()
return locations
except Exception as e:
logger.error(f"Failed to get active locations by tenant: {str(e)}")
raise DatabaseError(f"Failed to get active locations by tenant: {str(e)}")
async def get_locations_by_tenant_with_type(self, tenant_id: str, location_types: List[str]) -> List[TenantLocation]:
"""
Get locations for a specific tenant filtered by location types
Args:
tenant_id: UUID of the tenant
location_types: List of location types to filter by
Returns:
List of TenantLocation objects matching the criteria
"""
try:
stmt = select(TenantLocation).where(
TenantLocation.tenant_id == tenant_id,
TenantLocation.location_type.in_(location_types)
)
result = await self.session.execute(stmt)
locations = result.scalars().all()
return locations
except Exception as e:
logger.error(f"Failed to get locations by tenant and type: {str(e)}")
raise DatabaseError(f"Failed to get locations by tenant and type: {str(e)}")

View File

@@ -381,3 +381,188 @@ class TenantRepository(TenantBaseRepository):
async def activate_tenant(self, tenant_id: str) -> Optional[Tenant]:
"""Activate a tenant"""
return await self.activate_record(tenant_id)
async def get_child_tenants(self, parent_tenant_id: str) -> List[Tenant]:
"""Get all child tenants for a parent tenant"""
try:
return await self.get_multi(
filters={"parent_tenant_id": parent_tenant_id, "is_active": True},
order_by="created_at",
order_desc=False
)
except Exception as e:
logger.error("Failed to get child tenants",
parent_tenant_id=parent_tenant_id,
error=str(e))
raise DatabaseError(f"Failed to get child tenants: {str(e)}")
async def get_child_tenant_count(self, parent_tenant_id: str) -> int:
"""Get count of child tenants for a parent tenant"""
try:
child_tenants = await self.get_child_tenants(parent_tenant_id)
return len(child_tenants)
except Exception as e:
logger.error("Failed to get child tenant count",
parent_tenant_id=parent_tenant_id,
error=str(e))
return 0
async def get_user_tenants_with_hierarchy(self, user_id: str) -> List[Dict[str, Any]]:
"""
Get all tenants a user has access to, organized in hierarchy.
Returns parent tenants with their children nested.
"""
try:
# Get all tenants where user is owner or member
query_text = """
SELECT DISTINCT t.*
FROM tenants t
LEFT JOIN tenant_members tm ON t.id = tm.tenant_id
WHERE (t.owner_id = :user_id OR tm.user_id = :user_id)
AND t.is_active = true
ORDER BY t.tenant_type DESC, t.created_at ASC
"""
result = await self.session.execute(text(query_text), {"user_id": user_id})
tenants = []
for row in result.fetchall():
record_dict = dict(row._mapping)
tenant = self.model(**record_dict)
tenants.append(tenant)
# Organize into hierarchy
tenant_hierarchy = []
parent_map = {}
# First pass: collect all parent/standalone tenants
for tenant in tenants:
if tenant.tenant_type in ['parent', 'standalone']:
tenant_dict = {
'id': str(tenant.id),
'name': tenant.name,
'subdomain': tenant.subdomain,
'tenant_type': tenant.tenant_type,
'business_type': tenant.business_type,
'business_model': tenant.business_model,
'city': tenant.city,
'is_active': tenant.is_active,
'children': [] if tenant.tenant_type == 'parent' else None
}
tenant_hierarchy.append(tenant_dict)
parent_map[str(tenant.id)] = tenant_dict
# Second pass: attach children to their parents
for tenant in tenants:
if tenant.tenant_type == 'child' and tenant.parent_tenant_id:
parent_id = str(tenant.parent_tenant_id)
if parent_id in parent_map:
child_dict = {
'id': str(tenant.id),
'name': tenant.name,
'subdomain': tenant.subdomain,
'tenant_type': 'child',
'parent_tenant_id': parent_id,
'city': tenant.city,
'is_active': tenant.is_active
}
parent_map[parent_id]['children'].append(child_dict)
return tenant_hierarchy
except Exception as e:
logger.error("Failed to get user tenants with hierarchy",
user_id=user_id,
error=str(e))
return []
async def get_tenants_by_session_id(self, session_id: str) -> List[Tenant]:
"""
Get tenants associated with a specific demo session using the demo_session_id field.
"""
try:
return await self.get_multi(
filters={
"demo_session_id": session_id,
"is_active": True
},
order_by="created_at",
order_desc=True
)
except Exception as e:
logger.error("Failed to get tenants by session ID",
session_id=session_id,
error=str(e))
raise DatabaseError(f"Failed to get tenants by session ID: {str(e)}")
async def get_professional_demo_tenants(self, session_id: str) -> List[Tenant]:
"""
Get professional demo tenants filtered by session.
Args:
session_id: Required demo session ID to filter tenants
Returns:
List of professional demo tenants for this specific session
"""
try:
filters = {
"business_model": "professional_bakery",
"is_demo": True,
"is_active": True,
"demo_session_id": session_id # Always filter by session
}
return await self.get_multi(
filters=filters,
order_by="created_at",
order_desc=True
)
except Exception as e:
logger.error("Failed to get professional demo tenants",
session_id=session_id,
error=str(e))
raise DatabaseError(f"Failed to get professional demo tenants: {str(e)}")
async def get_enterprise_demo_tenants(self, session_id: str) -> List[Tenant]:
"""
Get enterprise demo tenants (parent and children) filtered by session.
Args:
session_id: Required demo session ID to filter tenants
Returns:
List of enterprise demo tenants (1 parent + 3 children) for this specific session
"""
try:
# Get enterprise demo parent tenants for this session
parent_tenants = await self.get_multi(
filters={
"tenant_type": "parent",
"is_demo": True,
"is_active": True,
"demo_session_id": session_id # Always filter by session
},
order_by="created_at",
order_desc=True
)
# Get child tenants for the enterprise demo session
child_tenants = await self.get_multi(
filters={
"tenant_type": "child",
"is_demo": True,
"is_active": True,
"demo_session_id": session_id # Always filter by session
},
order_by="created_at",
order_desc=True
)
# Combine parent and child tenants
return parent_tenants + child_tenants
except Exception as e:
logger.error("Failed to get enterprise demo tenants",
session_id=session_id,
error=str(e))
raise DatabaseError(f"Failed to get enterprise demo tenants: {str(e)}")

View File

@@ -0,0 +1,89 @@
"""
Tenant Location Schemas
"""
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List, Dict, Any
from datetime import datetime
from uuid import UUID
class TenantLocationBase(BaseModel):
"""Base schema for tenant location"""
name: str = Field(..., min_length=1, max_length=200)
location_type: str = Field(..., pattern=r'^(central_production|retail_outlet|warehouse|store|branch)$')
address: str = Field(..., min_length=10, max_length=500)
city: str = Field(default="Madrid", max_length=100)
postal_code: str = Field(..., min_length=3, max_length=10)
latitude: Optional[float] = Field(None, ge=-90, le=90)
longitude: Optional[float] = Field(None, ge=-180, le=180)
contact_person: Optional[str] = Field(None, max_length=200)
contact_phone: Optional[str] = Field(None, max_length=20)
contact_email: Optional[str] = Field(None, max_length=255)
is_active: bool = True
delivery_windows: Optional[Dict[str, Any]] = None
operational_hours: Optional[Dict[str, Any]] = None
capacity: Optional[int] = Field(None, ge=0)
max_delivery_radius_km: Optional[float] = Field(None, ge=0)
delivery_schedule_config: Optional[Dict[str, Any]] = None
metadata: Optional[Dict[str, Any]] = Field(None)
class TenantLocationCreate(TenantLocationBase):
"""Schema for creating a tenant location"""
tenant_id: str # This will be validated as UUID in the API layer
class TenantLocationUpdate(BaseModel):
"""Schema for updating a tenant location"""
name: Optional[str] = Field(None, min_length=1, max_length=200)
location_type: Optional[str] = Field(None, pattern=r'^(central_production|retail_outlet|warehouse|store|branch)$')
address: Optional[str] = Field(None, min_length=10, max_length=500)
city: Optional[str] = Field(None, max_length=100)
postal_code: Optional[str] = Field(None, min_length=3, max_length=10)
latitude: Optional[float] = Field(None, ge=-90, le=90)
longitude: Optional[float] = Field(None, ge=-180, le=180)
contact_person: Optional[str] = Field(None, max_length=200)
contact_phone: Optional[str] = Field(None, max_length=20)
contact_email: Optional[str] = Field(None, max_length=255)
is_active: Optional[bool] = None
delivery_windows: Optional[Dict[str, Any]] = None
operational_hours: Optional[Dict[str, Any]] = None
capacity: Optional[int] = Field(None, ge=0)
max_delivery_radius_km: Optional[float] = Field(None, ge=0)
delivery_schedule_config: Optional[Dict[str, Any]] = None
metadata: Optional[Dict[str, Any]] = Field(None)
class TenantLocationResponse(TenantLocationBase):
"""Schema for tenant location response"""
id: str
tenant_id: str
created_at: datetime
updated_at: Optional[datetime]
@field_validator('id', 'tenant_id', mode='before')
@classmethod
def convert_uuid_to_string(cls, v):
"""Convert UUID objects to strings for JSON serialization"""
if isinstance(v, UUID):
return str(v)
return v
class Config:
from_attributes = True
populate_by_name = True
class TenantLocationsResponse(BaseModel):
"""Schema for multiple tenant locations response"""
locations: List[TenantLocationResponse]
total: int
class TenantLocationTypeFilter(BaseModel):
"""Schema for filtering locations by type"""
location_types: List[str] = Field(
default=["central_production", "retail_outlet", "warehouse", "store", "branch"],
description="List of location types to include"
)

View File

@@ -63,6 +63,8 @@ class TenantResponse(BaseModel):
subdomain: Optional[str]
business_type: str
business_model: Optional[str]
tenant_type: Optional[str] = "standalone" # standalone, parent, or child
parent_tenant_id: Optional[str] = None # For child tenants
address: str
city: str
postal_code: str
@@ -75,7 +77,7 @@ class TenantResponse(BaseModel):
created_at: datetime
# ✅ FIX: Add custom validator to convert UUID to string
@field_validator('id', 'owner_id', mode='before')
@field_validator('id', 'owner_id', 'parent_tenant_id', mode='before')
@classmethod
def convert_uuid_to_string(cls, v):
"""Convert UUID objects to strings for JSON serialization"""

View File

@@ -314,6 +314,120 @@ class EnhancedTenantService:
error=str(e))
return []
async def get_virtual_tenants_for_session(self, demo_session_id: str, demo_account_type: str) -> List[TenantResponse]:
"""
Get virtual tenants associated with a specific demo session.
This method handles the special demo session access patterns:
- Individual bakery demo user: should have access to professional demo tenant (1 tenant)
- Enterprise demo session: should have access to parent tenant and its children (4 tenants)
Now properly filters by demo_session_id field which is populated during tenant cloning.
"""
try:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
# Query all tenants by demo_session_id (now properly populated during cloning)
virtual_tenants = await self.tenant_repo.get_tenants_by_session_id(demo_session_id)
if not virtual_tenants:
logger.warning(
"No virtual tenants found for demo session - session may not exist or tenants not yet created",
demo_session_id=demo_session_id,
demo_account_type=demo_account_type
)
return []
logger.info(
"Retrieved virtual tenants for demo session",
demo_session_id=demo_session_id,
demo_account_type=demo_account_type,
tenant_count=len(virtual_tenants)
)
return [TenantResponse.from_orm(tenant) for tenant in virtual_tenants]
except Exception as e:
logger.error("Error getting virtual tenants for demo session",
demo_session_id=demo_session_id,
demo_account_type=demo_account_type,
error=str(e))
# Fallback: return empty list instead of all demo tenants
return []
async def get_demo_tenants_by_session_type(self, demo_account_type: str, current_user_id: str) -> List[TenantResponse]:
"""
DEPRECATED: Fallback method for old demo sessions without demo_session_id.
Get demo tenants based on session type rather than user ownership.
This implements the specific requirements:
- Individual bakery demo user: access to professional demo tenant
- Enterprise demo session: access only to enterprise parent tenant and its child
WARNING: This method returns ALL demo tenants of a given type, not session-specific ones.
New code should use get_virtual_tenants_for_session() instead.
"""
logger.warning(
"Using deprecated fallback method - demo_session_id not available",
demo_account_type=demo_account_type,
user_id=current_user_id
)
try:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
if demo_account_type.lower() == 'professional_bakery':
# Individual bakery demo user should have access to professional demo tenant
# Return demo tenants with business_model='professional_bakery' that are demo tenants
tenants = await self.tenant_repo.get_multi(
filters={
"business_model": "professional_bakery",
"is_demo": True,
"is_active": True
}
)
elif demo_account_type.lower() in ['enterprise_chain', 'enterprise_parent']:
# Enterprise demo session should have access to parent tenant and its children
# Return demo tenants with tenant_type in ['parent', 'child'] that are demo tenants
parent_tenants = await self.tenant_repo.get_multi(
filters={
"tenant_type": "parent",
"is_demo": True,
"is_active": True
}
)
child_tenants = await self.tenant_repo.get_multi(
filters={
"tenant_type": "child",
"is_demo": True,
"is_active": True
}
)
tenants = parent_tenants + child_tenants
elif demo_account_type.lower() == 'enterprise_child':
# For child enterprise sessions, return only child demo tenants
tenants = await self.tenant_repo.get_multi(
filters={
"tenant_type": "child",
"is_demo": True,
"is_active": True
}
)
else:
# Default case - return the user's actual owned tenants
tenants = await self.tenant_repo.get_tenants_by_owner(current_user_id)
return [TenantResponse.from_orm(tenant) for tenant in tenants]
except Exception as e:
logger.error("Error getting demo tenants by session type",
demo_account_type=demo_account_type,
user_id=current_user_id,
error=str(e))
# Fallback: return empty list
return []
async def get_active_tenants(self, skip: int = 0, limit: int = 100) -> List[TenantResponse]:
"""Get all active tenants"""

View File

@@ -1,8 +1,8 @@
"""Comprehensive initial schema with all tenant service tables and columns, including coupon tenant_id nullable change
"""Comprehensive unified initial schema with all tenant service tables and columns
Revision ID: 001_unified_initial_schema
Revises:
Create Date: 2025-11-06 14:00:00.000000+00:00
Revises:
Create Date: 2025-11-27 12:00:00.000000+00:00
"""
from typing import Sequence, Union
@@ -56,7 +56,7 @@ def upgrade() -> None:
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)
# Create tenants table
# Create tenants table with all columns including hierarchy fields
op.create_table('tenants',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
@@ -82,7 +82,11 @@ def upgrade() -> None:
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=False, default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
# Enterprise tier hierarchy fields
sa.Column('parent_tenant_id', sa.UUID(), nullable=True),
sa.Column('tenant_type', sa.String(length=50), nullable=False, server_default='standalone'),
sa.Column('hierarchy_path', sa.String(length=500), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('subdomain')
)
@@ -91,6 +95,25 @@ def upgrade() -> None:
op.create_index(op.f('ix_tenants_is_demo'), 'tenants', ['is_demo'], unique=False)
op.create_index(op.f('ix_tenants_is_demo_template'), 'tenants', ['is_demo_template'], unique=False)
op.create_index(op.f('ix_tenants_owner_id'), 'tenants', ['owner_id'], unique=False)
# Hierarchy indexes
op.create_index('ix_tenants_parent_tenant_id', 'tenants', ['parent_tenant_id'])
op.create_index('ix_tenants_tenant_type', 'tenants', ['tenant_type'])
op.create_index('ix_tenants_hierarchy_path', 'tenants', ['hierarchy_path'])
# Add foreign key constraint for hierarchy
op.create_foreign_key(
'fk_tenants_parent_tenant',
'tenants',
'tenants',
['parent_tenant_id'],
['id'],
ondelete='RESTRICT'
)
# Add check constraint to prevent circular hierarchy
op.create_check_constraint(
'check_parent_not_self',
'tenants',
'id != parent_tenant_id'
)
# Create tenant_members table
op.create_table('tenant_members',
@@ -103,13 +126,13 @@ def upgrade() -> None:
sa.Column('invited_by', sa.UUID(), nullable=True),
sa.Column('invited_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('joined_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tenant_members_user_id'), 'tenant_members', ['user_id'], unique=False)
# Create tenant_settings table with current model structure
# Create tenant_settings table with all settings including notification settings
op.create_table('tenant_settings',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
@@ -124,8 +147,29 @@ def upgrade() -> None:
sa.Column('moq_settings', postgresql.JSON(astext_type=sa.Text()), nullable=False),
sa.Column('supplier_selection_settings', postgresql.JSON(astext_type=sa.Text()), nullable=False),
sa.Column('ml_insights_settings', postgresql.JSON(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
sa.Column('notification_settings', postgresql.JSON(astext_type=sa.Text()), nullable=False,
server_default=sa.text("""'{
"whatsapp_enabled": false,
"whatsapp_phone_number_id": "",
"whatsapp_access_token": "",
"whatsapp_business_account_id": "",
"whatsapp_api_version": "v18.0",
"whatsapp_default_language": "es",
"email_enabled": true,
"email_from_address": "",
"email_from_name": "",
"email_reply_to": "",
"enable_po_notifications": true,
"enable_inventory_alerts": true,
"enable_production_alerts": true,
"enable_forecast_alerts": true,
"po_notification_channels": ["email"],
"inventory_alert_channels": ["email"],
"production_alert_channels": ["email"],
"forecast_alert_channels": ["email"]
}'::jsonb""")),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tenant_id')
@@ -149,8 +193,8 @@ def upgrade() -> None:
sa.Column('max_locations', sa.Integer(), nullable=True),
sa.Column('max_products', sa.Integer(), nullable=True),
sa.Column('features', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
@@ -167,7 +211,7 @@ def upgrade() -> None:
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=False),
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('active', sa.Boolean(), nullable=False, default=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('extra_data', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
@@ -183,7 +227,7 @@ def upgrade() -> None:
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.String(length=255), nullable=False),
sa.Column('coupon_code', sa.String(length=50), nullable=False),
sa.Column('redeemed_at', sa.DateTime(timezone=True), nullable=False, default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('redeemed_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('discount_applied', sa.JSON(), nullable=False),
sa.Column('extra_data', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['coupon_code'], ['coupons.code'], ),
@@ -215,8 +259,8 @@ def upgrade() -> None:
sa.Column('recurrence_pattern', sa.String(200), nullable=True),
sa.Column('actual_impact_multiplier', sa.Float, nullable=True),
sa.Column('actual_sales_increase_percent', sa.Float, nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
sa.Column('created_by', sa.String(255), nullable=True),
sa.Column('notes', sa.Text, nullable=True),
)
@@ -234,8 +278,8 @@ def upgrade() -> None:
sa.Column('default_affected_categories', sa.String(500), nullable=True),
sa.Column('recurrence_pattern', sa.String(200), nullable=False),
sa.Column('is_active', sa.Boolean, default=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
)
# Create indexes for better query performance on events
@@ -243,8 +287,44 @@ def upgrade() -> None:
op.create_index('ix_events_type_date', 'events', ['event_type', 'event_date'])
op.create_index('ix_event_templates_tenant_active', 'event_templates', ['tenant_id', 'is_active'])
# Create tenant_locations table (from 004 migration)
op.create_table('tenant_locations',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('location_type', sa.String(length=50), nullable=False), # central_production, retail_outlet
sa.Column('address', sa.Text(), nullable=False),
sa.Column('city', sa.String(length=100), nullable=False, server_default='Madrid'),
sa.Column('postal_code', sa.String(length=10), nullable=False),
sa.Column('latitude', sa.Float(), nullable=True),
sa.Column('longitude', sa.Float(), nullable=True),
sa.Column('delivery_windows', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('capacity', sa.Integer(), nullable=True),
sa.Column('max_delivery_radius_km', sa.Float(), nullable=True, default=50.0),
sa.Column('operational_hours', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
sa.Column('contact_person', sa.String(length=200), nullable=True),
sa.Column('contact_phone', sa.String(length=20), nullable=True),
sa.Column('contact_email', sa.String(length=255), nullable=True),
sa.Column('delivery_schedule_config', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('metadata_', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_tenant_locations_tenant_id', 'tenant_locations', ['tenant_id'])
op.create_index('ix_tenant_locations_location_type', 'tenant_locations', ['location_type'])
op.create_index('ix_tenant_locations_coordinates', 'tenant_locations', ['latitude', 'longitude'])
def downgrade() -> None:
# Drop tenant_locations table
op.drop_index('ix_tenant_locations_coordinates')
op.drop_index('ix_tenant_locations_location_type')
op.drop_index('ix_tenant_locations_tenant_id')
op.drop_table('tenant_locations')
# Drop indexes for events
op.drop_index('ix_event_templates_tenant_active', table_name='event_templates')
op.drop_index('ix_events_type_date', table_name='events')
@@ -275,6 +355,12 @@ def downgrade() -> None:
op.drop_index(op.f('ix_tenant_members_user_id'), table_name='tenant_members')
op.drop_table('tenant_members')
# Drop tenant hierarchy constraints and indexes
op.drop_constraint('check_parent_not_self', 'tenants', type_='check')
op.drop_constraint('fk_tenants_parent_tenant', 'tenants', type_='foreignkey')
op.drop_index('ix_tenants_hierarchy_path', table_name='tenants')
op.drop_index('ix_tenants_tenant_type', table_name='tenants')
op.drop_index('ix_tenants_parent_tenant_id', table_name='tenants')
op.drop_index(op.f('ix_tenants_owner_id'), table_name='tenants')
op.drop_index(op.f('ix_tenants_is_demo_template'), table_name='tenants')
op.drop_index(op.f('ix_tenants_is_demo'), table_name='tenants')

View File

@@ -1,57 +0,0 @@
"""Add notification_settings column to tenant_settings table
Revision ID: 002_add_notification_settings
Revises: 001_unified_initial_schema
Create Date: 2025-11-13 15:00:00.000000+00: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 = '002_add_notification_settings'
down_revision: Union[str, None] = '001_unified_initial_schema'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add notification_settings column with default values"""
# Add column with default value as JSONB
op.add_column(
'tenant_settings',
sa.Column(
'notification_settings',
postgresql.JSON(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("""'{
"whatsapp_enabled": false,
"whatsapp_phone_number_id": "",
"whatsapp_access_token": "",
"whatsapp_business_account_id": "",
"whatsapp_api_version": "v18.0",
"whatsapp_default_language": "es",
"email_enabled": true,
"email_from_address": "",
"email_from_name": "",
"email_reply_to": "",
"enable_po_notifications": true,
"enable_inventory_alerts": true,
"enable_production_alerts": true,
"enable_forecast_alerts": true,
"po_notification_channels": ["email"],
"inventory_alert_channels": ["email"],
"production_alert_channels": ["email"],
"forecast_alert_channels": ["email"]
}'::jsonb""")
)
)
def downgrade() -> None:
"""Remove notification_settings column"""
op.drop_column('tenant_settings', 'notification_settings')

View File

@@ -44,13 +44,39 @@ structlog.configure(
logger = structlog.get_logger()
# Fixed Demo Tenant IDs (must match tenant service)
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7")
DEMO_TENANT_PROFESSIONAL = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
DEMO_TENANT_ENTERPRISE_CHAIN = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8")
DEMO_TENANT_CHILD_1 = uuid.UUID("d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9")
DEMO_TENANT_CHILD_2 = uuid.UUID("e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0")
DEMO_TENANT_CHILD_3 = uuid.UUID("f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1")
SUBSCRIPTIONS_DATA = [
{
"tenant_id": DEMO_TENANT_SAN_PABLO,
"tenant_id": DEMO_TENANT_PROFESSIONAL,
"plan": "professional",
"status": "active",
"monthly_price": 0.0, # Free for demo
"max_users": -1, # Unlimited users for demo
"max_locations": 3, # Professional tier limit (will be upgraded for demo sessions)
"max_products": -1, # Unlimited products for demo
"features": {
"inventory_management": "advanced",
"demand_prediction": "advanced",
"production_reports": "advanced",
"analytics": "advanced",
"support": "priority",
"ai_model_configuration": "advanced",
"multi_location": True,
"custom_integrations": True,
"api_access": True,
"dedicated_support": False
},
"trial_ends_at": None,
"next_billing_date": datetime.now(timezone.utc) + timedelta(days=90), # 90 days for demo
},
{
"tenant_id": DEMO_TENANT_ENTERPRISE_CHAIN,
"plan": "enterprise",
"status": "active",
"monthly_price": 0.0, # Free for demo
@@ -70,15 +96,61 @@ SUBSCRIPTIONS_DATA = [
"dedicated_support": True
},
"trial_ends_at": None,
"next_billing_date": datetime.now(timezone.utc) + timedelta(days=90), # 90 days for demo
"next_billing_date": datetime.now(timezone.utc) + timedelta(days=90),
},
{
"tenant_id": DEMO_TENANT_LA_ESPIGA,
"plan": "enterprise",
"tenant_id": DEMO_TENANT_CHILD_1,
"plan": "enterprise", # Child inherits parent's enterprise plan
"status": "active",
"monthly_price": 0.0, # Free for demo
"max_users": -1, # Unlimited users
"max_locations": -1, # Unlimited locations
"max_locations": 1, # Single location
"max_products": -1, # Unlimited products
"features": {
"inventory_management": "advanced",
"demand_prediction": "advanced",
"production_reports": "advanced",
"analytics": "predictive",
"support": "priority",
"ai_model_configuration": "advanced",
"multi_location": True,
"custom_integrations": True,
"api_access": True,
"dedicated_support": True
},
"trial_ends_at": None,
"next_billing_date": datetime.now(timezone.utc) + timedelta(days=90),
},
{
"tenant_id": DEMO_TENANT_CHILD_2,
"plan": "enterprise", # Child inherits parent's enterprise plan
"status": "active",
"monthly_price": 0.0, # Free for demo
"max_users": -1, # Unlimited users
"max_locations": 1, # Single location
"max_products": -1, # Unlimited products
"features": {
"inventory_management": "advanced",
"demand_prediction": "advanced",
"production_reports": "advanced",
"analytics": "predictive",
"support": "priority",
"ai_model_configuration": "advanced",
"multi_location": True,
"custom_integrations": True,
"api_access": True,
"dedicated_support": True
},
"trial_ends_at": None,
"next_billing_date": datetime.now(timezone.utc) + timedelta(days=90),
},
{
"tenant_id": DEMO_TENANT_CHILD_3,
"plan": "enterprise", # Child inherits parent's enterprise plan
"status": "active",
"monthly_price": 0.0, # Free for demo
"max_users": -1, # Unlimited users
"max_locations": 1, # Single location
"max_products": -1, # Unlimited products
"features": {
"inventory_management": "advanced",

View File

@@ -46,8 +46,7 @@ structlog.configure(
logger = structlog.get_logger()
# Fixed Demo Tenant IDs (must match seed_demo_tenants.py)
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7")
DEMO_TENANT_PROFESSIONAL = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
# Owner user IDs (must match seed_demo_users.py)
OWNER_SAN_PABLO = uuid.UUID("c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6") # María García López
@@ -80,100 +79,100 @@ def get_permissions_for_role(role: str) -> str:
TENANT_MEMBERS_DATA = [
# San Pablo Members (Panadería Individual)
{
"tenant_id": DEMO_TENANT_SAN_PABLO,
"tenant_id": DEMO_TENANT_PROFESSIONAL,
"user_id": uuid.UUID("c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6"), # María García López
"role": "owner",
"invited_by": uuid.UUID("c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6"),
"is_owner": True
},
{
"tenant_id": DEMO_TENANT_SAN_PABLO,
"tenant_id": DEMO_TENANT_PROFESSIONAL,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000001"), # Juan Pérez Moreno - Panadero Senior
"role": "baker",
"invited_by": OWNER_SAN_PABLO,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_SAN_PABLO,
"tenant_id": DEMO_TENANT_PROFESSIONAL,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000002"), # Ana Rodríguez Sánchez - Responsable de Ventas
"role": "sales",
"invited_by": OWNER_SAN_PABLO,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_SAN_PABLO,
"tenant_id": DEMO_TENANT_PROFESSIONAL,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000003"), # Luis Fernández García - Inspector de Calidad
"role": "quality_control",
"invited_by": OWNER_SAN_PABLO,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_SAN_PABLO,
"tenant_id": DEMO_TENANT_PROFESSIONAL,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000004"), # Carmen López Martínez - Administradora
"role": "admin",
"invited_by": OWNER_SAN_PABLO,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_SAN_PABLO,
"tenant_id": DEMO_TENANT_PROFESSIONAL,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000005"), # Pedro González Torres - Encargado de Almacén
"role": "warehouse",
"invited_by": OWNER_SAN_PABLO,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_SAN_PABLO,
"tenant_id": DEMO_TENANT_PROFESSIONAL,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000006"), # Isabel Romero Díaz - Jefa de Producción
"role": "production_manager",
"invited_by": OWNER_SAN_PABLO,
"is_owner": False
},
# La Espiga Members (Obrador Central)
# La Espiga Members (Professional Bakery - merged from San Pablo + La Espiga)
{
"tenant_id": DEMO_TENANT_LA_ESPIGA,
"tenant_id": DEMO_TENANT_PROFESSIONAL,
"user_id": uuid.UUID("d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7"), # Carlos Martínez Ruiz
"role": "owner",
"invited_by": uuid.UUID("d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7"),
"is_owner": True
},
{
"tenant_id": DEMO_TENANT_LA_ESPIGA,
"tenant_id": DEMO_TENANT_PROFESSIONAL,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000011"), # Roberto Sánchez Vargas - Director de Producción
"role": "production_manager",
"invited_by": OWNER_LA_ESPIGA,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_LA_ESPIGA,
"tenant_id": DEMO_TENANT_PROFESSIONAL,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000012"), # Sofía Jiménez Ortega - Responsable de Control de Calidad
"role": "quality_control",
"invited_by": OWNER_LA_ESPIGA,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_LA_ESPIGA,
"tenant_id": DEMO_TENANT_PROFESSIONAL,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000013"), # Miguel Herrera Castro - Coordinador de Logística
"role": "logistics",
"invited_by": OWNER_LA_ESPIGA,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_LA_ESPIGA,
"tenant_id": DEMO_TENANT_PROFESSIONAL,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000014"), # Elena Morales Ruiz - Directora Comercial
"role": "sales",
"invited_by": OWNER_LA_ESPIGA,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_LA_ESPIGA,
"tenant_id": DEMO_TENANT_PROFESSIONAL,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000015"), # Javier Navarro Prieto - Responsable de Compras
"role": "procurement",
"invited_by": OWNER_LA_ESPIGA,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_LA_ESPIGA,
"tenant_id": DEMO_TENANT_PROFESSIONAL,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000016"), # Laura Delgado Santos - Técnica de Mantenimiento
"role": "maintenance",
"invited_by": OWNER_LA_ESPIGA,
@@ -198,7 +197,8 @@ async def seed_tenant_members(db: AsyncSession) -> dict:
skipped_count = 0
# First, verify that template tenants exist
for tenant_id in [DEMO_TENANT_SAN_PABLO, DEMO_TENANT_LA_ESPIGA]:
for member_data in TENANT_MEMBERS_DATA:
tenant_id = member_data["tenant_id"]
result = await db.execute(
select(Tenant).where(Tenant.id == tenant_id)
)
@@ -206,8 +206,8 @@ async def seed_tenant_members(db: AsyncSession) -> dict:
if not tenant:
logger.error(
f"Template tenant not found: {tenant_id}",
tenant_id=str(tenant_id)
"Template tenant not found: %s",
str(tenant_id)
)
logger.error("Please run seed_demo_tenants.py first!")
return {
@@ -219,10 +219,12 @@ async def seed_tenant_members(db: AsyncSession) -> dict:
}
logger.info(
f"✓ Template tenant found: {tenant.name}",
"✓ Template tenant found: %s",
tenant.name,
tenant_id=str(tenant_id),
tenant_name=tenant.name
)
break # Only need to verify one tenant exists, then proceed with member creation
# Now seed the tenant members
for member_data in TENANT_MEMBERS_DATA:

View File

@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*-
"""
Demo Tenant Seeding Script for Tenant Service
Creates the two demo template tenants: San Pablo and La Espiga
Creates demo template tenants: Professional Bakery and Enterprise Chain
This script runs as a Kubernetes init job inside the tenant-service container.
It creates template tenants that will be cloned for demo sessions.
@@ -46,75 +46,193 @@ structlog.configure(
logger = structlog.get_logger()
# Fixed Demo Tenant IDs (these are the template tenants that will be cloned)
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7")
# Professional demo (merged from San Pablo + La Espiga)
DEMO_TENANT_PROFESSIONAL = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
# Enterprise chain demo (parent + 3 children)
DEMO_TENANT_ENTERPRISE_CHAIN = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8")
DEMO_TENANT_CHILD_1 = uuid.UUID("d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9")
DEMO_TENANT_CHILD_2 = uuid.UUID("e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0")
DEMO_TENANT_CHILD_3 = uuid.UUID("f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1")
TENANTS_DATA = [
{
"id": DEMO_TENANT_SAN_PABLO,
"name": "Panadería San Pablo",
"business_model": "san_pablo",
"id": DEMO_TENANT_PROFESSIONAL,
"name": "Panadería Artesana Madrid",
"business_model": "individual_bakery",
"is_demo": False, # Template tenants are not marked as demo
"is_demo_template": True, # They are templates for cloning
"is_active": True,
# Required fields
"address": "Calle Mayor 45",
"address": "Calle de Fuencarral, 85",
"city": "Madrid",
"postal_code": "28013",
"owner_id": uuid.UUID("c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6"), # María García López (San Pablo owner)
"postal_code": "28004",
"owner_id": uuid.UUID("c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6"), # Professional bakery owner
"metadata_": {
"type": "traditional_bakery",
"description": "Panadería tradicional familiar con venta al público",
"type": "professional_bakery",
"description": "Modern professional bakery combining artisan quality with operational efficiency",
"characteristics": [
"Producción en lotes pequeños adaptados a la demanda diaria",
"Venta directa al consumidor final (walk-in customers)",
"Ciclos de producción diarios comenzando de madrugada",
"Variedad limitada de productos clásicos",
"Proveedores locales de confianza",
"Atención personalizada al cliente",
"Ubicación en zona urbana residencial"
"Local artisan production with modern equipment",
"Omnichannel sales: retail + online + B2B catering",
"AI-driven demand forecasting and inventory optimization",
"Professional recipes and standardized processes",
"Strong local supplier relationships",
"Digital POS with customer tracking",
"Production planning with waste minimization"
],
"location_type": "urban",
"size": "small",
"employees": 8,
"size": "medium",
"employees": 12,
"opening_hours": "07:00-21:00",
"production_shifts": 1,
"target_market": "local_consumers"
"target_market": "b2c_and_local_b2b",
"production_capacity_kg_day": 300,
"sales_channels": ["retail", "online", "catering"]
}
},
{
"id": DEMO_TENANT_LA_ESPIGA,
"name": "Panadería La Espiga - Obrador Central",
"business_model": "la_espiga",
"id": DEMO_TENANT_ENTERPRISE_CHAIN,
"name": "Panadería Central - Obrador Madrid",
"business_model": "enterprise_chain",
"is_demo": False,
"is_demo_template": True,
"is_active": True,
"tenant_type": "parent", # Parent tenant for enterprise chain
# Required fields
"address": "Polígono Industrial de Vicálvaro, Calle 15, Nave 8",
"city": "Madrid",
"postal_code": "28052",
"latitude": 40.3954,
"longitude": -3.6121,
"owner_id": uuid.UUID("e3f4a5b6-c7d8-49e0-f1a2-b3c4d5e6f7a8"), # Enterprise Chain owner
"metadata_": {
"type": "enterprise_chain",
"description": "Central production facility serving retail network across Spain",
"characteristics": [
"Central production facility with distributed retail network",
"Multiple retail outlets across major Spanish cities",
"Centralized planning and inventory management",
"Standardized processes across all locations",
"Shared procurement and supplier relationships",
"Cross-location inventory optimization with internal transfers",
"Corporate-level business intelligence and reporting",
"VRP-optimized distribution logistics"
],
"location_type": "industrial",
"size": "large",
"employees": 45,
"opening_hours": "24/7",
"production_shifts": 2,
"retail_outlets_count": 3,
"target_market": "chain_retail",
"production_capacity_kg_day": 3000,
"distribution_range_km": 400
}
},
{
"id": DEMO_TENANT_CHILD_1,
"name": "Panadería Central - Madrid Centro",
"business_model": "retail_outlet",
"is_demo": False,
"is_demo_template": True,
"is_active": True,
# Required fields
"address": "Polígono Industrial Las Rozas, Nave 12",
"city": "Las Rozas de Madrid",
"postal_code": "28232",
"owner_id": uuid.UUID("d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7"), # Carlos Martínez Ruiz (La Espiga owner)
"address": "Calle Mayor, 45",
"city": "Madrid",
"postal_code": "28013",
"latitude": 40.4168,
"longitude": -3.7038,
"owner_id": uuid.UUID("e3f4a5b6-c7d8-49e0-f1a2-b3c4d5e6f7a8"), # Same owner as parent enterprise
"parent_tenant_id": DEMO_TENANT_ENTERPRISE_CHAIN, # Link to parent
"tenant_type": "child",
"metadata_": {
"type": "central_workshop",
"description": "Obrador central con distribución mayorista B2B",
"type": "retail_outlet",
"description": "Retail outlet in Madrid city center",
"characteristics": [
"Producción industrial en lotes grandes",
"Distribución a clientes mayoristas (hoteles, restaurantes, supermercados)",
"Operación 24/7 con múltiples turnos de producción",
"Amplia variedad de productos estandarizados",
"Proveedores regionales con contratos de volumen",
"Logística de distribución optimizada",
"Ubicación en polígono industrial"
"Consumer-facing retail location in high-traffic area",
"Tri-weekly delivery from central production",
"Standardized product offering from central catalog",
"Brand-consistent customer experience",
"Part of enterprise network with internal transfer capability"
],
"location_type": "industrial",
"size": "large",
"employees": 25,
"opening_hours": "24/7",
"production_shifts": 3,
"distribution_radius_km": 50,
"target_market": "b2b_wholesale",
"production_capacity_kg_day": 2000
"location_type": "retail",
"size": "medium",
"employees": 8,
"opening_hours": "07:00-21:00",
"target_market": "local_consumers",
"foot_traffic": "high",
"zone": "Centro"
}
},
{
"id": DEMO_TENANT_CHILD_2,
"name": "Panadería Central - Barcelona Gràcia",
"business_model": "retail_outlet",
"is_demo": False,
"is_demo_template": True,
"is_active": True,
# Required fields
"address": "Carrer de Verdi, 32",
"city": "Barcelona",
"postal_code": "08012",
"latitude": 41.4036,
"longitude": 2.1561,
"owner_id": uuid.UUID("e3f4a5b6-c7d8-49e0-f1a2-b3c4d5e6f7a8"), # Same owner as parent enterprise
"parent_tenant_id": DEMO_TENANT_ENTERPRISE_CHAIN, # Link to parent
"tenant_type": "child",
"metadata_": {
"type": "retail_outlet",
"description": "Retail outlet in Barcelona Gràcia neighborhood",
"characteristics": [
"Consumer-facing retail location in trendy neighborhood",
"Tri-weekly delivery from central production",
"Standardized product offering from central catalog",
"Brand-consistent customer experience",
"Part of enterprise network with internal transfer capability"
],
"location_type": "retail",
"size": "medium",
"employees": 7,
"opening_hours": "07:00-21:30",
"target_market": "local_consumers",
"foot_traffic": "medium_high",
"zone": "Gràcia"
}
},
{
"id": DEMO_TENANT_CHILD_3,
"name": "Panadería Central - Valencia Ruzafa",
"business_model": "retail_outlet",
"is_demo": False,
"is_demo_template": True,
"is_active": True,
# Required fields
"address": "Carrer de Sueca, 51",
"city": "Valencia",
"postal_code": "46006",
"latitude": 39.4623,
"longitude": -0.3645,
"owner_id": uuid.UUID("e3f4a5b6-c7d8-49e0-f1a2-b3c4d5e6f7a8"), # Same owner as parent enterprise
"parent_tenant_id": DEMO_TENANT_ENTERPRISE_CHAIN, # Link to parent
"tenant_type": "child",
"metadata_": {
"type": "retail_outlet",
"description": "Retail outlet in Valencia Ruzafa district",
"characteristics": [
"Consumer-facing retail location in vibrant district",
"Tri-weekly delivery from central production",
"Standardized product offering from central catalog",
"Brand-consistent customer experience",
"Part of enterprise network with internal transfer capability"
],
"location_type": "retail",
"size": "medium",
"employees": 6,
"opening_hours": "06:30-21:00",
"target_market": "local_consumers",
"foot_traffic": "medium",
"zone": "Ruzafa"
}
}
]
@@ -174,7 +292,7 @@ async def seed_tenants(db: AsyncSession) -> dict:
# Flush to get tenant IDs before creating subscriptions
await db.flush()
# Create demo subscriptions for all tenants (enterprise tier for full demo access)
# Create demo subscriptions for all tenants with proper tier assignments
from app.models.tenants import Subscription
# 'select' is already imported at the top of the file, so no need to import locally
@@ -188,7 +306,7 @@ async def seed_tenants(db: AsyncSession) -> dict:
)
existing_subscription = result.scalars().first()
except Exception as e:
# If there's a column error (like missing cancellation_effective_date),
# If there's a column error (like missing cancellation_effective_date),
# we need to ensure migrations are applied first
if "does not exist" in str(e):
logger.error("Database schema does not match model. Ensure migrations are applied first.")
@@ -197,28 +315,183 @@ async def seed_tenants(db: AsyncSession) -> dict:
raise # Re-raise if it's a different error
if not existing_subscription:
# Determine subscription tier based on tenant type
if tenant_id == DEMO_TENANT_PROFESSIONAL:
plan = "professional"
max_locations = 3
elif tenant_id in [DEMO_TENANT_ENTERPRISE_CHAIN, DEMO_TENANT_CHILD_1,
DEMO_TENANT_CHILD_2, DEMO_TENANT_CHILD_3]:
plan = "enterprise"
max_locations = -1 # Unlimited
else:
plan = "starter"
max_locations = 1
logger.info(
"Creating demo subscription for tenant",
tenant_id=str(tenant_id),
plan="enterprise"
plan=plan
)
subscription = Subscription(
tenant_id=tenant_id,
plan="enterprise", # Demo templates get full access
plan=plan,
status="active",
monthly_price=0.0, # Free for demo
billing_cycle="monthly",
max_users=-1, # Unlimited
max_locations=-1,
max_products=-1,
max_users=-1, # Unlimited for demo
max_locations=max_locations,
max_products=-1, # Unlimited for demo
features={}
)
db.add(subscription)
# Commit all changes
# Commit the tenants and subscriptions first
await db.commit()
# Create TenantLocation records for enterprise template tenants
from app.models.tenant_location import TenantLocation
logger.info("Creating TenantLocation records for enterprise template tenants")
# After committing tenants and subscriptions, create location records
# Parent location - Central Production
parent_location = TenantLocation(
id=uuid.uuid4(),
tenant_id=DEMO_TENANT_ENTERPRISE_CHAIN,
name="Obrador Madrid - Central Production",
location_type="central_production",
address="Polígono Industrial de Vicálvaro, Calle 15, Nave 8",
city="Madrid",
postal_code="28052",
latitude=40.3954,
longitude=-3.6121,
capacity=3000, # kg/day
operational_hours={
"monday": "00:00-23:59",
"tuesday": "00:00-23:59",
"wednesday": "00:00-23:59",
"thursday": "00:00-23:59",
"friday": "00:00-23:59",
"saturday": "00:00-23:59",
"sunday": "00:00-23:59"
}, # 24/7
delivery_schedule_config={
"delivery_days": ["monday", "wednesday", "friday"],
"time_window": "07:00-10:00"
},
is_active=True,
metadata_={"type": "production_facility", "zone": "industrial", "size": "large"}
)
db.add(parent_location)
# Child 1 location - Madrid Centro
child1_location = TenantLocation(
id=uuid.uuid4(),
tenant_id=DEMO_TENANT_CHILD_1,
name="Madrid Centro - Retail Outlet",
location_type="retail_outlet",
address="Calle Mayor, 45",
city="Madrid",
postal_code="28013",
latitude=40.4168,
longitude=-3.7038,
delivery_windows={
"monday": "07:00-10:00",
"wednesday": "07:00-10:00",
"friday": "07:00-10:00"
},
operational_hours={
"monday": "07:00-21:00",
"tuesday": "07:00-21:00",
"wednesday": "07:00-21:00",
"thursday": "07:00-21:00",
"friday": "07:00-21:00",
"saturday": "08:00-21:00",
"sunday": "09:00-21:00"
},
delivery_schedule_config={
"delivery_days": ["monday", "wednesday", "friday"],
"time_window": "07:00-10:00"
},
is_active=True,
metadata_={"type": "retail_outlet", "zone": "center", "size": "medium", "foot_traffic": "high"}
)
db.add(child1_location)
# Child 2 location - Barcelona Gràcia
child2_location = TenantLocation(
id=uuid.uuid4(),
tenant_id=DEMO_TENANT_CHILD_2,
name="Barcelona Gràcia - Retail Outlet",
location_type="retail_outlet",
address="Carrer de Verdi, 32",
city="Barcelona",
postal_code="08012",
latitude=41.4036,
longitude=2.1561,
delivery_windows={
"monday": "07:00-10:00",
"wednesday": "07:00-10:00",
"friday": "07:00-10:00"
},
operational_hours={
"monday": "07:00-21:30",
"tuesday": "07:00-21:30",
"wednesday": "07:00-21:30",
"thursday": "07:00-21:30",
"friday": "07:00-21:30",
"saturday": "08:00-21:30",
"sunday": "09:00-21:00"
},
delivery_schedule_config={
"delivery_days": ["monday", "wednesday", "friday"],
"time_window": "07:00-10:00"
},
is_active=True,
metadata_={"type": "retail_outlet", "zone": "gracia", "size": "medium", "foot_traffic": "medium_high"}
)
db.add(child2_location)
# Child 3 location - Valencia Ruzafa
child3_location = TenantLocation(
id=uuid.uuid4(),
tenant_id=DEMO_TENANT_CHILD_3,
name="Valencia Ruzafa - Retail Outlet",
location_type="retail_outlet",
address="Carrer de Sueca, 51",
city="Valencia",
postal_code="46006",
latitude=39.4623,
longitude=-0.3645,
delivery_windows={
"monday": "07:00-10:00",
"wednesday": "07:00-10:00",
"friday": "07:00-10:00"
},
operational_hours={
"monday": "06:30-21:00",
"tuesday": "06:30-21:00",
"wednesday": "06:30-21:00",
"thursday": "06:30-21:00",
"friday": "06:30-21:00",
"saturday": "07:00-21:00",
"sunday": "08:00-21:00"
},
delivery_schedule_config={
"delivery_days": ["monday", "wednesday", "friday"],
"time_window": "07:00-10:00"
},
is_active=True,
metadata_={"type": "retail_outlet", "zone": "ruzafe", "size": "medium", "foot_traffic": "medium"}
)
db.add(child3_location)
# Commit the location records
await db.commit()
logger.info("Created 4 TenantLocation records for enterprise templates")
logger.info("=" * 80)
logger.info(
"✅ Demo Tenant Seeding Completed",