Add improvements

This commit is contained in:
Urtzi Alfaro
2026-01-12 14:24:14 +01:00
parent 6037faaf8c
commit 230bbe6a19
61 changed files with 1668 additions and 894 deletions

View File

@@ -84,13 +84,6 @@ def parse_date_field(
return None
def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
"""Verify internal API key for service-to-service communication"""
if x_internal_api_key != settings.INTERNAL_API_KEY:
logger.warning("Unauthorized internal API access attempted")
raise HTTPException(status_code=403, detail="Invalid internal API key")
return True
@router.post("/clone")
async def clone_demo_data(
@@ -98,8 +91,7 @@ async def clone_demo_data(
virtual_tenant_id: str,
demo_account_type: str,
session_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
db: AsyncSession = Depends(get_db)
):
"""
Clone tenant service data for a virtual demo tenant
@@ -549,8 +541,7 @@ async def clone_demo_data(
@router.post("/create-child")
async def create_child_outlet(
request: dict,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
db: AsyncSession = Depends(get_db)
):
"""
Create a child outlet tenant for enterprise demos
@@ -820,7 +811,7 @@ async def create_child_outlet(
@router.get("/clone/health")
async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
async def clone_health_check():
"""
Health check for internal cloning endpoint
Used by orchestrator to verify service availability

View File

@@ -41,25 +41,21 @@ async def get_onboarding_status(
suppliers_url = os.getenv("SUPPLIERS_SERVICE_URL", "http://suppliers-service:8000")
recipes_url = os.getenv("RECIPES_SERVICE_URL", "http://recipes-service:8000")
internal_api_key = settings.INTERNAL_API_KEY
# Fetch counts from all services in parallel
async with httpx.AsyncClient(timeout=10.0) as client:
results = await asyncio.gather(
client.get(
f"{inventory_url}/internal/count",
params={"tenant_id": tenant_id},
headers={"X-Internal-API-Key": internal_api_key}
params={"tenant_id": tenant_id}
),
client.get(
f"{suppliers_url}/internal/count",
params={"tenant_id": tenant_id},
headers={"X-Internal-API-Key": internal_api_key}
params={"tenant_id": tenant_id}
),
client.get(
f"{recipes_url}/internal/count",
params={"tenant_id": tenant_id},
headers={"X-Internal-API-Key": internal_api_key}
params={"tenant_id": tenant_id}
),
return_exceptions=True
)

View File

@@ -0,0 +1,127 @@
"""
Startup seeder for tenant service.
Seeds initial data (like pilot coupons) on service startup.
All operations are idempotent - safe to run multiple times.
"""
import os
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.coupon import CouponModel
logger = structlog.get_logger()
async def ensure_pilot_coupon(session: AsyncSession) -> Optional[CouponModel]:
"""
Ensure the PILOT2025 coupon exists in the database.
This coupon provides 3 months (90 days) free trial extension
for the first 20 pilot customers.
This function is idempotent - it will not create duplicates.
Args:
session: Database session
Returns:
The coupon model (existing or newly created), or None if disabled
"""
# Check if pilot mode is enabled via environment variable
pilot_mode_enabled = os.getenv("VITE_PILOT_MODE_ENABLED", "true").lower() == "true"
if not pilot_mode_enabled:
logger.info("Pilot mode is disabled, skipping coupon seeding")
return None
coupon_code = os.getenv("VITE_PILOT_COUPON_CODE", "PILOT2025")
trial_months = int(os.getenv("VITE_PILOT_TRIAL_MONTHS", "3"))
max_redemptions = int(os.getenv("PILOT_MAX_REDEMPTIONS", "20"))
# Check if coupon already exists
result = await session.execute(
select(CouponModel).where(CouponModel.code == coupon_code)
)
existing_coupon = result.scalars().first()
if existing_coupon:
logger.info(
"Pilot coupon already exists",
code=coupon_code,
current_redemptions=existing_coupon.current_redemptions,
max_redemptions=existing_coupon.max_redemptions,
active=existing_coupon.active
)
return existing_coupon
# Create new coupon
now = datetime.now(timezone.utc)
valid_until = now + timedelta(days=180) # Valid for 6 months
trial_days = trial_months * 30 # Approximate days
coupon = CouponModel(
id=uuid.uuid4(),
code=coupon_code,
discount_type="trial_extension",
discount_value=trial_days,
max_redemptions=max_redemptions,
current_redemptions=0,
valid_from=now,
valid_until=valid_until,
active=True,
created_at=now,
extra_data={
"program": "pilot_launch_2025",
"description": f"Programa piloto - {trial_months} meses gratis para los primeros {max_redemptions} clientes",
"terms": "Válido para nuevos registros únicamente. Un cupón por cliente."
}
)
session.add(coupon)
await session.commit()
await session.refresh(coupon)
logger.info(
"Pilot coupon created successfully",
code=coupon_code,
type="Trial Extension",
value=f"{trial_days} days ({trial_months} months)",
max_redemptions=max_redemptions,
valid_until=valid_until.isoformat(),
id=str(coupon.id)
)
return coupon
async def run_startup_seeders(database_manager) -> None:
"""
Run all startup seeders.
This function is called during service startup to ensure
required seed data exists in the database.
Args:
database_manager: The database manager instance
"""
logger.info("Running startup seeders...")
try:
async with database_manager.get_session() as session:
# Seed pilot coupon
await ensure_pilot_coupon(session)
logger.info("Startup seeders completed successfully")
except Exception as e:
# Log but don't fail startup - seed data is not critical
logger.warning(
"Startup seeder encountered an error (non-fatal)",
error=str(e)
)

View File

@@ -17,11 +17,6 @@ class TenantService(StandardFastAPIService):
expected_migration_version = "00001"
async def on_startup(self, app):
"""Custom startup logic including migration verification"""
await self.verify_migrations()
await super().on_startup(app)
async def verify_migrations(self):
"""Verify database schema matches the latest migrations."""
try:
@@ -67,6 +62,9 @@ class TenantService(StandardFastAPIService):
async def on_startup(self, app: FastAPI):
"""Custom startup logic for tenant service"""
# Verify migrations first
await self.verify_migrations()
# Import models to ensure they're registered with SQLAlchemy
from app.models.tenants import Tenant, TenantMember, Subscription
from app.models.tenant_settings import TenantSettings
@@ -87,6 +85,11 @@ class TenantService(StandardFastAPIService):
await start_scheduler(self.database_manager, redis_client, settings)
self.logger.info("Usage tracking scheduler started")
# Run startup seeders (pilot coupon, etc.)
from app.jobs.startup_seeder import run_startup_seeders
await run_startup_seeders(self.database_manager)
self.logger.info("Startup seeders completed")
async def on_shutdown(self, app: FastAPI):
"""Custom shutdown logic for tenant service"""
# Stop usage tracking scheduler

View File

@@ -1,178 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Seed script to create the PILOT2025 coupon for the pilot customer program.
This coupon provides 3 months (90 days) free trial extension for the first 20 customers.
This script runs as a Kubernetes job inside the tenant-service container.
Usage:
python /app/services/tenant/scripts/seed_pilot_coupon.py
Environment Variables Required:
TENANT_DATABASE_URL - PostgreSQL connection string for tenant database
LOG_LEVEL - Logging level (default: INFO)
"""
import asyncio
import sys
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
import uuid
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
import structlog
from app.models.coupon import CouponModel
# Configure logging
structlog.configure(
processors=[
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer()
]
)
logger = structlog.get_logger()
async def seed_pilot_coupon(db: AsyncSession):
"""Create or update the PILOT2025 coupon"""
coupon_code = "PILOT2025"
logger.info("=" * 80)
logger.info("🎫 Seeding PILOT2025 Coupon")
logger.info("=" * 80)
# Check if coupon already exists
result = await db.execute(
select(CouponModel).where(CouponModel.code == coupon_code)
)
existing_coupon = result.scalars().first()
if existing_coupon:
logger.info(
"Coupon already exists",
code=coupon_code,
current_redemptions=existing_coupon.current_redemptions,
max_redemptions=existing_coupon.max_redemptions,
active=existing_coupon.active,
valid_from=existing_coupon.valid_from,
valid_until=existing_coupon.valid_until
)
return existing_coupon
# Create new coupon
now = datetime.now(timezone.utc)
valid_until = now + timedelta(days=180) # Valid for 6 months
coupon = CouponModel(
id=uuid.uuid4(),
code=coupon_code,
discount_type="trial_extension",
discount_value=90, # 90 days = 3 months
max_redemptions=20, # First 20 pilot customers
current_redemptions=0,
valid_from=now,
valid_until=valid_until,
active=True,
created_at=now,
extra_data={
"program": "pilot_launch_2025",
"description": "Programa piloto - 3 meses gratis para los primeros 20 clientes",
"terms": "Válido para nuevos registros únicamente. Un cupón por cliente."
}
)
db.add(coupon)
await db.commit()
await db.refresh(coupon)
logger.info("=" * 80)
logger.info(
"✅ Successfully created coupon",
code=coupon_code,
type="Trial Extension",
value="90 days (3 months)",
max_redemptions=20,
valid_from=coupon.valid_from,
valid_until=coupon.valid_until,
id=str(coupon.id)
)
logger.info("=" * 80)
return coupon
async def main():
"""Main execution function"""
logger.info("Pilot Coupon Seeding Script Starting")
logger.info("Log Level: %s", os.getenv("LOG_LEVEL", "INFO"))
# Get database URL from environment
database_url = os.getenv("TENANT_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
logger.error("❌ TENANT_DATABASE_URL or DATABASE_URL environment variable must be set")
return 1
# Convert to async URL if needed
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
logger.info("Connecting to tenant database")
# Create engine and session
engine = create_async_engine(
database_url,
echo=False,
pool_pre_ping=True,
pool_size=5,
max_overflow=10
)
async_session = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
try:
async with async_session() as session:
await seed_pilot_coupon(session)
logger.info("")
logger.info("🎉 Success! PILOT2025 coupon is ready.")
logger.info("")
logger.info("Coupon Details:")
logger.info(" Code: PILOT2025")
logger.info(" Type: Trial Extension")
logger.info(" Value: 90 days (3 months)")
logger.info(" Max Redemptions: 20")
logger.info("")
return 0
except Exception as e:
logger.error("=" * 80)
logger.error("❌ Pilot Coupon Seeding Failed")
logger.error("=" * 80)
logger.error("Error: %s", str(e))
logger.error("", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)