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
@@ -27,29 +27,29 @@ async def get_calendar_structure(db_name: str) -> Dict[str, Any]:
// Collect all nodes with dates converted to strings
RETURN {
years: collect(DISTINCT {
id: y.unique_id,
path: y.path,
id: y.uuid_string,
path: y.node_storage_path,
date: toString(y.date),
__primarylabel__: 'CalendarYear'
}),
months: collect(DISTINCT {
id: m.unique_id,
path: m.path,
id: m.uuid_string,
path: m.node_storage_path,
date: toString(m.date),
__primarylabel__: 'CalendarMonth'
}),
weeks: collect(DISTINCT {
id: w.unique_id,
path: w.path,
id: w.uuid_string,
path: w.node_storage_path,
date: toString(w.date),
__primarylabel__: 'CalendarWeek'
}),
days: collect(DISTINCT {
id: d.unique_id,
path: d.path,
id: d.uuid_string,
path: d.node_storage_path,
date: toString(d.date),
week_id: w.unique_id,
month_id: m.unique_id,
week_id: w.uuid_string,
month_id: m.uuid_string,
__primarylabel__: 'CalendarDay'
})
} as structure
@@ -98,11 +98,11 @@ async def get_calendar_days(db_name: str, start_date: str, end_date: str) -> Dic
OPTIONAL MATCH (w:CalendarWeek)-[:WEEK_INCLUDES_DAY]->(d)
OPTIONAL MATCH (m:CalendarMonth)-[:MONTH_INCLUDES_DAY]->(d)
RETURN {
id: d.unique_id,
path: d.path,
id: d.uuid_string,
path: d.node_storage_path,
date: d.date,
week_id: w.unique_id,
month_id: m.unique_id,
week_id: w.uuid_string,
month_id: m.uuid_string,
__primarylabel__: 'CalendarDay'
} as day
ORDER BY d.date
@@ -132,10 +132,10 @@ async def get_calendar_weeks(db_name: str, start_date: str, end_date: str) -> Di
WHERE date(w.date) >= date($start_date) AND date(w.date) <= date($end_date)
WITH w, collect(d) as days
RETURN {
id: w.unique_id,
path: w.path,
id: w.uuid_string,
path: w.node_storage_path,
date: w.date,
day_ids: [day in days | day.unique_id],
day_ids: [day in days | day.uuid_string],
__primarylabel__: 'CalendarWeek'
} as week
ORDER BY w.date
@@ -165,10 +165,10 @@ async def get_calendar_months(db_name: str, start_date: str, end_date: str) -> D
WHERE date(m.date) >= date($start_date) AND date(m.date) <= date($end_date)
WITH m, collect(d) as days
RETURN {
id: m.unique_id,
path: m.path,
id: m.uuid_string,
path: m.node_storage_path,
date: m.date,
day_ids: [day in days | day.unique_id],
day_ids: [day in days | day.uuid_string],
__primarylabel__: 'CalendarMonth'
} as month
ORDER BY m.date
@@ -197,10 +197,10 @@ async def get_calendar_years(db_name: str) -> Dict[str, Any]:
MATCH (y:CalendarYear)-[:YEAR_INCLUDES_MONTH]->(m:CalendarMonth)
WITH y, collect(m) as months
RETURN {
id: y.unique_id,
path: y.path,
id: y.uuid_string,
path: y.node_storage_path,
date: y.date,
month_ids: [month in months | month.unique_id],
month_ids: [month in months | month.uuid_string],
__primarylabel__: 'CalendarYear'
} as year
ORDER BY y.date
+37 -3
View File
@@ -47,7 +47,7 @@ def get_default_node_week(db_name: str) -> Dict[str, Any]:
return {
"status": "success",
"node": {
"id": node["unique_id"],
"id": node["uuid_string"],
"path": node["path"],
"type": "CalendarWeek",
"label": node.get("title", "Calendar Week"),
@@ -81,7 +81,7 @@ def get_default_node_month(db_name: str) -> Dict[str, Any]:
return {
"status": "success",
"node": {
"id": node["unique_id"],
"id": node["uuid_string"],
"path": node["path"],
"type": "CalendarMonth",
"label": node.get("title", "Calendar Month"),
@@ -89,6 +89,39 @@ def get_default_node_month(db_name: str) -> Dict[str, Any]:
}
}
@router.get("/debug-list-nodes")
async def debug_list_nodes(db_name: str) -> Dict[str, Any]:
"""Debug endpoint to list all nodes in a database."""
try:
with driver_tools.get_session(database=db_name) as session:
query = """
MATCH (n)
RETURN labels(n) as labels, n.uuid_string as uuid, n.user_name as name, n.cc_username as username
LIMIT 20
"""
result = session.run(query)
nodes = []
for record in result:
nodes.append({
"labels": list(record["labels"]),
"uuid": record["uuid"],
"name": record["name"],
"username": record["username"]
})
return {
"status": "success",
"db_name": db_name,
"node_count": len(nodes),
"nodes": nodes
}
except Exception as e:
return {
"status": "error",
"db_name": db_name,
"error": str(e)
}
@router.get("/get-default-node/{context}")
async def get_default_node(context: str, db_name: str, base_context: str | None = None) -> Dict[str, Any]:
"""Get the default node for a given context."""
@@ -244,8 +277,9 @@ async def get_default_node(context: str, db_name: str, base_context: str | None
return {
"status": "success",
"node": {
"id": node["unique_id"],
"id": node["uuid_string"],
"path": node["path"],
"node_storage_path": node.get("node_storage_path", node["path"]),
"type": list(node.labels)[0],
"label": node.get("title", ""),
"data": converted_data
+6 -6
View File
@@ -51,10 +51,10 @@ router = APIRouter()
@router.get("/get_teacher_timetable_events")
async def get_teacher_timetable_events(
unique_id: str,
uuid_string: str,
worker_db_name: str
):
logging.info(f"Getting timetable events for teacher {unique_id} from database {worker_db_name}")
logging.info(f"Getting timetable events for teacher {uuid_string} from database {worker_db_name}")
neo_driver = driver.get_driver(db_name=worker_db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
@@ -62,9 +62,9 @@ async def get_teacher_timetable_events(
try:
with neo_driver.session(database=worker_db_name) as neo_session:
query = """
MATCH (t:Teacher {unique_id: $unique_id})-[:TEACHER_HAS_TIMETABLE]->(tt:TeacherTimetable)
MATCH (t:Teacher {uuid_string: $uuid_string})-[:TEACHER_HAS_TIMETABLE]->(tt:TeacherTimetable)
-[:TIMETABLE_HAS_CLASS]->(sc:SubjectClass)-[:CLASS_HAS_LESSON]->(tl:TimetableLesson)
RETURN tl.unique_id as id,
RETURN tl.uuid_string as id,
tl.period_code as period_code,
COALESCE(sc.subject_class_code, 'Untitled Class') as subject_class,
tl.date as date,
@@ -72,7 +72,7 @@ async def get_teacher_timetable_events(
tl.end_time as end_time,
tl.path as path
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
events = []
for record in result:
@@ -92,7 +92,7 @@ async def get_teacher_timetable_events(
"path": record['path']
}
})
logging.info(f"Found {len(events)} events for teacher {unique_id}")
logging.info(f"Found {len(events)} events for teacher {uuid_string}")
return {"status": "success", "events": events}
except Exception as e:
logging.error(f"Error fetching events: {str(e)}")
+49 -45
View File
@@ -25,18 +25,18 @@ from fastapi import APIRouter, HTTPException, Query
router = APIRouter()
@router.get("/get-node")
async def get_node(unique_id: str = Query(...), db_name: str = Query(...)):
logging.info(f"Getting node for {unique_id} from database {db_name}")
async def get_node(uuid_string: str = Query(...), db_name: str = Query(...)):
logging.info(f"Getting node for {uuid_string} from database {db_name}")
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
try:
with neo_driver.session(database=db_name) as neo_session:
query = """
MATCH (n {unique_id: $unique_id})
MATCH (n {uuid_string: $uuid_string})
RETURN n
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
record = result.single()
if record:
@@ -47,11 +47,23 @@ async def get_node(unique_id: str = Query(...), db_name: str = Query(...)):
try:
# Convert node based on its type
node_type = node_labels[0] if node_labels else "Unknown"
if node_type in globals():
node_class = globals()[f"{node_type}Node"]
logging.debug(f"Attempting to convert node of type: {node_type}")
logging.debug(f"Available node classes: {[name for name in globals() if name.endswith('Node')]}")
logging.debug(f"UserNode in globals: {'UserNode' in globals()}")
logging.debug(f"UserNode class: {UserNode}")
logging.debug(f"UserNode class name: {UserNode.__name__}")
# Try to find the node class
node_class_name = f"{node_type}Node"
if node_class_name in globals():
node_class = globals()[node_class_name]
logging.debug(f"Found node class: {node_class}")
node_object = node_class(**node_data)
node_dict = node_object.to_dict()
logging.debug(f"Successfully converted node to dict: {node_dict}")
else:
logging.warning(f"No node class found for type: {node_type} (looking for {node_class_name}), using raw data")
logging.debug(f"Available classes: {[name for name in globals() if 'Node' in name]}")
node_dict = node_data
return {
@@ -101,8 +113,8 @@ async def get_user_node(user_id: str = Query(...)):
driver.close_driver(neo_driver)
@router.get("/get-connected-nodes")
async def get_connected_nodes(unique_id: str = Query(...), db_name: str = Query(...)):
logging.info(f"Getting connected nodes for {unique_id} from database {db_name}")
async def get_connected_nodes(uuid_string: str = Query(...), db_name: str = Query(...)):
logging.info(f"Getting connected nodes for {uuid_string} from database {db_name}")
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
@@ -110,11 +122,11 @@ async def get_connected_nodes(unique_id: str = Query(...), db_name: str = Query(
try:
with neo_driver.session(database=db_name) as neo_session:
query = """
MATCH (n {unique_id: $unique_id})
MATCH (n {uuid_string: $uuid_string})
OPTIONAL MATCH (n)-[]-(connected)
RETURN n, collect(connected) as connected_nodes
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
record = result.single()
if record:
main_node = record['n']
@@ -171,15 +183,15 @@ async def get_connected_nodes(unique_id: str = Query(...), db_name: str = Query(
driver.close_driver(neo_driver)
@router.get("/get-user-connected-nodes")
async def get_user_connected_nodes(unique_id: str = Query(...)):
logging.info(f"Getting user adjacent nodes for node {unique_id}")
async def get_user_connected_nodes(uuid_string: str = Query(...)):
logging.info(f"Getting user adjacent nodes for node {uuid_string}")
db_name = os.getenv("NEO4J_DB_NAME", "cc.institutes.kevlarai") # TODO: This function needs to be able to take a db_name as a parameter
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
raise HTTPException(status_code=500, detail="Failed to connect to the database")
try:
with neo_driver.session(database=db_name) as neo_session:
user_node_and_connected_nodes = session.get_node_by_unique_id_and_adjacent_nodes(neo_session, unique_id)
user_node_and_connected_nodes = session.get_node_by_uuid_string_and_adjacent_nodes(neo_session, uuid_string)
user_node = user_node_and_connected_nodes['node']
connected_nodes = user_node_and_connected_nodes['connected_nodes']
try:
@@ -251,15 +263,15 @@ async def get_user_connected_nodes(unique_id: str = Query(...)):
driver.close_driver(neo_driver)
@router.get("/get-worker-connected-nodes")
async def get_worker_connected_nodes(unique_id: str = Query(...)):
logging.info(f"Getting worker adjacent nodes for node {unique_id}")
async def get_worker_connected_nodes(uuid_string: str = Query(...)):
logging.info(f"Getting worker adjacent nodes for node {uuid_string}")
db_name = os.getenv("NEO4J_DB_NAME", "cc.institutes.kevlarai") # TODO: This function needs to be able to take a db_name as a parameter
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
raise HTTPException(status_code=500, detail="Failed to connect to the database")
try:
with neo_driver.session(database=db_name) as neo_session:
node_and_connected_nodes = session.get_node_by_unique_id_and_adjacent_nodes(neo_session, unique_id)
node_and_connected_nodes = session.get_node_by_uuid_string_and_adjacent_nodes(neo_session, uuid_string)
worker_node = node_and_connected_nodes['node']
connected_nodes = node_and_connected_nodes['connected_nodes']
try:
@@ -319,9 +331,9 @@ async def get_worker_connected_nodes(unique_id: str = Query(...)):
driver.close_driver(neo_driver)
@router.get("/get-calendar-connected-nodes")
async def get_calendar_connected_nodes(unique_id: str = Query(...)):
async def get_calendar_connected_nodes(uuid_string: str = Query(...)):
db_name = os.getenv("NEO4J_DB_NAME", "cc.institutes.kevlarai")
logging.info(f"Getting connected nodes for calendar {unique_id} from database {db_name}")
logging.info(f"Getting connected nodes for calendar {uuid_string} from database {db_name}")
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
@@ -330,11 +342,11 @@ async def get_calendar_connected_nodes(unique_id: str = Query(...)):
with neo_driver.session(database=db_name) as neo_session:
query = """
MATCH (n)
WHERE n.unique_id = $unique_id AND (n:Calendar OR n:CalendarYear OR n:CalendarMonth OR n:CalendarWeek OR n:CalendarDay OR n:CalendarTimeChunk)
WHERE n.uuid_string = $uuid_string AND (n:Calendar OR n:CalendarYear OR n:CalendarMonth OR n:CalendarWeek OR n:CalendarDay OR n:CalendarTimeChunk)
OPTIONAL MATCH (n)-[]-(connected)
RETURN n, collect(connected) as connected_nodes
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
record = result.single()
if record:
calendar_node = record['n']
@@ -369,9 +381,9 @@ async def get_calendar_connected_nodes(unique_id: str = Query(...)):
driver.close_driver(neo_driver)
@router.get("/get-teacher-timetable-connected-nodes")
async def get_teacher_timetable_connected_nodes(unique_id: str = Query(...)):
async def get_teacher_timetable_connected_nodes(uuid_string: str = Query(...)):
db_name = os.getenv("NEO4J_DB_NAME", "cc.institutes.kevlarai")
logging.info(f"Getting connected nodes for teacher timetable {unique_id} from database {db_name}")
logging.info(f"Getting connected nodes for teacher timetable {uuid_string} from database {db_name}")
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
@@ -379,11 +391,11 @@ async def get_teacher_timetable_connected_nodes(unique_id: str = Query(...)):
try:
with neo_driver.session(database=db_name) as neo_session:
query = """
MATCH (n:TeacherTimetable {unique_id: $unique_id})
MATCH (n:TeacherTimetable {uuid_string: $uuid_string})
OPTIONAL MATCH (n)-[]-(connected)
RETURN n, collect(connected) as connected_nodes
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
record = result.single()
if record:
teacher_timetable_node = record['n']
@@ -422,9 +434,9 @@ async def get_teacher_timetable_connected_nodes(unique_id: str = Query(...)):
driver.close_driver(neo_driver)
@router.get("/get-school-timetable-connected-nodes")
async def get_school_timetable_connected_nodes(unique_id: str = Query(...)):
async def get_school_timetable_connected_nodes(uuid_string: str = Query(...)):
db_name = os.getenv("NEO4J_DB_NAME", "cc.institutes.kevlarai")
logging.info(f"Getting connected nodes for school timetable {unique_id} from database {db_name}")
logging.info(f"Getting connected nodes for school timetable {uuid_string} from database {db_name}")
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
@@ -432,11 +444,11 @@ async def get_school_timetable_connected_nodes(unique_id: str = Query(...)):
try:
with neo_driver.session(database=db_name) as neo_session:
query = """
MATCH (n:SchoolTimetable {unique_id: $unique_id})
MATCH (n:SchoolTimetable {uuid_string: $uuid_string})
OPTIONAL MATCH (n)-[]-(connected)
RETURN n, collect(connected) as connected_nodes
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
record = result.single()
if record:
school_timetable_node = record['n']
@@ -483,9 +495,9 @@ async def get_school_timetable_connected_nodes(unique_id: str = Query(...)):
driver.close_driver(neo_driver)
@router.get("/get-curriculum-connected-nodes")
async def get_curriculum_connected_nodes(unique_id: str = Query(...)):
async def get_curriculum_connected_nodes(uuid_string: str = Query(...)):
db_name = os.getenv("NEO4J_DB_NAME", "cc.institutes.kevlarai")
logging.info(f"Getting connected nodes for curriculum {unique_id} from database {db_name}")
logging.info(f"Getting connected nodes for curriculum {uuid_string} from database {db_name}")
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
@@ -494,11 +506,11 @@ async def get_curriculum_connected_nodes(unique_id: str = Query(...)):
with neo_driver.session(database=db_name) as neo_session:
query = """
MATCH (n)
WHERE n.unique_id = $unique_id AND (n:PastoralStructure OR n:YearGroup OR n:CurriculumStructure OR n:KeyStage OR n:KeyStageSyllabus OR n:YearGroupSyllabus OR n:Subject OR n:Topic OR n:TopicLesson OR n:LearningStatement OR n:ScienceLab)
WHERE n.uuid_string = $uuid_string AND (n:PastoralStructure OR n:YearGroup OR n:CurriculumStructure OR n:KeyStage OR n:KeyStageSyllabus OR n:YearGroupSyllabus OR n:Subject OR n:Topic OR n:TopicLesson OR n:LearningStatement OR n:ScienceLab)
OPTIONAL MATCH (n)-[]-(connected)
RETURN n, collect(connected) as connected_nodes
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
record = result.single()
if record:
curriculum_node = record['n']
@@ -533,26 +545,18 @@ async def get_curriculum_connected_nodes(unique_id: str = Query(...)):
driver.close_driver(neo_driver)
@router.get("/get-school-node")
async def get_school_node(school_uuid: str = Query(...)):
logging.info(f"Getting school node for school {school_uuid}...")
db_name = f"cc.institutes.{school_uuid}"
async def get_school_node(school_uuid_string: str = Query(...)):
logging.info(f"Getting school node for school {school_uuid_string}...")
db_name = f"cc.institutes.{school_uuid_string}"
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
try:
with neo_driver.session(database=db_name) as neo_session:
nodes = session.find_nodes_by_label_and_properties(neo_session, "School", {"school_uuid": school_uuid})
nodes = session.find_nodes_by_label_and_properties(neo_session, "School", {"uuid_string": school_uuid_string})
if nodes:
school_node = nodes[0]
data = SchoolNode(
unique_id=school_node["unique_id"],
school_uuid=school_node["school_uuid"],
school_name=school_node["school_name"],
school_website=school_node["school_website"],
path=school_node["path"]
)
school_node_data = data.to_dict()
school_node_data = SchoolNode(**nodes[0]).to_dict()
return {"status": "success", "school_node": school_node_data, "school_node_raw": nodes}
else:
return {"status": "not_found", "message": "School node not found"}
@@ -89,8 +89,8 @@ async def get_all_nodes_and_edges():
@router.get("/get-connected-nodes-and-edges")
async def get_connected_nodes_and_edges(unique_id: str = Query(...), db_name: str = Query(...)):
logging.info(f"Getting connected nodes and edges for {unique_id} from database {db_name}")
async def get_connected_nodes_and_edges(uuid_string: str = Query(...), db_name: str = Query(...)):
logging.info(f"Getting connected nodes and edges for {uuid_string} from database {db_name}")
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
@@ -98,11 +98,11 @@ async def get_connected_nodes_and_edges(unique_id: str = Query(...), db_name: st
try:
with neo_driver.session(database=db_name) as neo_session:
query = """
MATCH (n {unique_id: $unique_id})
MATCH (n {uuid_string: $uuid_string})
OPTIONAL MATCH (n)-[r]-(connected)
RETURN n, collect(connected) as connected_nodes, collect(r) as relationships
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
record = result.single()
if record:
main_node = record['n']
+218 -51
View File
@@ -34,20 +34,24 @@ async def read_tldraw_user_node_file(user_node: UserNode):
logging.debug(f"Filesystem root path: {fs.root_path}")
# Handle path based on environment
if os.getenv("DEV_MODE") == "true":
# In dev mode, use the full system path from the node
if not user_node.path:
raise HTTPException(status_code=400, detail="Node path not found")
logging.debug(f"Using DEV_MODE path: {user_node.path}")
base_path = os.path.normpath(user_node.path)
# Use the path directly as provided - it represents the structure from root
if not user_node.node_storage_path:
raise HTTPException(status_code=400, detail="Node path not found")
# The path might already contain parts of the filesystem structure
# We need to construct the full path carefully
if user_node.node_storage_path.startswith("users/"):
# If path starts with users/, remove it since filesystem already has users/ structure
base_path = user_node.node_storage_path[6:] # Remove "users/" prefix
logging.debug(f"Removed 'users/' prefix, base_path is now: {base_path}")
else:
# In prod mode, construct path using formatted email
logging.warning(f"Using db_name as base path not ready in prod: {db_name}")
base_path = formatted_email
base_path = user_node.node_storage_path
logging.debug(f"No 'users/' prefix found, using path as-is: {base_path}")
base_path = os.path.normpath(base_path)
logging.debug(f"Using base path: {base_path}")
# Construct final path including tldraw file
logging.debug(f"Base path: {base_path}")
file_path = os.path.join(base_path, "tldraw_file.json")
logging.debug(f"File path: {file_path}")
file_location = os.path.normpath(os.path.join(fs.root_path, file_path))
@@ -68,8 +72,30 @@ async def read_tldraw_user_node_file(user_node: UserNode):
logging.error(f"Error reading file: {e}")
raise HTTPException(status_code=500, detail="Error reading file")
else:
logging.debug(f"File does not exist: {file_location}")
raise HTTPException(status_code=404, detail="File not found")
# Check if directory exists
directory_location = os.path.dirname(file_location)
if os.path.exists(directory_location):
logging.debug(f"Directory exists but file doesn't, creating default tldraw file at: {file_location}")
try:
# Create default tldraw content
default_tldraw_content = create_default_tldraw_content()
# Ensure directory exists (should already exist, but just in case)
os.makedirs(directory_location, exist_ok=True)
# Write the default file
with open(file_location, "w") as file:
json.dump(default_tldraw_content, file, indent=4)
logging.info(f"Default tldraw file created at: {file_location}")
return default_tldraw_content
except Exception as e:
logging.error(f"Error creating default tldraw file: {e}")
raise HTTPException(status_code=500, detail="Error creating default tldraw file")
else:
logging.debug(f"Neither directory nor file exists: {directory_location}")
raise HTTPException(status_code=404, detail="Directory not found")
@router.post("/set_tldraw_user_node_file")
async def set_tldraw_user_node_file(user_node: UserNode, data: Dict):
@@ -81,15 +107,22 @@ async def set_tldraw_user_node_file(user_node: UserNode, data: Dict):
fs = ClassroomCopilotFilesystem(db_name=db_name, init_run_type="user")
# Handle path based on environment
if os.getenv("ENVIRONMENT") == "dev":
# In dev mode, use the full system path from the node
if not user_node.path:
raise HTTPException(status_code=400, detail="Node path not found")
base_path = os.path.normpath(user_node.path)
# Use the path directly as provided - it represents the structure from root
if not user_node.node_storage_path:
raise HTTPException(status_code=400, detail="Node path not found")
# The path might already contain parts of the filesystem structure
# We need to construct the full path carefully
if user_node.node_storage_path.startswith("users/"):
# If path starts with users/, remove it since filesystem already has users/ structure
base_path = user_node.node_storage_path[6:] # Remove "users/" prefix
logging.debug(f"Removed 'users/' prefix, base_path is now: {base_path}")
else:
# In prod mode, construct path using formatted email
base_path = formatted_email
base_path = user_node.node_storage_path
logging.debug(f"No 'users/' prefix found, using path as-is: {base_path}")
base_path = os.path.normpath(base_path)
logging.debug(f"Using base path: {base_path}")
# Construct final path including tldraw file
file_path = os.path.join(base_path, "tldraw_file.json")
@@ -99,11 +132,15 @@ async def set_tldraw_user_node_file(user_node: UserNode, data: Dict):
try:
# Ensure directory exists
os.makedirs(os.path.dirname(file_location), exist_ok=True)
directory_location = os.path.dirname(file_location)
os.makedirs(directory_location, exist_ok=True)
logging.debug(f"Ensured directory exists: {directory_location}")
# Write the file
with open(file_location, "w") as file:
json.dump(data, file)
json.dump(data, file, indent=4)
logging.info(f"tldraw file successfully written to: {file_location}")
return {"status": "success"}
except Exception as e:
logging.error(f"Error writing file: {e}")
@@ -112,29 +149,38 @@ async def set_tldraw_user_node_file(user_node: UserNode, data: Dict):
@router.get("/get_tldraw_node_file")
async def read_tldraw_node_file(path: str, db_name: str):
logging.debug(f"Reading tldraw file for path: {path}")
logging.debug(f"Database name: {db_name}")
fs = ClassroomCopilotFilesystem(db_name=db_name, init_run_type="user")
logging.debug(f"Filesystem root path: {fs.root_path}")
# Handle path based on environment
if os.getenv("DEV_MODE") == "true":
# In dev mode, use the full system path from the node
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
logging.debug(f"Using DEV_MODEpath: {path}")
base_path = os.path.normpath(path)
# Use the path directly as provided - it represents the structure from root
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
# The path might already contain parts of the filesystem structure
# We need to construct the full path carefully
if path.startswith("users/"):
# If path starts with users/, remove it since filesystem already has users/ structure
base_path = path[6:] # Remove "users/" prefix
logging.debug(f"Removed 'users/' prefix, base_path is now: {base_path}")
else:
# In prod mode, construct path
logging.warning(f"Using db_name as base path not ready in prod: {db_name}")
base_path = db_name
base_path = path
logging.debug(f"No 'users/' prefix found, using path as-is: {base_path}")
base_path = os.path.normpath(base_path)
logging.debug(f"Using base path: {base_path}")
# Construct final path including tldraw file
logging.debug(f"Base path: {base_path}")
file_path = os.path.join(base_path, "tldraw_file.json")
logging.debug(f"File path: {file_path}")
file_location = os.path.normpath(os.path.join(fs.root_path, file_path))
logging.debug(f"File location: {file_location}")
logging.debug(f"Final file location: {file_location}")
# Debug: Check what directories exist
logging.debug(f"Checking if root path exists: {fs.root_path} - {os.path.exists(fs.root_path)}")
logging.debug(f"Checking if base path exists: {os.path.join(fs.root_path, base_path)} - {os.path.exists(os.path.join(fs.root_path, base_path))}")
logging.debug(f"Attempting to read file at: {file_location}")
@@ -151,8 +197,44 @@ async def read_tldraw_node_file(path: str, db_name: str):
logging.error(f"Error reading file: {e}")
raise HTTPException(status_code=500, detail="Error reading file")
else:
logging.debug(f"File does not exist: {file_location}")
raise HTTPException(status_code=404, detail="File not found")
# Check if directory exists
directory_location = os.path.dirname(file_location)
logging.debug(f"Checking if directory exists: {directory_location} - {os.path.exists(directory_location)}")
if os.path.exists(directory_location):
logging.debug(f"Directory exists but file doesn't, creating default tldraw file at: {file_location}")
try:
# Create default tldraw content
default_tldraw_content = create_default_tldraw_content()
# Ensure directory exists (should already exist, but just in case)
os.makedirs(directory_location, exist_ok=True)
# Write the default file
with open(file_location, "w") as file:
json.dump(default_tldraw_content, file, indent=4)
logging.info(f"Default tldraw file created at: {file_location}")
return default_tldraw_content
except Exception as e:
logging.error(f"Error creating default tldraw file: {e}")
raise HTTPException(status_code=500, detail="Error creating default tldraw file")
else:
logging.debug(f"Neither directory nor file exists: {directory_location}")
# List contents of parent directories to help debug
parent_dir = os.path.dirname(directory_location)
if os.path.exists(parent_dir):
logging.debug(f"Parent directory exists: {parent_dir}")
try:
contents = os.listdir(parent_dir)
logging.debug(f"Parent directory contents: {contents}")
except Exception as e:
logging.debug(f"Could not list parent directory contents: {e}")
else:
logging.debug(f"Parent directory does not exist: {parent_dir}")
raise HTTPException(status_code=404, detail="Directory not found")
@router.post("/set_tldraw_node_file")
async def set_tldraw_node_file(path: str, db_name: str, data: Dict):
@@ -162,22 +244,25 @@ async def set_tldraw_node_file(path: str, db_name: str, data: Dict):
logging.debug(f"Filesystem root path: {fs.root_path}")
# Handle path based on environment
if os.getenv("DEV_MODE") == "true":
# In dev mode, use the full system path from the node
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
logging.debug(f"Using DEV_MODEpath: {path}")
base_path = os.path.normpath(path)
# Use the path directly as provided - it represents the structure from root
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
# The path might already contain parts of the filesystem structure
# We need to construct the full path carefully
if path.startswith("users/"):
# If path starts with users/, remove it since filesystem already has users/ structure
base_path = path[6:] # Remove "users/" prefix
logging.debug(f"Removed 'users/' prefix, base_path is now: {base_path}")
else:
# In prod mode, construct path
logging.warning(f"Using db_name as base path not ready in prod: {db_name}")
base_path = db_name
base_path = path
logging.debug(f"No 'users/' prefix found, using path as-is: {base_path}")
base_path = os.path.normpath(base_path)
logging.debug(f"Using base path: {base_path}")
# Construct final path including tldraw file
logging.debug(f"Base path: {base_path}")
file_path = os.path.join(base_path, "tldraw_file.json")
logging.debug(f"File path: {file_path}")
file_location = os.path.normpath(os.path.join(fs.root_path, file_path))
logging.debug(f"File location: {file_location}")
@@ -185,12 +270,94 @@ async def set_tldraw_node_file(path: str, db_name: str, data: Dict):
try:
# Ensure directory exists
os.makedirs(os.path.dirname(file_location), exist_ok=True)
directory_location = os.path.dirname(file_location)
os.makedirs(directory_location, exist_ok=True)
logging.debug(f"Ensured directory exists: {directory_location}")
# Write the file
with open(file_location, "w") as file:
json.dump(data, file)
json.dump(data, file, indent=4)
logging.info(f"tldraw file successfully written to: {file_location}")
return {"status": "success"}
except Exception as e:
logging.error(f"Error writing file: {e}")
raise HTTPException(status_code=500, detail="Error writing file")
def create_default_tldraw_content():
"""Create default tldraw content structure."""
return {
"document": {
"store": {
"document:document": {
"gridSize": 10,
"name": "",
"meta": {},
"id": "document:document",
"typeName": "document"
},
"page:page": {
"meta": {},
"id": "page:page",
"name": "Page 1",
"index": "a1",
"typeName": "page"
}
},
"schema": {
"schemaVersion": 2,
"sequences": {
"com.tldraw.store": 4,
"com.tldraw.asset": 1,
"com.tldraw.camera": 1,
"com.tldraw.document": 2,
"com.tldraw.instance": 25,
"com.tldraw.instance_page_state": 5,
"com.tldraw.page": 1,
"com.tldraw.instance_presence": 5,
"com.tldraw.pointer": 1,
"com.tldraw.shape": 4,
"com.tldraw.asset.bookmark": 2,
"com.tldraw.asset.image": 5,
"com.tldraw.asset.video": 5,
"com.tldraw.shape.arrow": 5,
"com.tldraw.shape.bookmark": 2,
"com.tldraw.shape.draw": 2,
"com.tldraw.shape.embed": 4,
"com.tldraw.shape.frame": 0,
"com.tldraw.shape.geo": 9,
"com.tldraw.shape.group": 0,
"com.tldraw.shape.highlight": 1,
"com.tldraw.shape.image": 4,
"com.tldraw.shape.line": 5,
"com.tldraw.shape.note": 8,
"com.tldraw.shape.text": 2,
"com.tldraw.shape.video": 2,
"com.tldraw.binding.arrow": 0
}
},
"recordVersions": {
"asset": {"version": 1, "subTypeKey": "type", "subTypeVersions": {}},
"camera": {"version": 1},
"document": {"version": 2},
"instance": {"version": 21},
"instance_page_state": {"version": 5},
"page": {"version": 1},
"shape": {"version": 3, "subTypeKey": "type", "subTypeVersions": {}},
"instance_presence": {"version": 5},
"pointer": {"version": 1}
},
"rootShapeIds": [],
"bindings": [],
"assets": []
},
"session": {
"version": 0,
"currentPageId": "page:page",
"pageStates": [{
"pageId": "page:page",
"camera": {"x": 0, "y": 0, "z": 1},
"selectedShapeIds": []
}]
}
}
@@ -0,0 +1,265 @@
"""
TLDraw Supabase Storage Router
=============================
Handles TLDraw snapshot operations using Supabase Storage instead of local filesystem.
This replaces the old filesystem-based tldraw_filesystem.py router.
"""
from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv())
import os
import json
import logging
from fastapi import APIRouter, HTTPException, Query
from typing import Dict, Any
from modules.database.supabase.utils.storage import StorageAdmin
from modules.logger_tool import initialise_logger
router = APIRouter()
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
def create_default_tldraw_content():
"""Create default tldraw content structure."""
return {
"document": {
"store": {
"document:document": {
"gridSize": 10,
"name": "",
"meta": {},
"id": "document:document",
"typeName": "document"
},
"page:page": {
"meta": {},
"id": "page:page",
"name": "Page 1",
"index": "a1",
"typeName": "page"
}
},
"schema": {
"schemaVersion": 2,
"sequences": {
"com.tldraw.store": 4,
"com.tldraw.asset": 1,
"com.tldraw.camera": 1,
"com.tldraw.document": 2,
"com.tldraw.instance": 25,
"com.tldraw.instance_page_state": 5,
"com.tldraw.page": 1,
"com.tldraw.instance_presence": 5,
"com.tldraw.pointer": 1,
"com.tldraw.shape": 4,
"com.tldraw.asset.bookmark": 2,
"com.tldraw.asset.image": 5,
"com.tldraw.asset.video": 5,
"com.tldraw.shape.arrow": 5,
"com.tldraw.shape.bookmark": 2,
"com.tldraw.shape.draw": 2,
"com.tldraw.shape.embed": 4,
"com.tldraw.shape.frame": 0,
"com.tldraw.shape.geo": 9,
"com.tldraw.shape.group": 0,
"com.tldraw.shape.highlight": 1,
"com.tldraw.shape.image": 4,
"com.tldraw.shape.line": 5,
"com.tldraw.shape.note": 8,
"com.tldraw.shape.text": 2,
"com.tldraw.shape.video": 2,
"com.tldraw.binding.arrow": 0
}
},
"recordVersions": {
"asset": {"version": 1, "subTypeKey": "type", "subTypeVersions": {}},
"camera": {"version": 1},
"document": {"version": 2},
"instance": {"version": 21},
"instance_page_state": {"version": 5},
"page": {"version": 1},
"shape": {"version": 3, "subTypeKey": "type", "subTypeVersions": {}},
"instance_presence": {"version": 5},
"pointer": {"version": 1}
},
"rootShapeIds": [],
"bindings": [],
"assets": []
},
"session": {
"version": 0,
"currentPageId": "page:page",
"pageStates": [{
"pageId": "page:page",
"camera": {"x": 0, "y": 0, "z": 1},
"selectedShapeIds": []
}]
}
}
@router.get("/get_tldraw_node_file")
async def read_tldraw_node_file_from_supabase(
path: str = Query(..., description="Supabase Storage path (e.g., 'cc.public.snapshots/User/user_id')"),
db_name: str = Query(..., description="Database name for context")
):
"""
Load TLDraw snapshot from Supabase Storage.
Args:
path: Supabase Storage path in format 'bucket/nodetype/node_id'
db_name: Database name for context (used for logging)
Returns:
TLDraw snapshot data
"""
logger.debug(f"Reading tldraw file from Supabase Storage for path: {path}")
logger.debug(f"Database name: {db_name}")
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
try:
# Initialize Supabase Storage
storage = StorageAdmin()
# Parse the path to extract bucket and file path
# Expected format: "cc.public.snapshots/User/user_id" or "cc.public.snapshots/Teacher/teacher_id"
path_parts = path.split('/')
if len(path_parts) < 3:
raise HTTPException(status_code=400, detail="Invalid path format. Expected: bucket/nodetype/node_id")
bucket = path_parts[0] # e.g., "cc.public.snapshots"
node_type = path_parts[1] # e.g., "User", "Teacher"
node_id = path_parts[2] # e.g., "cbc309e5-4029-4c34-aab7-0aa33c563cd0"
# Construct the file path in Supabase Storage
# Format: nodetype/node_id/tldraw_file.json
file_path = f"{node_type}/{node_id}/tldraw_file.json"
logger.debug(f"Bucket: {bucket}")
logger.debug(f"File path: {file_path}")
try:
# Try to download the file from Supabase Storage
file_data = storage.download_file(bucket, file_path)
# Parse JSON data
try:
snapshot_data = json.loads(file_data.decode('utf-8'))
logger.info(f"Successfully loaded tldraw snapshot from Supabase Storage: {file_path}")
# Ensure the snapshot has the correct structure for TLDraw
if isinstance(snapshot_data, dict) and 'document' in snapshot_data and 'session' in snapshot_data:
# Check if it has the new format (schemaVersion in document.schema)
if 'document' in snapshot_data and isinstance(snapshot_data['document'], dict) and 'schema' in snapshot_data['document']:
return snapshot_data
# Check if it has the old format (schemaVersion at root level)
elif 'schemaVersion' in snapshot_data:
return snapshot_data
else:
# Use default structure if schema is missing
logger.warning(f"Snapshot data from {file_path_in_bucket} is missing schemaVersion. Using default structure.")
return create_default_tldraw_content()
else:
# Use default structure if basic structure is missing
logger.warning(f"Snapshot data from {file_path_in_bucket} is missing top-level TLDraw keys. Using default structure.")
return create_default_tldraw_content()
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON from Supabase Storage file: {e}")
raise HTTPException(status_code=500, detail="Invalid JSON in file")
except Exception as e:
# File doesn't exist, create default content
logger.info(f"File not found in Supabase Storage, creating default tldraw content: {file_path}")
# Create default tldraw content
default_content = create_default_tldraw_content()
try:
# Upload default content to Supabase Storage
json_data = json.dumps(default_content, indent=2).encode('utf-8')
storage.upload_file(bucket, file_path, json_data, 'application/json', upsert=True)
logger.info(f"Default tldraw file created in Supabase Storage: {file_path}")
return default_content
except Exception as upload_error:
logger.error(f"Error creating default tldraw file in Supabase Storage: {upload_error}")
raise HTTPException(status_code=500, detail="Error creating default tldraw file")
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Unexpected error loading tldraw file from Supabase Storage: {e}")
raise HTTPException(status_code=500, detail=f"Error loading file: {str(e)}")
@router.post("/set_tldraw_node_file")
async def set_tldraw_node_file_in_supabase(
path: str = Query(..., description="Supabase Storage path (e.g., 'cc.public.snapshots/User/user_id')"),
db_name: str = Query(..., description="Database name for context"),
data: Dict[str, Any] = None
):
"""
Save TLDraw snapshot to Supabase Storage.
Args:
path: Supabase Storage path in format 'bucket/nodetype/node_id'
db_name: Database name for context (used for logging)
data: TLDraw snapshot data to save
Returns:
Success status
"""
logger.debug(f"Saving tldraw file to Supabase Storage for path: {path}")
logger.debug(f"Database name: {db_name}")
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
if not data:
raise HTTPException(status_code=400, detail="Data not provided")
try:
# Initialize Supabase Storage
storage = StorageAdmin()
# Parse the path to extract bucket and file path
path_parts = path.split('/')
if len(path_parts) < 3:
raise HTTPException(status_code=400, detail="Invalid path format. Expected: bucket/nodetype/node_id")
bucket = path_parts[0] # e.g., "cc.public.snapshots"
node_type = path_parts[1] # e.g., "User", "Teacher"
node_id = path_parts[2] # e.g., "cbc309e5-4029-4c34-aab7-0aa33c563cd0"
# Construct the file path in Supabase Storage
file_path = f"{node_type}/{node_id}/tldraw_file.json"
logger.debug(f"Bucket: {bucket}")
logger.debug(f"File path: {file_path}")
# Convert data to JSON
try:
json_data = json.dumps(data, indent=2).encode('utf-8')
except (TypeError, ValueError) as e:
logger.error(f"Failed to serialize data to JSON: {e}")
raise HTTPException(status_code=400, detail="Invalid data format")
# Upload to Supabase Storage
try:
storage.upload_file(bucket, file_path, json_data, 'application/json', upsert=True)
logger.info(f"Successfully saved tldraw snapshot to Supabase Storage: {file_path}")
return {"status": "success", "message": "File saved successfully"}
except Exception as upload_error:
logger.error(f"Error uploading file to Supabase Storage: {upload_error}")
raise HTTPException(status_code=500, detail="Error saving file")
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Unexpected error saving tldraw file to Supabase Storage: {e}")
raise HTTPException(status_code=500, detail=f"Error saving file: {str(e)}")
@@ -29,34 +29,34 @@ async def get_worker_structure(db_name: str) -> Dict[str, Any]:
// Collect all nodes
RETURN {
timetables: collect(DISTINCT {
id: tt.unique_id,
path: tt.path,
id: tt.uuid_string,
path: tt.node_storage_path,
title: tt.title,
type: tt.__primarylabel__,
startTime: toString(tt.start_date),
endTime: toString(tt.end_date)
}),
classes: collect(DISTINCT {
id: c.unique_id,
path: c.path,
id: c.uuid_string,
path: c.node_storage_path,
title: c.title,
type: c.__primarylabel__
}),
lessons: collect(DISTINCT {
id: l.unique_id,
path: l.path,
id: l.uuid_string,
path: l.node_storage_path,
title: l.title,
type: l.__primarylabel__
}),
journals: collect(DISTINCT {
id: j.unique_id,
path: j.path,
id: j.uuid_string,
path: j.node_storage_path,
title: j.title,
type: j.__primarylabel__
}),
planners: collect(DISTINCT {
id: p.unique_id,
path: p.path,
id: p.uuid_string,
path: p.node_storage_path,
title: p.title,
type: p.__primarylabel__
})
@@ -106,8 +106,8 @@ async def get_timetables(db_name: str, start_date: str, end_date: str) -> Dict[s
MATCH (tt:UserTeacherTimetable)
WHERE date(tt.start_date) >= date($start_date) AND date(tt.end_date) <= date($end_date)
RETURN {
id: tt.unique_id,
path: tt.path,
id: tt.uuid_string,
path: tt.node_storage_path,
title: tt.title,
type: tt.__primarylabel__,
startTime: toString(tt.start_date),
@@ -138,8 +138,8 @@ async def get_journals(db_name: str) -> Dict[str, Any]:
query = """
MATCH (j:Journal)
RETURN {
id: j.unique_id,
path: j.path,
id: j.uuid_string,
path: j.node_storage_path,
title: j.title,
type: j.__primarylabel__
} as journal
@@ -168,8 +168,8 @@ async def get_planners(db_name: str) -> Dict[str, Any]:
query = """
MATCH (p:Planner)
RETURN {
id: p.unique_id,
path: p.path,
id: p.uuid_string,
path: p.node_storage_path,
title: p.title,
type: p.__primarylabel__
} as planner