api/modules/database/services/provisioning_service.py
2025-11-14 14:47:19 +00:00

307 lines
12 KiB
Python

import json
import os
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)
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"),
)
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')
finally:
neon.close_neontology_connection()
# 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,
}