New enterprise feature
This commit is contained in:
@@ -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
|
||||
|
||||
359
services/tenant/app/api/enterprise_upgrade.py
Normal file
359
services/tenant/app/api/enterprise_upgrade.py
Normal 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)}")
|
||||
628
services/tenant/app/api/tenant_locations.py
Normal file
628
services/tenant/app/api/tenant_locations.py
Normal 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"
|
||||
)
|
||||
@@ -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])
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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",
|
||||
|
||||
59
services/tenant/app/models/tenant_location.py
Normal file
59
services/tenant/app/models/tenant_location.py
Normal 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})>"
|
||||
@@ -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
|
||||
|
||||
|
||||
218
services/tenant/app/repositories/tenant_location_repository.py
Normal file
218
services/tenant/app/repositories/tenant_location_repository.py
Normal 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)}")
|
||||
@@ -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)}")
|
||||
|
||||
89
services/tenant/app/schemas/tenant_locations.py
Normal file
89
services/tenant/app/schemas/tenant_locations.py
Normal 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"
|
||||
)
|
||||
@@ -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"""
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user