import json import os import time from datetime import datetime, timedelta from typing import Dict, Optional, Tuple, List from modules.logger_tool import initialise_logger from modules.database.services.neo4j_service import Neo4jService from modules.database.init import init_user from modules.database.tools.supabase_storage_tools import SupabaseStorageTools from modules.database.schemas.nodes.schools.schools import SchoolNode import modules.database.tools.neontology_tools as neon from modules.database.tools.neontology_tools import create_or_merge_neontology_node from modules.database.supabase.utils.client import SupabaseServiceRoleClient logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) _CC_USERS_DB = "cc.users" _CC_SCHOOLS_DB = "cc.institutes" _DEFAULT_INSTITUTE_NAME = os.getenv("DEFAULT_INSTITUTE_NAME", "KevlarAI") _DEFAULT_INSTITUTE_ID = os.getenv("DEFAULT_INSTITUTE_ID") class ProvisioningService: """Coordinates provisioning of Neo4j resources for schools and users.""" def __init__(self): self.neo4j_service = Neo4jService() self.supabase = SupabaseServiceRoleClient().supabase # ------------------------------------------------------------------ # Naming helpers # ------------------------------------------------------------------ @staticmethod def _sanitize_component(value: str) -> str: return "".join(ch for ch in value.lower() if ch.isalnum()) def _build_user_db_name(self, role: str, user_id: str) -> str: return f"{_CC_USERS_DB}.{self._sanitize_component(role)}.{self._sanitize_component(user_id)}" def _build_school_db_name(self, institute_id: str) -> str: return f"{_CC_SCHOOLS_DB}.{self._sanitize_component(institute_id)}" # ------------------------------------------------------------------ # Supabase helpers # ------------------------------------------------------------------ def _get_profile(self, user_id: str) -> Dict: response = ( self.supabase .table("profiles") .select("*") .eq("id", user_id) .single() .execute() ) if not response.data: raise ValueError(f"Profile {user_id} not found") return response.data def _get_membership(self, profile_id: str) -> Optional[Dict]: response = ( self.supabase .table("institute_memberships") .select("*") .eq("profile_id", profile_id) .limit(1) .execute() ) data = response.data or [] return data[0] if data else None def _get_institute(self, institute_id: str) -> Optional[Dict]: response = ( self.supabase .table("institutes") .select("*") .eq("id", institute_id) .single() .execute() ) return response.data if response and response.data else None def _get_institute_by_name(self, name: str) -> Optional[Dict]: response = ( self.supabase .table("institutes") .select("*") .eq("name", name) .limit(1) .execute() ) data = response.data or [] return data[0] if data else None def _determine_membership_role(self, user_type: str) -> str: if "teacher" in user_type: return "teacher" if "student" in user_type: return "student" return "staff" def _ensure_membership(self, profile: Dict, user_type: str) -> Optional[Dict]: membership = self._get_membership(profile["id"]) if membership: return membership institute_id = _DEFAULT_INSTITUTE_ID institute = None if institute_id: institute = self._get_institute(institute_id) if not institute: logger.warning(f"Default institute {_DEFAULT_INSTITUTE_ID} not found; attempting lookup by name") institute_id = None if not institute_id: institute = self._get_institute_by_name(_DEFAULT_INSTITUTE_NAME) if not institute: raise ValueError(f"Default institute '{_DEFAULT_INSTITUTE_NAME}' not found; cannot create membership") institute_id = institute["id"] role = self._determine_membership_role(user_type) try: response = ( self.supabase .table("institute_memberships") .insert({ "profile_id": profile["id"], "institute_id": institute_id, "role": role }) .execute() ) data = response.data or [] membership = data[0] if isinstance(data, list) and data else data email = profile.get("email") or profile.get("user_email") or profile.get("id") logger.info(f"Created institute membership for {email} -> {institute_id} as {role}") return membership except Exception as exc: logger.warning(f"Failed to create institute membership for {profile['id']}: {exc}") # Try to fetch again in case of race condition return self._get_membership(profile["id"]) # ------------------------------------------------------------------ # Provisioning actions # ------------------------------------------------------------------ def ensure_school(self, institute_id: str) -> Dict[str, str]: """Ensure the Neo4j databases and root nodes exist for a school.""" institute = self._get_institute(institute_id) if not institute: raise ValueError(f"Institute {institute_id} not found in Supabase") school_db = self._build_school_db_name(institute_id) curriculum_db = f"{school_db}.curriculum" # Ensure root namespaces exist self.neo4j_service.create_database(_CC_SCHOOLS_DB) self.neo4j_service.create_database(school_db) self.neo4j_service.create_database(curriculum_db) # Wait for databases to be fully available (Neo4j needs time to make new databases accessible) logger.info(f"Waiting for databases to be fully available...") time.sleep(2) # Initial wait # Verify databases exist with retries max_retries = 5 retry_delay = 1 for attempt in range(max_retries): try: # Check if school_db exists check_result = self.neo4j_service.check_database_exists(school_db) if check_result.get("exists", False): logger.info(f"Database {school_db} is available") break else: if attempt < max_retries - 1: logger.info(f"Database {school_db} not yet available, retrying in {retry_delay}s... (attempt {attempt + 1}/{max_retries})") time.sleep(retry_delay) else: logger.warning(f"Database {school_db} may not be fully available, proceeding anyway...") except Exception as e: logger.warning(f"Error checking database existence: {e}, proceeding anyway...") if attempt < max_retries - 1: time.sleep(retry_delay) metadata = institute.get("metadata") or {} if isinstance(metadata, str): try: metadata = json.loads(metadata) except json.JSONDecodeError: metadata = {} school_type = metadata.get("school_type") or institute.get("school_type") or "demo" school_node = SchoolNode( uuid_string=self._sanitize_component(institute_id), node_storage_path=f"schools/{self._sanitize_component(institute_id)}/databases/{school_db}/{self._sanitize_component(institute_id)}", school_type=self._sanitize_component(school_type) or "demo", name=institute.get("name", "Unknown School"), website=institute.get("website", "https://example.com"), ) # Retry node creation with exponential backoff max_node_retries = 3 for attempt in range(max_node_retries): try: neon.init_neontology_connection() try: create_or_merge_neontology_node(school_node, database=_CC_SCHOOLS_DB, operation='merge') create_or_merge_neontology_node(school_node, database=school_db, operation='merge') logger.info(f"Successfully created school nodes in databases") break # Success, exit retry loop finally: neon.close_neontology_connection() except Exception as e: if "Database" in str(e) and "not found" in str(e): if attempt < max_node_retries - 1: wait_time = (attempt + 1) * 2 # Exponential backoff: 2s, 4s, 6s logger.warning(f"Database not yet available, waiting {wait_time}s before retry (attempt {attempt + 1}/{max_node_retries}): {e}") time.sleep(wait_time) else: logger.error(f"Failed to create school nodes after {max_node_retries} attempts: {e}") raise else: # Different error, don't retry raise # Try to persist database references back to Supabase (best effort) updates = { "neo4j_private_db_name": school_db, "neo4j_private_sync_status": "ready", "neo4j_private_sync_at": datetime.utcnow().isoformat(), } try: ( self.supabase .table("institutes") .update(updates) .eq("id", institute_id) .execute() ) except Exception as exc: # pragma: no cover - defensive logging only logger.warning(f"Failed to update institute {institute_id} with db info: {exc}") return { "db_name": school_db, "curriculum_db_name": curriculum_db, "school_node": school_node, } def ensure_user(self, user_id: str) -> Dict[str, Optional[str]]: """Provision Neo4j resources for a specific user profile.""" profile = self._get_profile(user_id) user_type_raw = (profile.get("user_type") or "").lower() user_type_map = { "teacher": ("email_teacher", "teacher"), "email_teacher": ("email_teacher", "teacher"), "student": ("email_student", "student"), "email_student": ("email_student", "student"), "developer": ("developer", "developer"), "cc_developer": ("developer", "developer"), "admin": ("superadmin", "superadmin"), "super_admin": ("superadmin", "superadmin"), "superadmin": ("superadmin", "superadmin"), } neo_user_type, worker_type = user_type_map.get(user_type_raw, (user_type_raw or "standard", user_type_raw or "standard")) user_db_name = profile.get("user_db_name") if not user_db_name: user_db_name = self._build_user_db_name(worker_type, user_id) full_name = profile.get("full_name") or profile.get("display_name") or profile.get("username") or "User" username = profile.get("username") or self._sanitize_component(profile.get("email", "user")) user_email = profile.get("email") or profile.get("user_email") or "" school_db_name = profile.get("school_db_name") school_node = None membership = None if worker_type in ("teacher", "student"): membership = self._ensure_membership(profile, user_type_raw) if not membership: raise ValueError("Unable to determine institute membership for school-based user") if membership: institute_id = membership.get("institute_id") if institute_id: ensure_school_result = self.ensure_school(institute_id) school_db_name = ensure_school_result["db_name"] school_meta = ensure_school_result.get("school_node") if isinstance(school_meta, SchoolNode): school_node = school_meta else: school_node = SchoolNode( uuid_string=self._sanitize_component(institute_id), node_storage_path="", school_type=getattr(school_meta, "school_type", "demo") if school_meta else "demo", name=(school_meta.get("name") if isinstance(school_meta, dict) else None) or "Unknown School", website=(school_meta.get("website") if isinstance(school_meta, dict) else None) or "https://example.com", ) # Ensure base namespaces exist before creating user-specific db self.neo4j_service.create_database(_CC_USERS_DB) self.neo4j_service.create_database(user_db_name) calendar_start = datetime.utcnow().date() calendar_end = (datetime.utcnow() + timedelta(days=365)).date() # Initialize storage tools for user provisioning storage_tools = SupabaseStorageTools(user_db_name, init_run_type="user") init_user.create_user( user_id=user_id, user_type=neo_user_type, username=username, user_email=user_email, user_name=full_name, worker_name=full_name, worker_type=worker_type, worker_email=user_email, cc_users_db_name=_CC_USERS_DB, user_db_name=user_db_name, worker_db_name=school_db_name, calendar_start_date=calendar_start, calendar_end_date=calendar_end, school_node=school_node, storage_tools=storage_tools, ) profile_updates = { "user_db_name": user_db_name, "school_db_name": school_db_name, "neo4j_sync_status": "ready", "neo4j_synced_at": datetime.utcnow().isoformat(), } try: ( self.supabase .table("profiles") .update(profile_updates) .eq("id", user_id) .execute() ) except Exception as exc: # pragma: no cover - logging only logger.warning(f"Failed to update profile {user_id} with provisioning info: {exc}") return { "user_db_name": user_db_name, "worker_db_name": school_db_name, "worker_type": worker_type, }