From 002e6adddcad6d84d84fe2db5ebd559d4625cf36 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Apr 2026 09:26:06 +0000 Subject: [PATCH] Add demo tenant seeding for Codespaces with predefined credentials - Add app/scripts/seed_demo.py: creates a "demo" tenant with 2 sites, 4 projects (with phases and staff assignments), 8 staff members with skills, 8 equipment items, and vacations. All using known password "demo1234" for immediate exploration. - Update .devcontainer/setup.sh to run seed_demo after init_db, and print demo credentials on setup completion. - Set INIT_ADMIN_PASSWORD=demo1234 in devcontainer docker-compose so the admin portal also uses a known password. - Update README.md with demo credentials table (admin portal + tenant). - Update docs/admin-guide/installation.md with Codespaces demo details. - Add docs/build.sh for CF Pages builds (avoids root requirements.txt). CF Pages dashboard settings needed: Build command: bash docs/build.sh Output directory: site Environment variable: SKIP_DEPENDENCY_INSTALL=true https://claude.ai/code/session_0161AK6paBMXuxs7uThEWmjC --- .devcontainer/docker-compose.yml | 1 + .devcontainer/setup.sh | 36 ++- README.md | 13 +- app/scripts/seed_demo.py | 367 +++++++++++++++++++++++++++++++ docs/admin-guide/installation.md | 18 ++ docs/build.sh | 7 + 6 files changed, 431 insertions(+), 11 deletions(-) create mode 100644 app/scripts/seed_demo.py create mode 100755 docs/build.sh diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 7955c706..e38f603c 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -25,6 +25,7 @@ services: - TENANT_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef - AUTO_INIT_DB=true - INIT_ADMIN_EMAIL=admin@demo.local + - INIT_ADMIN_PASSWORD=demo1234 - DEBUG=true - PORT=8485 ports: diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index 957ed291..ec506b8c 100755 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -20,18 +20,36 @@ for i in $(seq 1 30); do sleep 2 done -# Initialize the database (creates schemas, seeds admin user) -echo "Initializing database..." +# Initialize master database (creates schema + platform admin user) +echo "Initializing master database..." python -m app.scripts.init_db || echo "DB init skipped (may already exist)." +# Seed demo tenant with sample data +echo "Seeding demo tenant..." +python -m app.scripts.seed_demo || echo "Demo seed skipped (may already exist)." + +echo "" +echo "==============================================" +echo " Setup complete!" +echo "==============================================" +echo "" +echo " Start the app:" +echo " uvicorn app.main:app --host 0.0.0.0 --port 8485 --reload" +echo "" +echo " Start frontend dev server (optional):" +echo " cd frontend && npm run dev -- --host 0.0.0.0 --port 3333" +echo "" +echo " ---- Demo Credentials ----" echo "" -echo "=== Setup complete! ===" +echo " Admin Portal: http://localhost:8485/admin/" +echo " Email: admin@demo.local" +echo " Password: demo1234" echo "" -echo "Start the app: uvicorn app.main:app --host 0.0.0.0 --port 8485 --reload" -echo "Start frontend: cd frontend && npm run dev -- --host 0.0.0.0 --port 3333" -echo "Run backend tests: pytest" -echo "Run frontend tests: cd frontend && npm test" +echo " Demo Tenant: http://localhost:8485/t/demo/" +echo " Email: admin@demo.local" +echo " Password: demo1234" echo "" -echo "Admin portal: http://localhost:8485/admin/" -echo "Login: admin@demo.local (check logs for password)" +echo " The demo tenant includes 4 projects, 8 staff," +echo " 8 equipment items, skills, and assignments." +echo "==============================================" echo "" diff --git a/README.md b/README.md index 7a8ea2ad..0bf062cb 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,9 @@ A comprehensive R&D project management platform for multi-site organizations. ### Option 1: GitHub Codespaces (Recommended for Demo) -Click the button above or go to [codespaces.new/vincentmakes/milestone-planner](https://codespaces.new/vincentmakes/milestone-planner?quickstart=1) to launch a fully configured development environment with PostgreSQL, Python, Node.js, and VS Code extensions. +Click the badge above to launch a fully configured environment with PostgreSQL, Python, Node.js, and a pre-seeded demo tenant. -After the Codespace starts: +After the Codespace starts, run: ```bash # Start the backend @@ -40,6 +40,15 @@ uvicorn app.main:app --host 0.0.0.0 --port 8485 --reload cd frontend && npm run dev -- --host 0.0.0.0 --port 3333 ``` +The Codespace automatically creates a demo tenant with sample data: + +| Access | URL | Email | Password | +|--------|-----|-------|----------| +| **Admin Portal** | `/admin/` | `admin@demo.local` | `demo1234` | +| **Demo Tenant** | `/t/demo/` | `admin@demo.local` | `demo1234` | + +The demo tenant includes 2 sites (Winterthur, Frankfurt), 4 projects with phases and assignments, 8 staff members with skills, and 8 equipment items. All staff accounts use password `demo1234`. + ### Option 2: Docker Fresh Install ```bash diff --git a/app/scripts/seed_demo.py b/app/scripts/seed_demo.py new file mode 100644 index 00000000..1d2e2b66 --- /dev/null +++ b/app/scripts/seed_demo.py @@ -0,0 +1,367 @@ +""" +Seed demo data for GitHub Codespaces. + +Creates a demo tenant with sample projects, staff, equipment, and +assignments so the app is ready to explore immediately after setup. + +Designed to be idempotent — safe to run multiple times. + +Usage: + python -m app.scripts.seed_demo +""" + +import asyncio +import os +import sys +from datetime import date, timedelta + +import asyncpg + +from app.config import get_settings +from app.services.encryption import encrypt, hash_password + + +DEMO_TENANT_SLUG = "demo" +DEMO_TENANT_NAME = "Demo Company" +DEMO_TENANT_DB = "milestone_demo" +DEMO_TENANT_USER = "milestone_demo" +DEMO_TENANT_PASSWORD = "demo_db_pass_2026" +DEMO_ADMIN_EMAIL = "admin@demo.local" +DEMO_ADMIN_PASSWORD = "demo1234" + + +async def get_admin_conn(settings) -> asyncpg.Connection: + host = settings.master_db_host or settings.db_host + port = settings.master_db_port or settings.db_port + user = settings.pg_admin_user or settings.db_user + password = settings.pg_admin_password or settings.db_password + return await asyncpg.connect(host=host, port=port, user=user, password=password, database="postgres") + + +async def ensure_tenant_registered(settings) -> bool: + """Register the demo tenant in the master database. Returns True if newly created.""" + host = settings.master_db_host or settings.db_host + port = settings.master_db_port or settings.db_port + user = settings.master_db_user or settings.db_user + password = settings.master_db_password or settings.db_password + db_name = settings.master_db_name + + conn = await asyncpg.connect(host=host, port=port, user=user, password=password, database=db_name) + try: + exists = await conn.fetchval("SELECT 1 FROM tenants WHERE slug = $1", DEMO_TENANT_SLUG) + if exists: + print(f" Tenant '{DEMO_TENANT_SLUG}' already registered in master DB") + return False + + encrypted_pw = encrypt(DEMO_TENANT_PASSWORD) + await conn.execute( + """ + INSERT INTO tenants (name, slug, database_name, database_user, status, admin_email, company_name) + VALUES ($1, $2, $3, $4, 'active', $5, $6) + """, + DEMO_TENANT_NAME, DEMO_TENANT_SLUG, DEMO_TENANT_DB, DEMO_TENANT_USER, + DEMO_ADMIN_EMAIL, DEMO_TENANT_NAME, + ) + tenant_id = await conn.fetchval("SELECT id FROM tenants WHERE slug = $1", DEMO_TENANT_SLUG) + await conn.execute( + "INSERT INTO tenant_credentials (tenant_id, encrypted_password) VALUES ($1, $2)", + tenant_id, encrypted_pw, + ) + print(f" Registered tenant '{DEMO_TENANT_SLUG}' in master DB") + return True + finally: + await conn.close() + + +async def provision_demo_db(settings) -> bool: + """Create the demo tenant database and user. Returns True if newly created.""" + conn = await get_admin_conn(settings) + try: + exists = await conn.fetchval("SELECT 1 FROM pg_database WHERE datname = $1", DEMO_TENANT_DB) + if exists: + print(f" Database '{DEMO_TENANT_DB}' already exists") + return False + + user_exists = await conn.fetchval("SELECT 1 FROM pg_roles WHERE rolname = $1", DEMO_TENANT_USER) + if not user_exists: + safe_pw = DEMO_TENANT_PASSWORD.replace("'", "''") + await conn.execute(f"CREATE USER \"{DEMO_TENANT_USER}\" WITH PASSWORD '{safe_pw}'") + print(f" Created database user '{DEMO_TENANT_USER}'") + + await conn.execute(f'CREATE DATABASE "{DEMO_TENANT_DB}" OWNER "{DEMO_TENANT_USER}"') + await conn.execute(f'GRANT ALL PRIVILEGES ON DATABASE "{DEMO_TENANT_DB}" TO "{DEMO_TENANT_USER}"') + print(f" Created database '{DEMO_TENANT_DB}'") + return True + finally: + await conn.close() + + +async def apply_schema_and_seed(settings): + """Apply tenant schema and seed demo data.""" + from app.services.tenant_provisioner import get_tenant_schema_sql + + conn = await asyncpg.connect( + host=settings.db_host, port=settings.db_port, + user=DEMO_TENANT_USER, password=DEMO_TENANT_PASSWORD, + database=DEMO_TENANT_DB, + ) + try: + # Check if already seeded + has_users = await conn.fetchval( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'users')" + ) + if has_users: + user_count = await conn.fetchval("SELECT COUNT(*) FROM users") + if user_count > 1: + print(" Demo data already seeded — skipping") + return + + # Apply schema + schema_sql = get_tenant_schema_sql() + await conn.execute(schema_sql) + print(" Applied tenant schema") + + # Seed base data + await conn.execute(""" + INSERT INTO sso_config (id, enabled) VALUES (1, 0) ON CONFLICT (id) DO NOTHING + """) + await conn.execute(""" + INSERT INTO settings (key, value) VALUES + ('instance_title', 'Demo Instance'), + ('fiscal_year_start', '1') + ON CONFLICT (key) DO NOTHING + """) + await conn.execute(""" + INSERT INTO predefined_phases (name, sort_order, is_active) VALUES + ('Preparation', 0, 1), ('Analytics', 1, 1), ('Trial', 2, 1), + ('Cleaning', 3, 1), ('Report', 4, 1) + ON CONFLICT (name) DO NOTHING + """) + + # Sites + await conn.execute(""" + INSERT INTO sites (name, location, city, country_code, region_code, timezone) VALUES + ('Winterthur', 'Winterthur', 'Winterthur', 'CH', 'ZH', 'Europe/Zurich'), + ('Frankfurt', 'Frankfurt', 'Frankfurt', 'DE', 'HE', 'Europe/Berlin') + ON CONFLICT (name) DO NOTHING + """) + wt_id = await conn.fetchval("SELECT id FROM sites WHERE name = 'Winterthur'") + ff_id = await conn.fetchval("SELECT id FROM sites WHERE name = 'Frankfurt'") + + # Skills + await conn.execute(""" + INSERT INTO skills (name, description, color) VALUES + ('Project Management', 'Planning and execution', '#3b82f6'), + ('HPLC', 'High-Performance Liquid Chromatography', '#e74c3c'), + ('Data Analysis', 'Statistical analysis', '#8b5cf6'), + ('Cell Culture', 'Mammalian cell culture', '#10b981'), + ('Technical Writing', 'Documentation and reports', '#f59e0b'), + ('Quality Control', 'QC procedures', '#ef4444') + ON CONFLICT (name) DO NOTHING + """) + + # Admin user (known password: demo1234) + admin_hash = hash_password(DEMO_ADMIN_PASSWORD) + await conn.execute( + """INSERT INTO users (email, password, first_name, last_name, job_title, role, is_system) + VALUES ($1, $2, 'Admin', 'User', 'System Administrator', 'admin', 1) + ON CONFLICT (email) DO NOTHING""", + DEMO_ADMIN_EMAIL, admin_hash, + ) + admin_id = await conn.fetchval("SELECT id FROM users WHERE email = $1", DEMO_ADMIN_EMAIL) + await conn.execute( + "INSERT INTO user_sites (user_id, site_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + admin_id, wt_id, + ) + + # Staff members + staff_data = [ + ("alice.anderson@demo.local", "Alice", "Anderson", "Research Scientist", "superuser", wt_id), + ("bob.brown@demo.local", "Bob", "Brown", "Project Manager", "superuser", wt_id), + ("charlie.clark@demo.local", "Charlie", "Clark", "Lab Technician", "user", wt_id), + ("diana.davis@demo.local", "Diana", "Davis", "Data Analyst", "user", wt_id), + ("edward.evans@demo.local", "Edward", "Evans", "Process Engineer", "user", wt_id), + ("fiona.fischer@demo.local", "Fiona", "Fischer", "Analytical Chemist", "user", ff_id), + ("george.garcia@demo.local", "George", "Garcia", "R&D Engineer", "user", ff_id), + ("hannah.harris@demo.local", "Hannah", "Harris", "Quality Specialist", "user", ff_id), + ] + staff_hash = hash_password("demo1234") + staff_ids = {} + for email, first, last, title, role, site_id in staff_data: + await conn.execute( + """INSERT INTO users (email, password, first_name, last_name, job_title, role) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (email) DO NOTHING""", + email, staff_hash, first, last, title, role, + ) + uid = await conn.fetchval("SELECT id FROM users WHERE email = $1", email) + staff_ids[email] = uid + await conn.execute( + "INSERT INTO user_sites (user_id, site_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + uid, site_id, + ) + print(f" Created {len(staff_data)} staff members") + + # Assign some skills + skill_ids = await conn.fetch("SELECT id, name FROM skills") + skill_map = {r["name"]: r["id"] for r in skill_ids} + skill_assignments = [ + ("alice.anderson@demo.local", ["HPLC", "Data Analysis", "Technical Writing"]), + ("bob.brown@demo.local", ["Project Management", "Technical Writing"]), + ("charlie.clark@demo.local", ["HPLC", "Cell Culture", "Quality Control"]), + ("diana.davis@demo.local", ["Data Analysis", "Technical Writing"]), + ("fiona.fischer@demo.local", ["HPLC", "Quality Control"]), + ("george.garcia@demo.local", ["Cell Culture", "Data Analysis"]), + ] + for email, skills in skill_assignments: + uid = staff_ids.get(email) + if not uid: + continue + for skill_name in skills: + sid = skill_map.get(skill_name) + if sid: + await conn.execute( + "INSERT INTO user_skills (user_id, skill_id, proficiency) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + uid, sid, 4, + ) + + # Equipment + equip_data = [ + ("HPLC System 1", "Analytical", wt_id), + ("Mass Spectrometer", "Analytical", wt_id), + ("PCR Thermocycler", "Molecular Biology", wt_id), + ("Biosafety Cabinet", "Cell Culture", wt_id), + ("Centrifuge", "General Lab", wt_id), + ("HPLC System 2", "Analytical", ff_id), + ("Plate Reader", "Analytical", ff_id), + ("Freeze Dryer", "Processing", ff_id), + ] + for name, etype, site_id in equip_data: + await conn.execute( + """INSERT INTO equipment (name, type, site_id, active) + VALUES ($1, $2, $3, 1) + ON CONFLICT DO NOTHING""", + name, etype, site_id, + ) + print(f" Created {len(equip_data)} equipment items") + + # Projects + today = date.today() + projects = [ + ("Catalyst Optimization", wt_id, "Novachem AG", True, + today - timedelta(days=30), today + timedelta(days=150)), + ("Bioprocess Scale-Up", wt_id, "BioGen Ltd", True, + today - timedelta(days=15), today + timedelta(days=200)), + ("Analytical Method Transfer", wt_id, "PharmaCore", False, + today + timedelta(days=14), today + timedelta(days=120)), + ("Quality System Upgrade", ff_id, "Internal", True, + today - timedelta(days=60), today + timedelta(days=90)), + ] + for pname, site_id, customer, confirmed, start, end in projects: + await conn.execute( + """INSERT INTO projects (name, site_id, customer, confirmed, start_date, end_date) + VALUES ($1, $2, $3, $4, $5, $6)""", + pname, site_id, customer, 1 if confirmed else 0, start, end, + ) + print(f" Created {len(projects)} projects") + + # Phases for each project + project_rows = await conn.fetch("SELECT id, name, start_date, end_date FROM projects ORDER BY id") + phase_types = ["Preparation", "Analytics", "Trial", "Cleaning", "Report"] + for proj in project_rows: + proj_start = proj["start_date"] + proj_end = proj["end_date"] + duration = (proj_end - proj_start).days + phase_len = max(duration // len(phase_types), 7) + for i, ptype in enumerate(phase_types): + ps = proj_start + timedelta(days=i * phase_len) + pe = min(ps + timedelta(days=phase_len - 1), proj_end) + if ps > proj_end: + break + await conn.execute( + """INSERT INTO project_phases (project_id, type, start_date, end_date, sort_order) + VALUES ($1, $2, $3, $4, $5)""", + proj["id"], ptype, ps, pe, i, + ) + + # Staff assignments to phases + phases = await conn.fetch( + "SELECT pp.id, pp.project_id, pp.type, p.site_id " + "FROM project_phases pp JOIN projects p ON pp.project_id = p.id ORDER BY pp.id" + ) + wt_staff = [v for k, v in staff_ids.items() if k.split("@")[0] in + ("alice.anderson", "bob.brown", "charlie.clark", "diana.davis", "edward.evans")] + ff_staff = [v for k, v in staff_ids.items() if k.split("@")[0] in + ("fiona.fischer", "george.garcia", "hannah.harris")] + import random + random.seed(42) # reproducible + assignment_count = 0 + for phase in phases: + pool = wt_staff if phase["site_id"] == wt_id else ff_staff + n = min(random.randint(1, 3), len(pool)) + chosen = random.sample(pool, n) + for sid in chosen: + alloc = random.choice([25, 50, 50, 75, 100]) + await conn.execute( + """INSERT INTO phase_staff_assignments (phase_id, project_id, staff_id, allocation) + VALUES ($1, $2, $3, $4)""", + phase["id"], phase["project_id"], sid, alloc, + ) + assignment_count += 1 + print(f" Created {assignment_count} staff assignments") + + # A couple of vacations + alice_id = staff_ids.get("alice.anderson@demo.local") + bob_id = staff_ids.get("bob.brown@demo.local") + if alice_id: + await conn.execute( + "INSERT INTO vacations (staff_id, start_date, end_date, description) VALUES ($1, $2, $3, $4)", + alice_id, today + timedelta(days=30), today + timedelta(days=37), "Summer vacation", + ) + if bob_id: + await conn.execute( + "INSERT INTO vacations (staff_id, start_date, end_date, description) VALUES ($1, $2, $3, $4)", + bob_id, today + timedelta(days=60), today + timedelta(days=64), "Conference", + ) + + print(" Demo data seeded successfully") + finally: + await conn.close() + + +async def main(): + settings = get_settings() + + if not settings.multi_tenant: + print("ERROR: seed_demo requires MULTI_TENANT=true") + sys.exit(1) + + print("\n=== Seeding Demo Tenant ===") + + # 1. Provision the database + print("\n1. Provisioning database...") + await provision_demo_db(settings) + + # 2. Register tenant in master DB + print("\n2. Registering tenant...") + await ensure_tenant_registered(settings) + + # 3. Apply schema and seed data + print("\n3. Applying schema and seeding data...") + await apply_schema_and_seed(settings) + + print("\n" + "=" * 50) + print(" DEMO TENANT READY") + print("=" * 50) + print(f" URL: http://localhost:8485/t/{DEMO_TENANT_SLUG}/") + print(f" Email: {DEMO_ADMIN_EMAIL}") + print(f" Password: {DEMO_ADMIN_PASSWORD}") + print("=" * 50) + print(f"\n Admin Portal: http://localhost:8485/admin/") + print(f" Admin Email: admin@demo.local") + print(f" Admin Pass: demo1234") + print("=" * 50 + "\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/admin-guide/installation.md b/docs/admin-guide/installation.md index 9e6cb471..6472576a 100644 --- a/docs/admin-guide/installation.md +++ b/docs/admin-guide/installation.md @@ -116,6 +116,7 @@ The Codespace includes: - PostgreSQL 15 (auto-configured) - VS Code extensions for Python, TypeScript, Docker - Forwarded ports: 8485 (app), 3333 (Vite dev), 5432 (PostgreSQL) +- **Pre-seeded demo tenant** with sample projects, staff, and equipment After the Codespace starts, run: @@ -127,6 +128,23 @@ uvicorn app.main:app --host 0.0.0.0 --port 8485 --reload cd frontend && npm run dev -- --host 0.0.0.0 --port 3333 ``` +### Demo Credentials + +| Access | URL | Email | Password | +|--------|-----|-------|----------| +| **Admin Portal** | `/admin/` | `admin@demo.local` | `demo1234` | +| **Demo Tenant** | `/t/demo/` | `admin@demo.local` | `demo1234` | + +The demo tenant includes: + +- **2 sites**: Winterthur (CH), Frankfurt (DE) +- **4 projects** with phases, staff assignments, and timeline data +- **8 staff members** with skills and site assignments +- **8 equipment items** across both sites +- **6 skills** (Project Management, HPLC, Data Analysis, Cell Culture, Technical Writing, Quality Control) + +All staff accounts use password `demo1234`. + ## Reverse Proxy (SSL/TLS) For production, place a reverse proxy (nginx, Caddy, or Cloudflare Tunnel) in front of port 8485: diff --git a/docs/build.sh b/docs/build.sh new file mode 100755 index 00000000..726fb857 --- /dev/null +++ b/docs/build.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Build script for Cloudflare Pages deployment. +# CF Pages build command: bash docs/build.sh +# CF Pages output directory: site +set -e +pip install -r docs/requirements.txt +mkdocs build --strict