diff --git a/run/initialization/demo_users.py b/run/initialization/demo_users.py index 54a118d..a20643f 100644 --- a/run/initialization/demo_users.py +++ b/run/initialization/demo_users.py @@ -1,395 +1,218 @@ """ -Demo users initialization module for ClassroomCopilot -Creates demo teachers and students +Demo users initialization — creates the three canonical @kevlarai.com accounts +and links them to the KevlarAI institute in both Supabase and Neo4j. + +Idempotent: existing users are reused, stale .edu demo users are removed. +Run via: python3 main.py --mode demo-users """ import os -import json import requests import time from typing import Dict, Any from modules.logger_tool import initialise_logger -from modules.database.services.provisioning_service import ProvisioningService logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) -class DemoUsersInitializer: - """Handles demo users creation""" - - def __init__(self, supabase_url: str, service_role_key: str): - self.supabase_url = supabase_url - self.service_role_key = service_role_key - self.supabase_headers = { - "apikey": service_role_key, - "Authorization": f"Bearer {service_role_key}", - "Content-Type": "application/json" - } - self.provisioning_service = ProvisioningService() - - def create_demo_users(self) -> Dict[str, Any]: - """Create demo teachers and students""" - logger.info("Creating demo users...") - - try: - # Define demo users - demo_users = [ - # Demo Teachers - { - "email": "teacher1@kevlarai.edu", - "password": "DemoTeacher123!", - "email_confirm": True, - "user_metadata": { - "name": "Dr. Sarah Chen", - "username": "sarah.chen", - "full_name": "Dr. Sarah Chen", - "display_name": "Dr. Chen", - "user_type": "teacher" - }, - "app_metadata": { - "provider": "email", - "providers": ["email"] - } - }, - { - "email": "teacher2@kevlarai.edu", - "password": "DemoTeacher123!", - "email_confirm": True, - "user_metadata": { - "name": "Prof. Marcus Rodriguez", - "username": "marcus.rodriguez", - "full_name": "Professor Marcus Rodriguez", - "display_name": "Prof. Rodriguez", - "user_type": "teacher" - }, - "app_metadata": { - "provider": "email", - "providers": ["email"] - } - }, - # Demo Students - { - "email": "student1@kevlarai.edu", - "password": "DemoStudent123!", - "email_confirm": True, - "user_metadata": { - "name": "Alex Thompson", - "username": "alex.thompson", - "full_name": "Alex Thompson", - "display_name": "Alex", - "user_type": "student" - }, - "app_metadata": { - "provider": "email", - "providers": ["email"] - } - }, - { - "email": "student2@kevlarai.edu", - "password": "DemoStudent123!", - "email_confirm": True, - "user_metadata": { - "name": "Jordan Lee", - "username": "jordan.lee", - "full_name": "Jordan Lee", - "display_name": "Jordan", - "user_type": "student" - }, - "app_metadata": { - "provider": "email", - "providers": ["email"] - } - } - ] - - created_users = [] - failed_users = [] - - for user_data in demo_users: - try: - # Create user via Auth API - response = self._supabase_request_with_retry( - 'post', - f"{self.supabase_url}/auth/v1/admin/users", - headers=self.supabase_headers, - json=user_data - ) - - if response.status_code in (200, 201): - user = response.json() - user_id = user.get("id") - - # Wait a moment for user to be created - time.sleep(1) - - # Create profile - profile_data = { - "id": user_id, - "email": user_data["email"], - "user_type": user_data["user_metadata"]["user_type"], - "username": user_data["user_metadata"]["username"], - "full_name": user_data["user_metadata"]["full_name"], - "display_name": user_data["user_metadata"]["display_name"] - } - - profile_response = self._supabase_request_with_retry( - 'post', - f"{self.supabase_url}/rest/v1/profiles", - headers=self.supabase_headers, - json=profile_data - ) - - if profile_response.status_code in (200, 201): - created_users.append({ - "id": user_id, - "email": user_data["email"], - "user_type": user_data["user_metadata"]["user_type"], - "username": user_data["user_metadata"]["username"] - }) - logger.info(f"Successfully created user: {user_data['email']}") - else: - logger.warning(f"Failed to create profile for {user_data['email']}: {profile_response.text}") - failed_users.append({ - "email": user_data["email"], - "error": f"Profile creation failed: {profile_response.text}" - }) - else: - logger.warning(f"Failed to create user {user_data['email']}: {response.text}") - failed_users.append({ - "email": user_data["email"], - "error": f"User creation failed: {response.text}" - }) - - except Exception as e: - logger.error(f"Error creating user {user_data['email']}: {str(e)}") - failed_users.append({ - "email": user_data["email"], - "error": str(e) - }) - - # Create institute memberships for KevlarAI and provision users - all_users_to_provision = [] - - # Add newly created users - if created_users: - all_users_to_provision.extend(created_users) - self._create_institute_memberships(created_users) - - # Also provision existing users that failed due to email_exists - existing_users = [] - for failed_user in failed_users: - if "email_exists" in failed_user.get("error", ""): - # Get the existing user ID from Supabase - existing_user_id = self._get_existing_user_id(failed_user["email"]) - if existing_user_id: - existing_users.append({ - "id": existing_user_id, - "email": failed_user["email"], - "user_type": self._get_user_type_from_email(failed_user["email"]), - "username": self._get_username_from_email(failed_user["email"]) - }) - - if existing_users: - logger.info(f"Found {len(existing_users)} existing users to provision") - all_users_to_provision.extend(existing_users) - self._create_institute_memberships(existing_users) - - # Provision all users (new and existing) - if all_users_to_provision: - self._provision_users(all_users_to_provision) - - logger.info(f"Demo users creation completed: {len(created_users)} created, {len(failed_users)} failed") - - return { - "success": True, - "message": f"Successfully created {len(created_users)} demo users", - "created_users": created_users, - "failed_users": failed_users - } - - except Exception as e: - logger.error(f"Error creating demo users: {str(e)}") - return { - "success": False, - "message": f"Error creating demo users: {str(e)}" - } - - def _create_institute_memberships(self, users: list) -> None: - """Create institute memberships for users in KevlarAI""" - logger.info("Creating institute memberships for demo users...") - - try: - # Get KevlarAI institute ID - response = self._supabase_request_with_retry( - 'get', - f"{self.supabase_url}/rest/v1/institutes", - headers=self.supabase_headers, - params={ - "select": "id", - "name": "eq.KevlarAI" - } - ) - - if response.status_code != 200: - logger.warning("Could not get KevlarAI institute ID for memberships") - return - - institutes = response.json() - if not institutes: - logger.warning("KevlarAI institute not found for memberships") - return - - institute_id = institutes[0]["id"] - - # Get user profile IDs - for user in users: - try: - profile_response = self._supabase_request_with_retry( - 'get', - f"{self.supabase_url}/rest/v1/profiles", - headers=self.supabase_headers, - params={ - "select": "id", - "email": f"eq.{user['email']}" - } - ) - - if profile_response.status_code == 200: - profiles = profile_response.json() - if profiles: - profile_id = profiles[0]["id"] - - # Create membership - membership_data = { - "profile_id": profile_id, - "institute_id": institute_id, - "role": user["user_type"] - } - - membership_response = self._supabase_request_with_retry( - 'post', - f"{self.supabase_url}/rest/v1/institute_memberships", - headers=self.supabase_headers, - json=membership_data - ) - - if membership_response.status_code in (200, 201): - logger.info(f"Created membership for {user['email']} in KevlarAI") - else: - logger.warning(f"Failed to create membership for {user['email']}: {membership_response.text}") - - except Exception as e: - logger.warning(f"Error creating membership for {user['email']}: {str(e)}") - - except Exception as e: - logger.warning(f"Error creating institute memberships: {str(e)}") +INSTITUTE_DB = "cc.institutes.6585bf916ae84d72ab54cddf3ba4e648" +INSTITUTE_ID = "6585bf91-6ae8-4d72-ab54-cddf3ba4e648" - def _get_existing_user_id(self, email: str) -> str: - """Get the user ID for an existing user by email""" - try: - response = self._supabase_request_with_retry( - 'get', - f"{self.supabase_url}/rest/v1/profiles", - headers=self.supabase_headers, - params={ - "select": "id", - "email": f"eq.{email}" - } - ) - - if response.status_code == 200: - profiles = response.json() - if profiles and len(profiles) > 0: - return profiles[0].get("id") - - logger.warning(f"Could not find existing user ID for {email}") - return None - - except Exception as e: - logger.warning(f"Error getting existing user ID for {email}: {str(e)}") - return None - - def _get_user_type_from_email(self, email: str) -> str: - """Get user type from email based on demo user definitions""" - if "teacher" in email: - return "teacher" - elif "student" in email: - return "student" - return "teacher" # default - - def _get_username_from_email(self, email: str) -> str: - """Get username from email based on demo user definitions""" - username_map = { - "teacher1@kevlarai.edu": "sarah.chen", - "teacher2@kevlarai.edu": "marcus.rodriguez", - "student1@kevlarai.edu": "alex.thompson", - "student2@kevlarai.edu": "jordan.lee" - } - return username_map.get(email, email.split("@")[0]) +DEMO_USERS = [ + { + "email": "kcar@kevlarai.com", + "password": "KevlarAI2025!", + "username": "kcar", + "full_name": "Kevin Carroll", + "display_name": "Kevin", + "user_type": "teacher", + "role": "school_admin", + }, + { + "email": "teacher1@kevlarai.com", + "password": "Teacher1@KevlarAI!", + "username": "teacher1.kevlarai", + "full_name": "Sarah Chen", + "display_name": "Sarah", + "user_type": "teacher", + "role": "teacher", + }, + { + "email": "teacher2@kevlarai.com", + "password": "Teacher2@KevlarAI!", + "username": "teacher2.kevlarai", + "full_name": "Marcus Rodriguez", + "display_name": "Marcus", + "user_type": "teacher", + "role": "teacher", + }, +] - def _provision_users(self, users: list) -> None: - """Provision Neo4j databases for the created demo users.""" - for user in users: - user_id = user.get("id") - if not user_id: - continue - try: - self.provisioning_service.ensure_user(user_id) - logger.info(f"Provisioned Neo4j resources for {user.get('email')}") - except Exception as exc: - logger.warning(f"Failed to provision Neo4j resources for {user.get('email')}: {exc}") - - def _supabase_request_with_retry(self, method, url, **kwargs): - """Make a request to Supabase with retry logic""" - max_retries = 3 - retry_delay = 2 # seconds - - for attempt in range(max_retries): - try: - if method.lower() == 'get': - response = requests.get(url, **kwargs) - elif method.lower() == 'post': - response = requests.post(url, **kwargs) - elif method.lower() == 'put': - response = requests.put(url, **kwargs) - elif method.lower() == 'delete': - response = requests.delete(url, **kwargs) - else: - raise ValueError(f"Unsupported HTTP method: {method}") - - # If successful or client error (4xx), don't retry - if response.status_code < 500: - return response - - # Server error (5xx), retry after delay - logger.warning(f"Supabase server error (attempt {attempt+1}/{max_retries}): {response.status_code} - {response.text}") - time.sleep(retry_delay * (attempt + 1)) # Exponential backoff - - except requests.RequestException as e: - logger.warning(f"Supabase request exception (attempt {attempt+1}/{max_retries}): {str(e)}") - if attempt == max_retries - 1: - raise - time.sleep(retry_delay * (attempt + 1)) - - # If we get here, all retries failed with server errors - raise requests.RequestException(f"Failed after {max_retries} attempts to {method} {url}") def initialize_demo_users() -> Dict[str, Any]: - """Initialize demo users""" - logger.info("Starting demo users initialization...") - - supabase_url = os.getenv("SUPABASE_URL") - service_role_key = os.getenv("SERVICE_ROLE_KEY") - - if not supabase_url or not service_role_key: - return {"success": False, "message": "Missing SUPABASE_URL or SERVICE_ROLE_KEY environment variables"} - - initializer = DemoUsersInitializer(supabase_url, service_role_key) - - # Create demo users - result = initializer.create_demo_users() - - if result["success"]: - logger.info("Demo users initialization completed successfully") - else: - logger.error(f"Demo users initialization failed: {result['message']}") - - return result + """Create/refresh canonical @kevlarai.com demo users.""" + from neo4j import GraphDatabase + from modules.database.supabase.utils.client import SupabaseServiceRoleClient + + sb_client = SupabaseServiceRoleClient() + supabase_url = os.environ["SUPABASE_URL"] + service_key = os.environ["SERVICE_ROLE_KEY"] + auth_headers = { + "apikey": service_key, + "Authorization": f"Bearer {service_key}", + "Content-Type": "application/json", + } + + def auth_get(path, params=None): + r = requests.get(f"{supabase_url}/auth/v1/admin{path}", headers=auth_headers, params=params) + r.raise_for_status() + return r.json() + + def auth_post(path, data): + return requests.post(f"{supabase_url}/auth/v1/admin{path}", headers=auth_headers, json=data) + + def auth_delete(path): + return requests.delete(f"{supabase_url}/auth/v1/admin{path}", headers=auth_headers) + + def sb_upsert(table, data, on_conflict=None): + params = {} + if on_conflict: + params["on_conflict"] = on_conflict + hdrs = {**auth_headers, "Prefer": "resolution=merge-duplicates,return=representation"} + r = requests.post(f"{supabase_url}/rest/v1/{table}", headers=hdrs, json=data, params=params) + return r + + errors = [] + + # ── Step 1: delete stale .edu users ───────────────────────────────────── + logger.info("Removing stale .edu demo users...") + try: + existing = auth_get("/users", params={"per_page": 100}).get("users", []) + edu_users = [u for u in existing if u.get("email", "").endswith("@kevlarai.edu")] + for u in edu_users: + # Try direct delete first + r = auth_delete(f"/users/{u['id']}") + if r.status_code not in (200, 204): + # Profile has dependent rows — clean via SQL + _purge_profile_rows(supabase_url, service_key, u["id"]) + auth_delete(f"/users/{u['id']}") + logger.info(f" Removed: {u['email']}") + except Exception as e: + logger.warning(f" .edu cleanup warning: {e}") + + # ── Step 2: create @kevlarai.com users ─────────────────────────────────── + logger.info("Creating @kevlarai.com demo users...") + created_users = {} + for spec in DEMO_USERS: + email = spec["email"] + all_users = auth_get("/users", params={"per_page": 100}).get("users", []) + existing_user = next((u for u in all_users if u.get("email") == email), None) + + if existing_user: + uid = existing_user["id"] + logger.info(f" {email}: already exists [{uid[:8]}]") + created_users[email] = {"id": uid, **spec} + continue + + r = auth_post("/users", { + "email": email, + "password": spec["password"], + "email_confirm": True, + "user_metadata": { + "username": spec["username"], + "full_name": spec["full_name"], + "display_name": spec["display_name"], + "user_type": spec["user_type"], + }, + }) + if r.status_code in (200, 201): + uid = r.json()["id"] + logger.info(f" {email}: created [{uid[:8]}]") + created_users[email] = {"id": uid, **spec} + else: + msg = f"Failed to create {email}: {r.text[:200]}" + logger.error(f" {msg}") + errors.append(msg) + time.sleep(0.3) + + # ── Step 3: upsert profiles ────────────────────────────────────────────── + logger.info("Upserting profiles...") + for spec in DEMO_USERS: + u = created_users.get(spec["email"]) + if not u: + continue + sb_upsert("profiles", { + "id": u["id"], + "email": spec["email"], + "user_type": spec["user_type"], + "username": spec["username"], + "full_name": spec["full_name"], + "display_name": spec["display_name"], + "school_id": INSTITUTE_ID, + "neo4j_sync_status": "pending", + }, on_conflict="id") + + # ── Step 4: upsert memberships ─────────────────────────────────────────── + logger.info("Upserting institute memberships...") + for spec in DEMO_USERS: + u = created_users.get(spec["email"]) + if not u: + continue + sb_upsert("institute_memberships", { + "profile_id": u["id"], + "institute_id": INSTITUTE_ID, + "role": spec["role"], + }, on_conflict="profile_id,institute_id") + + # ── Step 5: Teacher nodes in Neo4j ─────────────────────────────────────── + logger.info("Creating Neo4j Teacher nodes...") + try: + driver = GraphDatabase.driver("bolt://192.168.0.209:7687", auth=("neo4j", "&%N304j&%")) + new_emails = {spec["email"] for spec in DEMO_USERS} + with driver.session(database=INSTITUTE_DB) as s: + # Remove stale Teacher nodes + stale = s.run("MATCH (t:Teacher) WHERE NOT t.worker_email IN $emails RETURN t.worker_email as e, t.uuid_string as u", emails=list(new_emails)).data() + for t in stale: + s.run("MATCH (t:Teacher {uuid_string: $u}) DETACH DELETE t", u=t["u"]) + logger.info(f" Removed stale Teacher: {t['e']}") + # Remove duplicate Teacher nodes (same email, different UUID) + for spec in DEMO_USERS: + u = created_users.get(spec["email"]) + if not u: + continue + dupes = s.run("MATCH (t:Teacher {worker_email: $e}) WHERE t.uuid_string <> $u RETURN t.uuid_string as uid", e=spec["email"], u=u["id"]).data() + for d in dupes: + s.run("MATCH (t:Teacher {uuid_string: $u}) DETACH DELETE t", u=d["uid"]) + logger.info(f" Removed duplicate Teacher UUID {d['uid'][:8]} for {spec['email']}") + # Upsert Teacher nodes + for spec in DEMO_USERS: + u = created_users.get(spec["email"]) + if not u: + continue + s.run(""" + MERGE (t:Teacher {uuid_string: $uuid}) + SET t.worker_email = $email, + t.worker_name = $name, + t.unique_id = $uid, + t.user_type = 'teacher', + t.worker_type = 'teacher' + """, uuid=u["id"], email=spec["email"], name=spec["full_name"], uid=u["id"]) + logger.info(f" Teacher node: {spec['email']} [{u['id'][:8]}]") + driver.close() + except Exception as e: + msg = f"Neo4j Teacher node setup failed: {e}" + logger.error(msg) + errors.append(msg) + + return { + "success": len(errors) == 0, + "created": list(created_users.keys()), + "errors": errors, + "message": "Demo users initialized" if not errors else f"{len(errors)} errors: {errors[0]}", + } + + +def _purge_profile_rows(supabase_url: str, service_key: str, profile_id: str) -> None: + """Delete all rows referencing a profile before deleting the auth user.""" + hdrs = {"apikey": service_key, "Authorization": f"Bearer {service_key}"} + for table, col in [("files", "uploaded_by"), ("whiteboard_rooms", "user_id"), + ("cabinet_memberships", "profile_id"), ("institute_memberships", "profile_id")]: + requests.delete(f"{supabase_url}/rest/v1/{table}?{col}=eq.{profile_id}", headers=hdrs)