This commit is contained in:
2025-11-14 14:47:19 +00:00
parent 2a85845835
commit 3758c7572a
137 changed files with 365654 additions and 11147 deletions
@@ -2,6 +2,7 @@ import os
from typing import Dict, List, Optional
from supabase import create_client
from modules.logger_tool import initialise_logger
from modules.database.services.provisioning_service import ProvisioningService
from pydantic import BaseModel
@@ -31,6 +32,7 @@ class AdminService:
"Authorization": f"Bearer {service_role_key}",
"Content-Type": "application/json",
}
self.provisioner = ProvisioningService()
def get_admin_profile(self, admin_id: str) -> Optional[Dict]:
"""Get admin profile by ID"""
@@ -88,6 +90,10 @@ class AdminService:
result = (
self.supabase.table("admin_profiles").insert(profile_data).execute()
)
try:
self.provisioner.ensure_user(profile_data["id"])
except Exception as exc:
self.logger.warning(f"Provisioning admin user {profile_data['id']} failed: {exc}")
return result.data[0] if result else None
except Exception as e:
@@ -186,6 +192,10 @@ class AdminService:
result = (
self.supabase.table("admin_profiles").insert(profile_data).execute()
)
try:
self.provisioner.ensure_user(profile_data["id"])
except Exception as exc:
self.logger.warning(f"Provisioning super admin {profile_data['id']} failed: {exc}")
return result.data[0] if result else None
except Exception as e:
@@ -1,67 +0,0 @@
import os
from typing import Dict, Any
from modules.logger_tool import initialise_logger
import modules.database.tools.neo4j_driver_tools as driver_tools
from modules.database.admin.neontology_provider import NeontologyProvider
from modules.database.admin.graph_provider import GraphNamingProvider
class GraphService:
def __init__(self):
self.logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
self.driver = driver_tools.get_driver()
self.neontology = NeontologyProvider()
self.graph_naming = GraphNamingProvider()
def check_schema_status(self, database_name: str = "neo4j") -> Dict[str, Any]:
"""Check the status of Neo4j schema including constraints, indexes, and labels"""
try:
with self.driver.session(database=database_name) as session:
# Check constraints
constraints_result = session.run("SHOW CONSTRAINTS")
constraints = list(constraints_result)
# Check indexes
indexes_result = session.run("SHOW INDEXES")
indexes = list(indexes_result)
# Check labels
labels_result = session.run("CALL db.labels()")
labels = list(labels_result)
return {
"constraints_count": len(constraints),
"indexes_count": len(indexes),
"labels_count": len(labels),
"constraints": [dict(record) for record in constraints],
"indexes": [dict(record) for record in indexes],
"labels": [dict(record) for record in labels]
}
except Exception as e:
self.logger.error(f"Error checking schema status: {str(e)}")
return {
"constraints_count": 0,
"indexes_count": 0,
"labels_count": 0,
"error": str(e)
}
def initialize_schema(self, database_name: str = "neo4j") -> Dict[str, Any]:
"""Initialize Neo4j schema with required constraints and indexes"""
try:
schema_queries = self.graph_naming.get_schema_creation_queries()
with self.driver.session(database=database_name) as session:
for query in schema_queries:
session.run(query)
return {
"status": "success",
"message": "Schema initialized successfully",
"details": self.check_schema_status(database_name)
}
except Exception as e:
self.logger.error(f"Error initializing schema: {str(e)}")
return {
"status": "error",
"message": str(e)
}
-45
View File
@@ -1,45 +0,0 @@
from datetime import datetime, timedelta
import jwt
from typing import Dict, List
class JWTService:
"""JWT Service for Neo4j authentication
TODO: Security Enhancements Needed
- Implement token refresh mechanism
- Add token revocation capability
- Add token validation checks
- Implement rate limiting
- Add audit logging for token generation/usage
- Consider reducing token expiry time and implementing refresh tokens
"""
def __init__(self, secret_key: str, algorithm: str = "HS256"):
self.secret_key = secret_key
self.algorithm = algorithm
def generate_neo4j_token(self, user_data: Dict) -> str:
"""Generate JWT token for Neo4j database access"""
payload = {
"sub": user_data["email"],
"roles": self._get_neo4j_roles(user_data["user_type"]),
"iss": "supabase",
"aud": "neo4j",
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + timedelta(hours=24)
}
if "school_uuid" in user_data:
payload["worker_db_name"] = f"cc.institutes.{user_data['school_uuid']}"
return jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
def _get_neo4j_roles(self, user_type: str) -> List[str]:
"""Map user types to Neo4j roles"""
role_mapping = {
"cc_admin": ["admin", "reader", "writer"],
"developer": ["developer", "reader", "writer"],
"email_teacher": ["teacher", "reader", "writer"],
"email_student": ["student", "reader"]
}
return role_mapping.get(user_type, ["reader"])
+12 -12
View File
@@ -86,18 +86,18 @@ class Neo4jService:
with self.driver.session(database=database_name) as session:
# Create constraints
constraints = [
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:School) REQUIRE n.unique_id IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Department) REQUIRE n.unique_id IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Subject) REQUIRE n.unique_id IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:YearGroup) REQUIRE n.unique_id IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Class) REQUIRE n.unique_id IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Teacher) REQUIRE n.unique_id IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Student) REQUIRE n.unique_id IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Calendar) REQUIRE n.unique_id IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Term) REQUIRE n.unique_id IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Week) REQUIRE n.unique_id IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Day) REQUIRE n.unique_id IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Period) REQUIRE n.unique_id IS UNIQUE"
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:School) REQUIRE n.uuid_string IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Department) REQUIRE n.uuid_string IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Subject) REQUIRE n.uuid_string IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:YearGroup) REQUIRE n.uuid_string IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Class) REQUIRE n.uuid_string IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Teacher) REQUIRE n.uuid_string IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Student) REQUIRE n.uuid_string IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Calendar) REQUIRE n.uuid_string IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Term) REQUIRE n.uuid_string IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Week) REQUIRE n.uuid_string IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Day) REQUIRE n.uuid_string IS UNIQUE",
"CREATE CONSTRAINT IF NOT EXISTS FOR (n:Period) REQUIRE n.uuid_string IS UNIQUE"
]
# Create indexes
@@ -0,0 +1,306 @@
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,
}
@@ -1,412 +0,0 @@
import os
from typing import Dict, Any, BinaryIO
import json
import pandas as pd
import modules.database.tools.neo4j_driver_tools as driver_tools
import modules.database.tools.neo4j_session_tools as session_tools
import modules.database.schemas.nodes.schools.schools as school_nodes
import modules.database.schemas.nodes.schools.curriculum as curriculum_nodes
import modules.database.schemas.nodes.schools.pastoral as pastoral_nodes
import modules.database.schemas.nodes.structures.schools as school_structures
from modules.database.schemas.entities import entities
from modules.database.schemas.relationships import curriculum_relationships, entity_relationships, entity_curriculum_rels
from modules.database.admin.neontology_provider import NeontologyProvider
from modules.database.admin.graph_provider import GraphNamingProvider
from modules.database.supabase.utils.client import SupabaseAnonClient
from modules.database.supabase.utils.storage import StorageManager
from modules.database.services.neo4j_service import Neo4jService
from modules.logger_tool import initialise_logger
class SchoolAdminService:
def __init__(self):
self.logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
self.driver = driver_tools.get_driver()
self.neontology = NeontologyProvider()
self.graph_naming = GraphNamingProvider()
self.storage = StorageManager(SupabaseAnonClient)
self.neo4j_service = Neo4jService()
def check_database_exists(self, database_name: str) -> Dict[str, Any]:
"""Check if a Neo4j database exists"""
return self.neo4j_service.check_database_exists(database_name)
def create_database(self, db_name: str) -> Dict:
"""Creates a Neo4j database with the given name"""
return self.neo4j_service.create_database(db_name)
def create_school_node(self, school_data: Dict) -> Dict:
"""Creates a school node in cc.institutes database and stores TLDraw file in Supabase"""
try:
# Convert school data to SchoolNode
school_unique_id = self.graph_naming.get_school_unique_id(school_data['urn'])
school_path = self.graph_naming.get_school_path("cc.institutes", school_data['urn'])
school_node = entities.SchoolNode(
unique_id=school_unique_id,
path=school_path,
urn=school_data['urn'],
establishment_number=school_data['establishment_number'],
establishment_name=school_data['establishment_name'],
establishment_type=school_data['establishment_type'],
establishment_status=school_data['establishment_status'],
phase_of_education=school_data['phase_of_education'] if school_data['phase_of_education'] not in [None, ''] else None,
statutory_low_age=int(school_data['statutory_low_age']) if school_data.get('statutory_low_age') is not None else 0,
statutory_high_age=int(school_data['statutory_high_age']) if school_data.get('statutory_high_age') is not None else 0,
religious_character=school_data.get('religious_character') if school_data.get('religious_character') not in [None, ''] else None,
school_capacity=int(school_data['school_capacity']) if school_data.get('school_capacity') is not None else 0,
school_website=school_data.get('school_website', ''),
ofsted_rating=school_data.get('ofsted_rating') if school_data.get('ofsted_rating') not in [None, ''] else None
)
# Create default tldraw file data
tldraw_data = {
"document": {
"version": 1,
"id": school_data['urn'],
"name": school_data['establishment_name'],
"meta": {
"created_at": "",
"updated_at": "",
"creator_id": "",
"is_template": False,
"is_snapshot": False,
"is_draft": False,
"template_id": None,
"snapshot_id": None,
"draft_id": None
}
},
"schema": {
"schemaVersion": 1,
"storeVersion": 4,
"recordVersions": {
"asset": {
"version": 1,
"subTypeKey": "type",
"subTypeVersions": {}
},
"camera": {
"version": 1
},
"document": {
"version": 2
},
"instance": {
"version": 22
},
"instance_page_state": {
"version": 5
},
"page": {
"version": 1
},
"shape": {
"version": 3,
"subTypeKey": "type",
"subTypeVersions": {
"cc-school-node": 1
}
},
"instance_presence": {
"version": 5
},
"pointer": {
"version": 1
}
}
},
"store": {
"document:document": {
"gridSize": 10,
"name": school_data['establishment_name'],
"meta": {},
"id": school_data['urn'],
"typeName": "document"
},
"page:page": {
"meta": {},
"id": "page",
"name": "Page 1",
"index": "a1",
"typeName": "page"
},
"shape:school-node": {
"x": 0,
"y": 0,
"rotation": 0,
"type": "cc-school-node",
"id": school_unique_id,
"parentId": "page",
"index": "a1",
"props": school_node.to_dict(),
"typeName": "shape"
},
"instance:instance": {
"id": "instance",
"currentPageId": "page",
"typeName": "instance"
},
"camera:camera": {
"x": 0,
"y": 0,
"z": 1,
"id": "camera",
"typeName": "camera"
}
}
}
# Store tldraw file in Supabase storage
file_path = f"{school_data['urn']}/tldraw.json"
file_options = {
"content-type": "application/json",
"x-upsert": "true",
"metadata": {
"establishment_urn": school_data['urn'],
"establishment_name": school_data['establishment_name']
}
}
# Upload file
self.storage.upload_file(
bucket_id="cc.institutes",
file_path=file_path,
file_data=json.dumps(tldraw_data).encode(),
content_type="application/json",
upsert=True
)
# Create node in Neo4j
with self.neontology as neo:
self.logger.info(f"Creating school node in Neo4j: {school_node.to_dict()}")
neo.create_or_merge_node(school_node, database="cc.institutes", operation="merge")
return {"status": "success", "node": school_node}
except Exception as e:
self.logger.error(f"Error creating school node: {str(e)}")
return {"status": "error", "message": str(e)}
def create_private_database(self, school_data: Dict) -> Dict:
"""Creates a private database for a specific school"""
try:
private_db_name = f"cc.institutes.{school_data['urn']}"
with self.driver.session() as session:
session_tools.create_database(session, private_db_name)
self.logger.info(f"Created private database {private_db_name}")
return {
"status": "success",
"message": f"Database {private_db_name} created successfully"
}
except Exception as e:
self.logger.error(f"Error creating private database: {str(e)}")
return {"status": "error", "message": str(e)}
def create_basic_structure(self, school_node: school_nodes.SchoolNode, database_name: str) -> Dict:
"""Creates basic structural nodes in the specified database"""
try:
# Create Department Structure node
department_structure_node_unique_id = f"DepartmentStructure_{school_node.unique_id}"
department_structure_node = entities.DepartmentStructureNode(
unique_id=department_structure_node_unique_id,
tldraw_snapshot=""
)
# Create Curriculum Structure node
curriculum_node = curriculum_nodes.CurriculumStructureNode(
unique_id=f"CurriculumStructure_{school_node.unique_id}",
tldraw_snapshot=""
)
# Create Pastoral Structure node
pastoral_node = school_structures.PastoralStructureNode(
unique_id=f"PastoralStructure_{school_node.unique_id}",
tldraw_snapshot=""
)
with self.neontology as neo:
# Create nodes
neo.create_or_merge_node(department_structure_node, database=str(database_name), operation='merge')
neo.create_or_merge_node(curriculum_node, database=str(database_name), operation='merge')
neo.create_or_merge_node(pastoral_node, database=str(database_name), operation='merge')
# Create relationships
neo.create_or_merge_relationship(
entity_relationships.SchoolHasDepartmentStructure(source=school_node, target=department_structure_node),
database=database_name, operation='merge'
)
neo.create_or_merge_relationship(
entity_curriculum_rels.SchoolHasCurriculumStructure(source=school_node, target=curriculum_node),
database=database_name, operation='merge'
)
neo.create_or_merge_relationship(
entity_curriculum_rels.SchoolHasPastoralStructure(source=school_node, target=pastoral_node),
database=database_name, operation='merge'
)
return {
"status": "success",
"message": "Basic structure created successfully",
"nodes": {
"department_structure": department_structure_node,
"curriculum_structure": curriculum_node,
"pastoral_structure": pastoral_node
}
}
except Exception as e:
self.logger.error(f"Error creating basic structure: {str(e)}")
return {"status": "error", "message": str(e)}
def create_detailed_structure(self, school_node: school_nodes.SchoolNode, database_name: str, excel_file: BinaryIO) -> Dict:
"""Creates detailed structural nodes from Excel file"""
try:
# Store Excel file in Supabase
file_path = f"{school_node.urn}/structure.xlsx"
# Upload Excel file
self.storage.upload_file(
bucket_id="cc.institutes",
file_path=file_path,
file_data=excel_file.read(),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
upsert=True
)
# Process Excel file
dataframes = pd.read_excel(excel_file, sheet_name=None)
# Get existing basic structure nodes
with self.neontology as neo:
result = neo.cypher_read("""
MATCH (s:School {unique_id: $school_id})
OPTIONAL MATCH (s)-[:HAS_DEPARTMENT_STRUCTURE]->(ds:DepartmentStructure)
OPTIONAL MATCH (s)-[:HAS_CURRICULUM_STRUCTURE]->(cs:CurriculumStructure)
OPTIONAL MATCH (s)-[:HAS_PASTORAL_STRUCTURE]->(ps:PastoralStructure)
RETURN ds, cs, ps
""", {"school_id": school_node.unique_id}, database=database_name)
if not result:
raise Exception("Basic structure not found")
department_structure = result['ds']
curriculum_structure = result['cs']
pastoral_structure = result['ps']
# Create departments and subjects
unique_departments = dataframes['keystagesyllabuses']['Department'].dropna().unique()
node_library = {}
with self.neontology as neo:
for department_name in unique_departments:
department_node = entities.DepartmentNode(
unique_id=f"Department_{school_node.unique_id}_{department_name.replace(' ', '_')}",
department_name=department_name,
tldraw_snapshot=""
)
neo.create_or_merge_node(department_node, database=database_name, operation='merge')
node_library[f'department_{department_name}'] = department_node
# Link to department structure
neo.create_or_merge_relationship(
entity_relationships.DepartmentStructureHasDepartment(
source=department_structure,
target=department_node
),
database=database_name,
operation='merge'
)
# Create year groups
year_groups = self.sort_year_groups(dataframes['yeargroupsyllabuses'])['YearGroup'].unique()
last_year_group_node = None
for year_group in year_groups:
numeric_year_group = pd.to_numeric(year_group, errors='coerce')
if pd.notna(numeric_year_group):
year_group_node = pastoral_nodes.YearGroupNode(
unique_id=f"YearGroup_{school_node.unique_id}_YGrp{int(numeric_year_group)}",
year_group=str(int(numeric_year_group)),
year_group_name=f"Year {int(numeric_year_group)}",
tldraw_snapshot=""
)
neo.create_or_merge_node(year_group_node, database=database_name, operation='merge')
node_library[f'year_group_{int(numeric_year_group)}'] = year_group_node
# Create sequential relationship
if last_year_group_node:
neo.create_or_merge_relationship(
curriculum_relationships.YearGroupFollowsYearGroup(
source=last_year_group_node,
target=year_group_node
),
database=database_name,
operation='merge'
)
last_year_group_node = year_group_node
# Link to pastoral structure
neo.create_or_merge_relationship(
curriculum_relationships.PastoralStructureIncludesYearGroup(
source=pastoral_structure,
target=year_group_node
),
database=database_name,
operation='merge'
)
# Create key stages
key_stages = dataframes['keystagesyllabuses']['KeyStage'].unique()
last_key_stage_node = None
for key_stage in sorted(key_stages):
key_stage_node = curriculum_nodes.KeyStageNode(
unique_id=f"KeyStage_{curriculum_structure.unique_id}_KStg{key_stage}",
key_stage_name=f"Key Stage {key_stage}",
key_stage=str(key_stage),
tldraw_snapshot=""
)
neo.create_or_merge_node(key_stage_node, database=database_name, operation='merge')
node_library[f'key_stage_{key_stage}'] = key_stage_node
# Create sequential relationship
if last_key_stage_node:
neo.create_or_merge_relationship(
curriculum_relationships.KeyStageFollowsKeyStage(
source=last_key_stage_node,
target=key_stage_node
),
database=database_name,
operation='merge'
)
last_key_stage_node = key_stage_node
# Link to curriculum structure
neo.create_or_merge_relationship(
curriculum_relationships.CurriculumStructureIncludesKeyStage(
source=curriculum_structure,
target=key_stage_node
),
database=database_name,
operation='merge'
)
return {
"status": "success",
"message": "Detailed structure created successfully",
"node_library": node_library
}
except Exception as e:
self.logger.error(f"Error creating detailed structure: {str(e)}")
return {"status": "error", "message": str(e)}
def sort_year_groups(self, df: pd.DataFrame) -> pd.DataFrame:
"""Helper function to sort year groups numerically"""
df = df.copy()
df['YearGroupNumeric'] = pd.to_numeric(df['YearGroup'], errors='coerce')
return df.sort_values(by='YearGroupNumeric')
-472
View File
@@ -1,472 +0,0 @@
import os
from typing import Dict, List, Optional, BinaryIO
import json
import pandas as pd
from backend.modules.database.schemas import entities
from modules.logger_tool import initialise_logger
from modules.database.tools.filesystem_tools import ClassroomCopilotFilesystem
import modules.database.tools.neo4j_driver_tools as driver_tools
import modules.database.tools.neo4j_session_tools as session_tools
from modules.database.admin.neontology_provider import NeontologyProvider
from modules.database.admin.graph_provider import GraphNamingProvider
from modules.database.schemas import curriculum_neo
from modules.database.schemas.relationships import curriculum_relationships, entity_relationships, entity_curriculum_rels
from modules.database.supabase.utils.storage import StorageManager
class SchoolService:
def __init__(self):
self.logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
self.driver = driver_tools.get_driver()
self.neontology = NeontologyProvider()
self.graph_naming = GraphNamingProvider()
self.storage = StorageManager()
def create_schools_database(self) -> Dict:
"""Creates the main cc.institutes database in Neo4j"""
try:
db_name = "cc.institutes"
with self.driver.session() as session:
session_tools.create_database(session, db_name)
self.logger.info(f"Created database {db_name}")
return {
"status": "success",
"message": f"Database {db_name} created successfully"
}
except Exception as e:
self.logger.error(f"Error creating schools database: {str(e)}")
return {"status": "error", "message": str(e)}
def create_school_node(self, school_data: Dict) -> Dict:
"""Creates a school node in cc.institutes database and stores TLDraw file in Supabase"""
try:
# Convert school data to SchoolNode
school_unique_id = self.graph_naming.get_school_unique_id(school_data['urn'])
school_path = self.graph_naming.get_school_path("cc.institutes", school_data['urn'])
school_node = entities.SchoolNode(
unique_id=school_unique_id,
path=school_path,
urn=school_data['urn'],
establishment_number=school_data['establishment_number'],
establishment_name=school_data['establishment_name'],
establishment_type=school_data['establishment_type'],
establishment_status=school_data['establishment_status'],
phase_of_education=school_data['phase_of_education'] if school_data['phase_of_education'] not in [None, ''] else None,
statutory_low_age=int(school_data['statutory_low_age']) if school_data.get('statutory_low_age') is not None else 0,
statutory_high_age=int(school_data['statutory_high_age']) if school_data.get('statutory_high_age') is not None else 0,
religious_character=school_data.get('religious_character') if school_data.get('religious_character') not in [None, ''] else None,
school_capacity=int(school_data['school_capacity']) if school_data.get('school_capacity') is not None else 0,
school_website=school_data.get('school_website', ''),
ofsted_rating=school_data.get('ofsted_rating') if school_data.get('ofsted_rating') not in [None, ''] else None
)
# Create default tldraw file data
tldraw_data = {
"document": {
"version": 1,
"id": school_data['urn'],
"name": school_data['establishment_name'],
"meta": {
"created_at": "",
"updated_at": "",
"creator_id": "",
"is_template": False,
"is_snapshot": False,
"is_draft": False,
"template_id": None,
"snapshot_id": None,
"draft_id": None
}
},
"schema": {
"schemaVersion": 1,
"storeVersion": 4,
"recordVersions": {
"asset": {
"version": 1,
"subTypeKey": "type",
"subTypeVersions": {}
},
"camera": {
"version": 1
},
"document": {
"version": 2
},
"instance": {
"version": 22
},
"instance_page_state": {
"version": 5
},
"page": {
"version": 1
},
"shape": {
"version": 3,
"subTypeKey": "type",
"subTypeVersions": {
"cc-school-node": 1
}
},
"instance_presence": {
"version": 5
},
"pointer": {
"version": 1
}
}
},
"store": {
"document:document": {
"gridSize": 10,
"name": school_data['establishment_name'],
"meta": {},
"id": school_data['urn'],
"typeName": "document"
},
"page:page": {
"meta": {},
"id": "page",
"name": "Page 1",
"index": "a1",
"typeName": "page"
},
"shape:school-node": {
"x": 0,
"y": 0,
"rotation": 0,
"type": "cc-school-node",
"id": school_unique_id,
"parentId": "page",
"index": "a1",
"props": school_node.to_dict(),
"typeName": "shape"
},
"instance:instance": {
"id": "instance",
"currentPageId": "page",
"typeName": "instance"
},
"camera:camera": {
"x": 0,
"y": 0,
"z": 1,
"id": "camera",
"typeName": "camera"
}
}
}
# Store tldraw file in Supabase storage
file_path = f"{school_data['urn']}/tldraw.json"
file_options = {
"content-type": "application/json",
"x-upsert": "true",
"metadata": {
"establishment_urn": school_data['urn'],
"establishment_name": school_data['establishment_name']
}
}
# Upload file
self.storage.upload_file(
bucket_id="cc.institutes",
file_path=file_path,
file_data=json.dumps(tldraw_data).encode(),
content_type="application/json",
upsert=True
)
# Create node in Neo4j
with self.neontology as neo:
self.logger.info(f"Creating school node in Neo4j: {school_node.to_dict()}")
neo.create_or_merge_node(school_node, database="cc.institutes", operation="merge")
return {"status": "success", "node": school_node}
except Exception as e:
self.logger.error(f"Error creating school node: {str(e)}")
return {"status": "error", "message": str(e)}
def create_private_database(self, school_data: Dict) -> Dict:
"""Creates a private database for a specific school"""
try:
private_db_name = f"cc.institutes.{school_data['urn']}"
with self.driver.session() as session:
session_tools.create_database(session, private_db_name)
self.logger.info(f"Created private database {private_db_name}")
return {
"status": "success",
"message": f"Database {private_db_name} created successfully"
}
except Exception as e:
self.logger.error(f"Error creating private database: {str(e)}")
return {"status": "error", "message": str(e)}
def create_basic_structure(self, school_node: entities.SchoolNode, database_name: str) -> Dict:
"""Creates basic structural nodes in the specified database"""
try:
# Create filesystem paths
fs_handler = ClassroomCopilotFilesystem(database_name, init_run_type="school")
# Create Department Structure node
department_structure_node_unique_id = f"DepartmentStructure_{school_node.unique_id}"
_, department_path = fs_handler.create_school_department_directory(school_node.path, "departments")
department_structure_node = entities.DepartmentStructureNode(
unique_id=department_structure_node_unique_id,
path=department_path
)
# Create Curriculum Structure node
_, curriculum_path = fs_handler.create_school_curriculum_directory(school_node.path)
curriculum_node = curriculum_neo.CurriculumStructureNode(
unique_id=f"CurriculumStructure_{school_node.unique_id}",
path=curriculum_path
)
# Create Pastoral Structure node
_, pastoral_path = fs_handler.create_school_pastoral_directory(school_node.path)
pastoral_node = curriculum_neo.PastoralStructureNode(
unique_id=f"PastoralStructure_{school_node.unique_id}",
path=pastoral_path
)
with self.neontology as neo:
# Create nodes
neo.create_or_merge_node(department_structure_node, database=str(database_name), operation='merge')
fs_handler.create_default_tldraw_file(department_structure_node.path, department_structure_node.to_dict())
neo.create_or_merge_node(curriculum_node, database=str(database_name), operation='merge')
fs_handler.create_default_tldraw_file(curriculum_node.path, curriculum_node.to_dict())
neo.create_or_merge_node(pastoral_node, database=database_name, operation='merge')
fs_handler.create_default_tldraw_file(pastoral_node.path, pastoral_node.to_dict())
# Create relationships
neo.create_or_merge_relationship(
entity_relationships.SchoolHasDepartmentStructure(source=school_node, target=department_structure_node),
database=database_name, operation='merge'
)
neo.create_or_merge_relationship(
entity_curriculum_rels.SchoolHasCurriculumStructure(source=school_node, target=curriculum_node),
database=database_name, operation='merge'
)
neo.create_or_merge_relationship(
entity_curriculum_rels.SchoolHasPastoralStructure(source=school_node, target=pastoral_node),
database=database_name, operation='merge'
)
return {
"status": "success",
"message": "Basic structure created successfully",
"nodes": {
"department_structure": department_structure_node,
"curriculum_structure": curriculum_node,
"pastoral_structure": pastoral_node
}
}
except Exception as e:
self.logger.error(f"Error creating basic structure: {str(e)}")
return {"status": "error", "message": str(e)}
def create_detailed_structure(self, school_node: entities.SchoolNode, database_name: str, excel_file: BinaryIO) -> Dict:
"""Creates detailed structural nodes from Excel file"""
try:
# Store Excel file in Supabase
file_path = f"{school_node.urn}/structure.xlsx"
# Upload Excel file
self.storage.upload_file(
bucket_id="cc.institutes",
file_path=file_path,
file_data=excel_file.read(),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
upsert=True
)
# Process Excel file
dataframes = pd.read_excel(excel_file, sheet_name=None)
# Get existing basic structure nodes
with self.neontology as neo:
result = neo.cypher_read("""
MATCH (s:School {unique_id: $school_id})
OPTIONAL MATCH (s)-[:HAS_DEPARTMENT_STRUCTURE]->(ds:DepartmentStructure)
OPTIONAL MATCH (s)-[:HAS_CURRICULUM_STRUCTURE]->(cs:CurriculumStructure)
OPTIONAL MATCH (s)-[:HAS_PASTORAL_STRUCTURE]->(ps:PastoralStructure)
RETURN ds, cs, ps
""", {"school_id": school_node.unique_id}, database=database_name)
if not result:
raise Exception("Basic structure not found")
department_structure = result['ds']
curriculum_structure = result['cs']
pastoral_structure = result['ps']
# Create departments and subjects
unique_departments = dataframes['keystagesyllabuses']['Department'].dropna().unique()
fs_handler = ClassroomCopilotFilesystem(database_name, init_run_type="school")
node_library = {}
with self.neontology as neo:
for department_name in unique_departments:
_, department_path = fs_handler.create_school_department_directory(school_node.path, department_name)
department_node = entities.DepartmentNode(
unique_id=f"Department_{school_node.unique_id}_{department_name.replace(' ', '_')}",
department_name=department_name,
path=department_path
)
neo.create_or_merge_node(department_node, database=database_name, operation='merge')
fs_handler.create_default_tldraw_file(department_node.path, department_node.to_dict())
node_library[f'department_{department_name}'] = department_node
# Link to department structure
neo.create_or_merge_relationship(
entity_relationships.DepartmentStructureHasDepartment(
source=department_structure,
target=department_node
),
database=database_name,
operation='merge'
)
# Create year groups
year_groups = self.sort_year_groups(dataframes['yeargroupsyllabuses'])['YearGroup'].unique()
last_year_group_node = None
for year_group in year_groups:
numeric_year_group = pd.to_numeric(year_group, errors='coerce')
if pd.notna(numeric_year_group):
_, year_group_path = fs_handler.create_pastoral_year_group_directory(
pastoral_structure.path,
str(int(numeric_year_group))
)
year_group_node = curriculum_neo.YearGroupNode(
unique_id=f"YearGroup_{school_node.unique_id}_YGrp{int(numeric_year_group)}",
year_group=str(int(numeric_year_group)),
year_group_name=f"Year {int(numeric_year_group)}",
path=year_group_path
)
neo.create_or_merge_node(year_group_node, database=database_name, operation='merge')
fs_handler.create_default_tldraw_file(year_group_node.path, year_group_node.to_dict())
node_library[f'year_group_{int(numeric_year_group)}'] = year_group_node
# Create sequential relationship
if last_year_group_node:
neo.create_or_merge_relationship(
curriculum_relationships.YearGroupFollowsYearGroup(
source=last_year_group_node,
target=year_group_node
),
database=database_name,
operation='merge'
)
last_year_group_node = year_group_node
# Link to pastoral structure
neo.create_or_merge_relationship(
curriculum_relationships.PastoralStructureIncludesYearGroup(
source=pastoral_structure,
target=year_group_node
),
database=database_name,
operation='merge'
)
# Create key stages
key_stages = dataframes['keystagesyllabuses']['KeyStage'].unique()
last_key_stage_node = None
for key_stage in sorted(key_stages):
_, key_stage_path = fs_handler.create_curriculum_key_stage_directory(
curriculum_structure.path,
str(key_stage)
)
key_stage_node = curriculum_neo.KeyStageNode(
unique_id=f"KeyStage_{curriculum_structure.unique_id}_KStg{key_stage}",
key_stage_name=f"Key Stage {key_stage}",
key_stage=str(key_stage),
path=key_stage_path
)
neo.create_or_merge_node(key_stage_node, database=database_name, operation='merge')
fs_handler.create_default_tldraw_file(key_stage_node.path, key_stage_node.to_dict())
node_library[f'key_stage_{key_stage}'] = key_stage_node
# Create sequential relationship
if last_key_stage_node:
neo.create_or_merge_relationship(
curriculum_relationships.KeyStageFollowsKeyStage(
source=last_key_stage_node,
target=key_stage_node
),
database=database_name,
operation='merge'
)
last_key_stage_node = key_stage_node
# Link to curriculum structure
neo.create_or_merge_relationship(
curriculum_relationships.CurriculumStructureIncludesKeyStage(
source=curriculum_structure,
target=key_stage_node
),
database=database_name,
operation='merge'
)
return {
"status": "success",
"message": "Detailed structure created successfully",
"node_library": node_library
}
except Exception as e:
self.logger.error(f"Error creating detailed structure: {str(e)}")
return {"status": "error", "message": str(e)}
def sort_year_groups(self, df: pd.DataFrame) -> pd.DataFrame:
"""Helper function to sort year groups numerically"""
df = df.copy()
df['YearGroupNumeric'] = pd.to_numeric(df['YearGroup'], errors='coerce')
return df.sort_values(by='YearGroupNumeric')
def check_schools_database(self) -> Dict:
"""Check if the schools database exists and has been initialized"""
try:
db_name = "cc.institutes"
with self.driver.session() as session:
# Check if database exists
databases = session_tools.list_databases(session)
if db_name not in databases:
return {
"status": "error",
"message": f"Database {db_name} does not exist"
}
# Check if database has any nodes (indicating it's been initialized)
session.run("USE " + db_name)
result = session.run("MATCH (n) RETURN count(n) as count").single()
node_count = result["count"] if result else 0
if node_count == 0:
return {
"status": "error",
"message": f"Database {db_name} exists but has no nodes"
}
return {
"status": "success",
"message": f"Database {db_name} exists and has {node_count} nodes"
}
except Exception as e:
self.logger.error(f"Error checking schools database: {str(e)}")
return {"status": "error", "message": str(e)}