from fastapi import APIRouter, Request, Depends, HTTPException, File, UploadFile, Form from fastapi.responses import HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates from typing import Dict import os from modules.logger_tool import initialise_logger from modules.database.services.admin_service import AdminService, AdminProfileBase from modules.database.services.school_admin_service import SchoolAdminService from modules.database.supabase.utils.client import SupabaseAnonClient from modules.database.supabase.utils.storage import StorageManager from .auth import verify_admin import csv import io router = APIRouter() templates = Jinja2Templates(directory="templates") logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) # Initialize services admin_service = AdminService() school_service = SchoolAdminService() storage_manager = StorageManager(SupabaseAnonClient) @router.get("/", response_class=HTMLResponse) async def admin_dashboard(request: Request, admin: Dict = Depends(verify_admin)): """Render admin dashboard""" return templates.TemplateResponse( "admin/dashboard/index.html", { "request": request, "admin": admin, "app_version": os.getenv("APP_VERSION", "Unknown") } ) @router.get("/users") async def list_users(request: Request, admin: Dict = Depends(verify_admin)): """List all users""" return templates.TemplateResponse( "admin/users/list.html", {"request": request, "admin": admin} ) @router.get("/users/{user_id}") async def get_user(request: Request, user_id: str, admin: Dict = Depends(verify_admin)): """Get user details""" return templates.TemplateResponse( "admin/users/detail.html", {"request": request, "admin": admin, "user_id": user_id} ) @router.get("/admins") async def list_admins(request: Request, admin: Dict = Depends(verify_admin)): """List all admins""" if not admin.get("is_super_admin"): raise HTTPException(status_code=403, detail="Only super admins can view admin list") admins = admin_service.list_admins() return templates.TemplateResponse( "admin/users/admins.html", {"request": request, "admin": admin, "admins": admins} ) @router.post("/admins") async def create_admin(admin_data: AdminProfileBase, current_admin: Dict = Depends(verify_admin)): """Create a new admin""" try: result = admin_service.create_admin(admin_data, current_admin) return JSONResponse(content={"status": "success", "admin": result}) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) @router.get("/schools/manage", response_class=HTMLResponse) async def manage_schools(request: Request, admin: Dict = Depends(verify_admin)): """Manage schools page""" try: # Fetch schools from Supabase result = admin_service.supabase.table("schools").select("*").execute() schools = result.data if result else [] # Sort schools by establishment_name schools.sort(key=lambda x: x.get("establishment_name", "")) return templates.TemplateResponse( "admin/schools/manage.html", { "request": request, "admin": admin, "schools": schools, "schools_count": len(schools) } ) except Exception as e: logger.error(f"Error fetching schools: {str(e)}") return templates.TemplateResponse( "admin/schools/manage.html", { "request": request, "admin": admin, "schools": [], "schools_count": 0, "error": str(e) } ) @router.post("/schools/import") async def import_schools( file: UploadFile = File(...), admin: Dict = Depends(verify_admin) ): """Import schools from CSV file""" if not file.filename.endswith('.csv'): raise HTTPException(status_code=400, detail="Please upload a CSV file") try: # Process the CSV file content = await file.read() csv_text = content.decode('utf-8-sig') # Handle BOM if present csv_reader = csv.DictReader(io.StringIO(csv_text)) # Prepare data for batch insert schools_data = [] for row in csv_reader: school_data = { "urn": row.get("URN"), "la_code": row.get("LA (code)"), "la_name": row.get("LA (name)"), "establishment_number": row.get("EstablishmentNumber"), "establishment_name": row.get("EstablishmentName"), "establishment_type": row.get("TypeOfEstablishment (name)"), "establishment_type_group": row.get("EstablishmentTypeGroup (name)"), "establishment_status": row.get("EstablishmentStatus (name)"), "reason_establishment_opened": row.get("ReasonEstablishmentOpened (name)"), "open_date": row.get("OpenDate"), "reason_establishment_closed": row.get("ReasonEstablishmentClosed (name)"), "close_date": row.get("CloseDate"), "phase_of_education": row.get("PhaseOfEducation (name)"), "statutory_low_age": row.get("StatutoryLowAge"), "statutory_high_age": row.get("StatutoryHighAge"), "boarders": row.get("Boarders (name)"), "nursery_provision": row.get("NurseryProvision (name)"), "official_sixth_form": row.get("OfficialSixthForm (name)"), "gender": row.get("Gender (name)"), "religious_character": row.get("ReligiousCharacter (name)"), "religious_ethos": row.get("ReligiousEthos (name)"), "diocese": row.get("Diocese (name)"), "admissions_policy": row.get("AdmissionsPolicy (name)"), "school_capacity": row.get("SchoolCapacity"), "special_classes": row.get("SpecialClasses (name)"), "census_date": row.get("CensusDate"), "number_of_pupils": row.get("NumberOfPupils"), "number_of_boys": row.get("NumberOfBoys"), "number_of_girls": row.get("NumberOfGirls"), "percentage_fsm": row.get("PercentageFSM"), "trust_school_flag": row.get("TrustSchoolFlag (name)"), "trusts_name": row.get("Trusts (name)"), "school_sponsor_flag": row.get("SchoolSponsorFlag (name)"), "school_sponsors_name": row.get("SchoolSponsors (name)"), "federation_flag": row.get("FederationFlag (name)"), "federations_name": row.get("Federations (name)"), "ukprn": row.get("UKPRN"), "fehe_identifier": row.get("FEHEIdentifier"), "further_education_type": row.get("FurtherEducationType (name)"), "ofsted_last_inspection": row.get("OfstedLastInsp"), "last_changed_date": row.get("LastChangedDate"), "street": row.get("Street"), "locality": row.get("Locality"), "address3": row.get("Address3"), "town": row.get("Town"), "county": row.get("County (name)"), "postcode": row.get("Postcode"), "school_website": row.get("SchoolWebsite"), "telephone_num": row.get("TelephoneNum"), "head_title": row.get("HeadTitle (name)"), "head_first_name": row.get("HeadFirstName"), "head_last_name": row.get("HeadLastName"), "head_preferred_job_title": row.get("HeadPreferredJobTitle"), "gssla_code": row.get("GSSLACode (name)"), "parliamentary_constituency": row.get("ParliamentaryConstituency (name)"), "urban_rural": row.get("UrbanRural (name)"), "rsc_region": row.get("RSCRegion (name)"), "country": row.get("Country (name)"), "uprn": row.get("UPRN"), "sen_stat": row.get("SENStat") == "true", "sen_no_stat": row.get("SENNoStat") == "true", "sen_unit_on_roll": row.get("SenUnitOnRoll"), "sen_unit_capacity": row.get("SenUnitCapacity"), "resourced_provision_on_roll": row.get("ResourcedProvisionOnRoll"), "resourced_provision_capacity": row.get("ResourcedProvisionCapacity"), } # Clean up empty strings and convert types for key, value in school_data.items(): if value == "": school_data[key] = None elif key in ["statutory_low_age", "statutory_high_age", "school_capacity", "number_of_pupils", "number_of_boys", "number_of_girls", "sen_unit_on_roll", "sen_unit_capacity", "resourced_provision_on_roll", "resourced_provision_capacity"]: if value: try: float_val = float(value) int_val = int(float_val) school_data[key] = int_val except (ValueError, TypeError): school_data[key] = None elif key == "percentage_fsm": if value: try: school_data[key] = float(value) except (ValueError, TypeError): school_data[key] = None elif key in ["open_date", "close_date", "census_date", "ofsted_last_inspection", "last_changed_date"]: if value: try: # Convert date from DD-MM-YYYY to YYYY-MM-DD parts = value.split("-") if len(parts) == 3: school_data[key] = f"{parts[2]}-{parts[1]}-{parts[0]}" else: school_data[key] = None except: school_data[key] = None schools_data.append(school_data) # Batch insert schools using admin service's Supabase client if schools_data: result = admin_service.supabase.table("schools").upsert( schools_data, on_conflict="urn" # Update if URN already exists ).execute() logger.info(f"Imported {len(schools_data)} schools") return {"status": "success", "imported_count": len(schools_data)} else: raise HTTPException(status_code=400, detail="No valid school data found in CSV") except Exception as e: logger.error(f"Error importing schools: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/initialize-schools-database") async def initialize_schools_database(admin: Dict = Depends(verify_admin)): """Initialize schools database""" if not admin.get("is_super_admin"): raise HTTPException(status_code=403, detail="Only super admins can initialize database") result = school_service.create_schools_database() if result["status"] == "error": raise HTTPException(status_code=500, detail=result["message"]) return result @router.get("/check-schools-database") async def check_schools_database(admin: Dict = Depends(verify_admin)): """Check schools database status""" try: # Use SchoolService to check if database exists and has required nodes/relationships result = school_service.check_schools_database() return {"exists": result["status"] == "success"} except Exception as e: logger.error(f"Error checking schools database: {str(e)}") return {"exists": False, "error": str(e)} @router.get("/storage", response_class=HTMLResponse) async def storage_management(request: Request, admin: Dict = Depends(verify_admin)): """Storage management page""" try: # Get list of storage buckets with correct IDs buckets = [ { "id": "cc.institutes", "name": "School Files", "public": False, "file_size_limit": 50 * 1024 * 1024, # 50MB "allowed_mime_types": [ "image/*", "video/*", "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/plain", "text/csv", "application/json" ] }, { "id": "cc.users", "name": "User Files", "public": False, "file_size_limit": 50 * 1024 * 1024, # 50MB "allowed_mime_types": [ "image/*", "video/*", "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/plain", "text/csv", "application/json" ] } ] return templates.TemplateResponse( "admin/storage/manage.html", {"request": request, "admin": admin, "buckets": buckets} ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.get("/storage/{bucket_id}/contents") async def list_bucket_contents( request: Request, bucket_id: str, path: str = "", admin: Dict = Depends(verify_admin) ): """List contents of a storage bucket""" try: contents = storage_manager.list_bucket_contents(bucket_id, path) bucket = {"id": bucket_id, "name": bucket_id.replace("_", " ").title()} return templates.TemplateResponse( "admin/storage/contents.html", { "request": request, "admin": admin, "bucket": bucket, "contents": contents, "current_path": path } ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.get("/storage/{bucket_id}/download/{file_path:path}") async def download_file( bucket_id: str, file_path: str, admin: Dict = Depends(verify_admin) ): """Get download URL for a file""" try: url = storage_manager.create_signed_url(bucket_id, file_path) return {"url": url} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.delete("/storage/{bucket_id}/objects/{object_path:path}") async def delete_object( bucket_id: str, object_path: str, admin: Dict = Depends(verify_admin) ): """Delete an object from storage""" try: storage_manager.delete_file(bucket_id, object_path) return {"status": "success"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.get("/check-storage") async def check_storage(admin: Dict = Depends(verify_admin)): """Check storage buckets status""" try: # Use the same bucket IDs as defined in initialize_storage buckets = [ {"id": "cc.users", "name": "User Files"}, {"id": "cc.institutes", "name": "School Files"} ] results = [] for bucket in buckets: exists = storage_manager.check_bucket_exists(bucket["id"]) results.append({ "id": bucket["id"], "name": bucket["name"], "exists": exists }) return {"buckets": results} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/initialize-storage") async def initialize_storage(admin: Dict = Depends(verify_admin)): """Initialize storage buckets and policies for schools""" try: # Verify super admin status if not admin.get('is_super_admin'): raise HTTPException(status_code=403, detail="Only super admins can initialize storage") # Use the storage manager to initialize storage storage_manager = StorageManager(SupabaseAnonClient) return storage_manager.initialize_storage() except Exception as e: logger.error(f"Error initializing storage: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/check-schema") async def check_schema(admin: Dict = Depends(verify_admin)): """Check Neo4j schema status""" try: from modules.database.services.graph_service import GraphService graph_service = GraphService() # Get actual schema status schema_status = graph_service.check_schema_status() # Return status with proper validation return { "constraints_valid": schema_status["constraints_count"] > 0, "constraints_count": schema_status["constraints_count"], "indexes_valid": schema_status["indexes_count"] > 0, "indexes_count": schema_status["indexes_count"], "labels_valid": schema_status["labels_count"] > 0, "labels_count": schema_status["labels_count"] } except Exception as e: logger.error(f"Error checking schema: {str(e)}") return { "constraints_valid": False, "constraints_count": 0, "indexes_valid": False, "indexes_count": 0, "labels_valid": False, "labels_count": 0, "error": str(e) } @router.post("/initialize-schema") async def initialize_schema(admin: Dict = Depends(verify_admin)): """Initialize Neo4j schema (constraints and indexes)""" if not admin.get("is_super_admin"): raise HTTPException(status_code=403, detail="Only super admins can initialize schema") try: from modules.database.services.graph_service import GraphService graph_service = GraphService() # Initialize schema result = graph_service.initialize_schema() if result["status"] == "error": raise HTTPException(status_code=500, detail=result["message"]) return result except Exception as e: logger.error(f"Error initializing schema: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/schools/{school_id}") async def view_school(request: Request, school_id: str, admin: Dict = Depends(verify_admin)): """View school details""" try: # Fetch school details from Supabase result = admin_service.supabase.table("schools").select("*").eq("id", school_id).single().execute() school = result.data if result else None if not school: raise HTTPException(status_code=404, detail="School not found") return templates.TemplateResponse( "admin/schools/detail.html", {"request": request, "admin": admin, "school": school} ) except Exception as e: logger.error(f"Error fetching school details: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @router.delete("/schools/{school_id}") async def delete_school(school_id: str, admin: Dict = Depends(verify_admin)): """Delete a school""" try: # Verify super admin status if not admin.get("is_super_admin"): raise HTTPException(status_code=403, detail="Only super admins can delete schools") # Delete the school from Supabase result = admin_service.supabase.table("schools").delete().eq("id", school_id).execute() if not result.data: raise HTTPException(status_code=404, detail="School not found") return {"status": "success", "message": "School deleted successfully"} except Exception as e: logger.error(f"Error deleting school: {str(e)}") raise HTTPException(status_code=500, detail=str(e))