351 lines
15 KiB
Python
351 lines
15 KiB
Python
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,
|
|
}
|