From 9b6356b0db33539bdbf7028fb4b76e417913e492 Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Fri, 21 Nov 2025 08:23:18 +0100 Subject: [PATCH] Add comprehensive demo data loading logic and `.env.demo` configuration - Implemented `load_demo_data` to populate organizations, users, and relationships from `demo_data.json`. - Refactored database initialization to handle demo-specific passwords and multi-entity creation in demo mode. - Added `demo_data.json` with sample organizations and users for better demo showcase. - Introduced `.env.demo` to simplify environment setup for demo scenarios. - Updated `.gitignore` to include `.env.demo` while keeping other `.env` files excluded. --- .env.demo | 31 ++++++++ .gitignore | 1 + backend/app/core/demo_data.json | 103 +++++++++++++++++++++++++++ backend/app/init_db.py | 121 +++++++++++++++++++++++++++----- frontend/.gitignore | 1 + 5 files changed, 238 insertions(+), 19 deletions(-) create mode 100644 .env.demo create mode 100644 backend/app/core/demo_data.json diff --git a/.env.demo b/.env.demo new file mode 100644 index 0000000..3e175a1 --- /dev/null +++ b/.env.demo @@ -0,0 +1,31 @@ +# Common settings +PROJECT_NAME=App +VERSION=1.0.0 + +# Database settings +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=app +POSTGRES_HOST=db +POSTGRES_PORT=5432 +DATABASE_URL=postgresql://postgres:postgres@db:5432/app + +# Backend settings +BACKEND_PORT=8000 +# CRITICAL: Generate a secure SECRET_KEY for production! +# Generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))' +# Must be at least 32 characters +SECRET_KEY=demo_secret_key_for_testing_only_do_not_use_in_prod +ENVIRONMENT=development +DEMO_MODE=true +DEBUG=true +BACKEND_CORS_ORIGINS=["http://localhost:3000"] +FIRST_SUPERUSER_EMAIL=admin@example.com +# IMPORTANT: Use a strong password (min 12 chars, mixed case, digits) +# Default weak passwords like 'Admin123' are rejected +FIRST_SUPERUSER_PASSWORD=Admin123! + +# Frontend settings +FRONTEND_PORT=3000 +NEXT_PUBLIC_API_URL=http://localhost:8000 +NODE_ENV=development diff --git a/.gitignore b/.gitignore index 95668d3..e0dad4a 100755 --- a/.gitignore +++ b/.gitignore @@ -268,6 +268,7 @@ celerybeat.pid .env .env.* !.env.template +!.env.demo .venv env/ venv/ diff --git a/backend/app/core/demo_data.json b/backend/app/core/demo_data.json new file mode 100644 index 0000000..d542738 --- /dev/null +++ b/backend/app/core/demo_data.json @@ -0,0 +1,103 @@ +{ + "organizations": [ + { + "name": "Acme Corp", + "slug": "acme-corp", + "description": "A leading provider of coyote-catching equipment." + }, + { + "name": "Globex Corporation", + "slug": "globex", + "description": "We own the East Coast." + }, + { + "name": "Soylent Corp", + "slug": "soylent", + "description": "Making food for the future." + }, + { + "name": "Initech", + "slug": "initech", + "description": "Software for the soul." + }, + { + "name": "Umbrella Corporation", + "slug": "umbrella", + "description": "Our business is life itself." + } + ], + "users": [ + { + "email": "alice@acme.com", + "password": "Demo123!", + "first_name": "Alice", + "last_name": "Smith", + "is_superuser": false, + "organization_slug": "acme-corp", + "role": "admin" + }, + { + "email": "bob@acme.com", + "password": "Demo123!", + "first_name": "Bob", + "last_name": "Jones", + "is_superuser": false, + "organization_slug": "acme-corp", + "role": "member" + }, + { + "email": "carol@globex.com", + "password": "Demo123!", + "first_name": "Carol", + "last_name": "Williams", + "is_superuser": false, + "organization_slug": "globex", + "role": "owner" + }, + { + "email": "dave@soylent.com", + "password": "Demo123!", + "first_name": "Dave", + "last_name": "Brown", + "is_superuser": false, + "organization_slug": "soylent", + "role": "member" + }, + { + "email": "eve@initech.com", + "password": "Demo123!", + "first_name": "Eve", + "last_name": "Davis", + "is_superuser": false, + "organization_slug": "initech", + "role": "admin" + }, + { + "email": "frank@umbrella.com", + "password": "Demo123!", + "first_name": "Frank", + "last_name": "Miller", + "is_superuser": false, + "organization_slug": "umbrella", + "role": "member" + }, + { + "email": "grace@example.com", + "password": "Demo123!", + "first_name": "Grace", + "last_name": "Hopper", + "is_superuser": false, + "organization_slug": null, + "role": null + }, + { + "email": "heidi@example.com", + "password": "Demo123!", + "first_name": "Heidi", + "last_name": "Klum", + "is_superuser": false, + "organization_slug": null, + "role": null + } + ] +} \ No newline at end of file diff --git a/backend/app/init_db.py b/backend/app/init_db.py index 7e09058..d29f66d 100644 --- a/backend/app/init_db.py +++ b/backend/app/init_db.py @@ -6,12 +6,18 @@ Creates the first superuser if configured and doesn't already exist. """ import asyncio +import json import logging +from pathlib import Path + +from sqlalchemy import select, text from app.core.config import settings from app.core.database import SessionLocal, engine from app.crud.user import user as user_crud +from app.models.organization import Organization from app.models.user import User +from app.models.user_organization import UserOrganization from app.schemas.users import UserCreate logger = logging.getLogger(__name__) @@ -26,7 +32,12 @@ async def init_db() -> User | None: """ # Use default values if not set in environment variables superuser_email = settings.FIRST_SUPERUSER_EMAIL or "admin@example.com" - superuser_password = settings.FIRST_SUPERUSER_PASSWORD or "AdminPassword123!" + + default_password = "AdminPassword123!" + if settings.DEMO_MODE: + default_password = "Admin123!" + + superuser_password = settings.FIRST_SUPERUSER_PASSWORD or default_password if not settings.FIRST_SUPERUSER_EMAIL or not settings.FIRST_SUPERUSER_PASSWORD: logger.warning( @@ -58,25 +69,9 @@ async def init_db() -> User | None: logger.info(f"Created first superuser: {user.email}") - # Create demo user if in demo mode + # Create demo data if in demo mode if settings.DEMO_MODE: - demo_email = "demo@example.com" - demo_password = "Demo123!" - - existing_demo_user = await user_crud.get_by_email(session, email=demo_email) - if not existing_demo_user: - demo_user_in = UserCreate( - email=demo_email, - password=demo_password, - first_name="Demo", - last_name="User", - is_superuser=False, - ) - demo_user = await user_crud.create(session, obj_in=demo_user_in) - await session.commit() - logger.info(f"Created demo user: {demo_user.email}") - else: - logger.info(f"Demo user already exists: {existing_demo_user.email}") + await load_demo_data(session) return user @@ -86,6 +81,94 @@ async def init_db() -> User | None: raise +def _load_json_file(path: Path): + with open(path) as f: + return json.load(f) + + +async def load_demo_data(session): + """Load demo data from JSON file.""" + demo_data_path = Path(__file__).parent / "core" / "demo_data.json" + if not demo_data_path.exists(): + logger.warning(f"Demo data file not found: {demo_data_path}") + return + + try: + # Use asyncio.to_thread to avoid blocking the event loop + data = await asyncio.to_thread(_load_json_file, demo_data_path) + + # Create Organizations + org_map = {} + for org_data in data.get("organizations", []): + # Check if org exists + result = await session.execute( + text("SELECT * FROM organizations WHERE slug = :slug"), + {"slug": org_data["slug"]}, + ) + existing_org = result.first() + + if not existing_org: + org = Organization( + name=org_data["name"], + slug=org_data["slug"], + description=org_data.get("description"), + is_active=True, + ) + session.add(org) + await session.flush() # Flush to get ID + org_map[org.slug] = org + logger.info(f"Created demo organization: {org.name}") + else: + # We can't easily get the ORM object from raw SQL result for map without querying again or mapping + # So let's just query it properly if we need it for relationships + # But for simplicity in this script, let's just assume we created it or it exists. + # To properly map for users, we need the ID. + # Let's use a simpler approach: just try to create, if slug conflict, skip. + pass + + # Re-query all orgs to build map for users + result = await session.execute(select(Organization)) + orgs = result.scalars().all() + org_map = {org.slug: org for org in orgs} + + # Create Users + for user_data in data.get("users", []): + existing_user = await user_crud.get_by_email( + session, email=user_data["email"] + ) + if not existing_user: + user_in = UserCreate( + email=user_data["email"], + password=user_data["password"], + first_name=user_data["first_name"], + last_name=user_data["last_name"], + is_superuser=user_data["is_superuser"], + ) + user = await user_crud.create(session, obj_in=user_in) + logger.info(f"Created demo user: {user.email}") + + # Add to organization if specified + org_slug = user_data.get("organization_slug") + role = user_data.get("role") + if org_slug and org_slug in org_map and role: + org = org_map[org_slug] + # Check if membership exists (it shouldn't for new user) + member = UserOrganization( + user_id=user.id, organization_id=org.id, role=role + ) + session.add(member) + logger.info(f"Added {user.email} to {org.name} as {role}") + else: + logger.info(f"Demo user already exists: {existing_user.email}") + + await session.commit() + logger.info("Demo data loaded successfully") + + except Exception as e: + logger.error(f"Error loading demo data: {e}") + raise + + async def main(): """Main entry point for database initialization.""" # Configure logging to show info logs diff --git a/frontend/.gitignore b/frontend/.gitignore index 075e6be..ac2c554 100755 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -33,6 +33,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +. # vercel .vercel