api/run/initialization/seed_environment.py
kcar e66c8ec291
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
t4: consolidate seed scripts, remove demo modes, standardize passwords
2026-05-29 19:51:32 +01:00

532 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
seed_environment.py — idempotent full-environment rebuild.
Assumes reset_environment.py has already been run (or it's the first boot).
Safe to re-run: all writes use UPSERT / MERGE.
Schools
-------
KevlarAI 6585bf91-6ae8-4d72-ab54-cddf3ba4e648 kevlarai.test
Greenfield Academy a1b2c3d4-e5f6-7890-abcd-ef1234567890 greenfieldacademy.test
Uniform accounts per school (10 × 2 = 20 total)
------------------------------------------------
admin@{domain} school_admin
head@{domain} school_admin
physics@{domain} teacher
maths@{domain} teacher
teacher1@{domain} teacher
teacher2@{domain} teacher
teacher3@{domain} teacher
student1@{domain} student
student2@{domain} student
student3@{domain} student
Run from inside the ccapi container:
python3 main.py --mode seed
python3 main.py --mode seed-test
Or directly:
python3 -c "from run.initialization.seed_environment import seed; seed()"
python3 -c "from run.initialization.seed_environment import seed; seed(test=True)"
"""
import os
import time
import requests
from datetime import datetime, timedelta
from typing import Dict, Any, List, Optional
from modules.logger_tool import initialise_logger
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
# ─── School constants ─────────────────────────────────────────────────────────
KEVLARAI_ID = "6585bf91-6ae8-4d72-ab54-cddf3ba4e648"
KEVLARAI_NAME = "KevlarAI"
KEVLARAI_URN = "KEVLARAI-001"
KEVLARAI_DOMAIN = "kevlarai.test"
GREENFIELD_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
GREENFIELD_NAME = "Greenfield Academy"
GREENFIELD_URN = "TEST-GFA-001"
GREENFIELD_DOMAIN = "greenfieldacademy.test"
# ─── Passwords ────────────────────────────────────────────────────────────────
DEFAULT_PLATFORM_ADMIN_PASSWORD = "KevlarAI2025!"
DEFAULT_SCHOOL_ADMIN_PASSWORD = "Admin@Cc2025!"
DEFAULT_TEACHER_PASSWORD = "Teacher@Cc2025!"
DEFAULT_STUDENT_PASSWORD = "Student@Cc2025!"
def get_seed_password(role: str) -> str:
"""Return the seed password for a role, allowing env overrides."""
normalized = role.lower().strip()
env_by_role = {
"platform_admin": ("SEED_PLATFORM_ADMIN_PASSWORD", DEFAULT_PLATFORM_ADMIN_PASSWORD),
"school_admin": ("SEED_SCHOOL_ADMIN_PASSWORD", DEFAULT_SCHOOL_ADMIN_PASSWORD),
"teacher": ("SEED_TEACHER_PASSWORD", DEFAULT_TEACHER_PASSWORD),
"student": ("SEED_STUDENT_PASSWORD", DEFAULT_STUDENT_PASSWORD),
}
if normalized not in env_by_role:
raise ValueError(f"Unknown seed password role: {role}")
env_name, default = env_by_role[normalized]
return os.getenv(env_name, default)
def get_seed_passwords() -> Dict[str, str]:
return {
"platform_admin": get_seed_password("platform_admin"),
"school_admin": get_seed_password("school_admin"),
"teacher": get_seed_password("teacher"),
"student": get_seed_password("student"),
}
# ─── Account template ────────────────────────────────────────────────────────
def _school_accounts(domain: str, institute_id: str) -> List[Dict]:
passwords = get_seed_passwords()
return [
# school_admin accounts
{
"prefix": "admin", "email": f"admin@{domain}",
"full_name": "Alex Admin", "display_name": "Alex",
"username": f"admin.{domain.replace('.', '_')}",
"user_type": "teacher", "role": "school_admin", "password": passwords["school_admin"],
"institute_id": institute_id,
},
{
"prefix": "head", "email": f"head@{domain}",
"full_name": "Helen Head", "display_name": "Helen",
"username": f"head.{domain.replace('.', '_')}",
"user_type": "teacher", "role": "school_admin", "password": passwords["school_admin"],
"institute_id": institute_id,
},
# teacher accounts
{
"prefix": "physics", "email": f"physics@{domain}",
"full_name": "Phil Physics", "display_name": "Phil",
"username": f"physics.{domain.replace('.', '_')}",
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
"institute_id": institute_id,
},
{
"prefix": "maths", "email": f"maths@{domain}",
"full_name": "Mary Maths", "display_name": "Mary",
"username": f"maths.{domain.replace('.', '_')}",
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
"institute_id": institute_id,
},
{
"prefix": "teacher1", "email": f"teacher1@{domain}",
"full_name": "Tom Teacher", "display_name": "Tom",
"username": f"teacher1.{domain.replace('.', '_')}",
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
"institute_id": institute_id,
},
{
"prefix": "teacher2", "email": f"teacher2@{domain}",
"full_name": "Tara Teach", "display_name": "Tara",
"username": f"teacher2.{domain.replace('.', '_')}",
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
"institute_id": institute_id,
},
{
"prefix": "teacher3", "email": f"teacher3@{domain}",
"full_name": "Tim Teachwell", "display_name": "Tim",
"username": f"teacher3.{domain.replace('.', '_')}",
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
"institute_id": institute_id,
},
# student accounts
{
"prefix": "student1", "email": f"student1@{domain}",
"full_name": "Sam Student", "display_name": "Sam",
"username": f"student1.{domain.replace('.', '_')}",
"user_type": "student", "role": "student", "password": passwords["student"],
"institute_id": institute_id,
},
{
"prefix": "student2", "email": f"student2@{domain}",
"full_name": "Sophie Study", "display_name": "Sophie",
"username": f"student2.{domain.replace('.', '_')}",
"user_type": "student", "role": "student", "password": passwords["student"],
"institute_id": institute_id,
},
{
"prefix": "student3", "email": f"student3@{domain}",
"full_name": "Steve Scholar", "display_name": "Steve",
"username": f"student3.{domain.replace('.', '_')}",
"user_type": "student", "role": "student", "password": passwords["student"],
"institute_id": institute_id,
},
]
FULL_ACCOUNTS = (
_school_accounts(KEVLARAI_DOMAIN, KEVLARAI_ID) +
_school_accounts(GREENFIELD_DOMAIN, GREENFIELD_ID)
)
def get_accounts(test: bool = False) -> List[Dict]:
"""Return full (20-user) or lightweight test (9-user) seed fixtures."""
if not test:
return list(FULL_ACCOUNTS)
wanted = {
f"student1@{KEVLARAI_DOMAIN}",
f"student2@{KEVLARAI_DOMAIN}",
f"student3@{KEVLARAI_DOMAIN}",
f"admin@{GREENFIELD_DOMAIN}",
f"physics@{GREENFIELD_DOMAIN}",
f"maths@{GREENFIELD_DOMAIN}",
f"teacher1@{GREENFIELD_DOMAIN}",
f"student1@{GREENFIELD_DOMAIN}",
f"student2@{GREENFIELD_DOMAIN}",
}
return [account for account in FULL_ACCOUNTS if account["email"] in wanted]
# ─── Supabase helpers ─────────────────────────────────────────────────────────
def _sb_ctx():
url = os.environ["SUPABASE_URL"]
key = os.environ["SERVICE_ROLE_KEY"]
headers = {
"apikey": key,
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
return url, headers
def _auth_post(url, headers, path, data):
return requests.post(f"{url}/auth/v1/admin{path}", headers=headers, json=data)
def _auth_get(url, headers, path, params=None):
r = requests.get(f"{url}/auth/v1/admin{path}", headers=headers, params=params)
r.raise_for_status()
return r.json()
def _rest_upsert(url, headers, table, data, on_conflict):
h = {**headers, "Prefer": "resolution=merge-duplicates,return=representation"}
r = requests.post(
f"{url}/rest/v1/{table}",
headers=h,
json=data,
params={"on_conflict": on_conflict},
)
return r
def _rest_patch(url, headers, table, match_col, match_val, data):
r = requests.patch(
f"{url}/rest/v1/{table}",
headers={**headers, "Prefer": "return=minimal"},
params={match_col: f"eq.{match_val}"},
json=data,
)
return r
# ─── Main seed function ───────────────────────────────────────────────────────
def seed(test: bool = False) -> Dict[str, Any]:
from modules.database.services.provisioning_service import ProvisioningService
from modules.database.services.neo4j_service import Neo4jService
from modules.database.init.init_calendar import create_calendar
accounts = get_accounts(test=test)
url, headers = _sb_ctx()
errors: List[str] = []
results: Dict[str, Any] = {"mode": "test" if test else "full", "account_count": len(accounts)}
# ── Step 1: Fix KevlarAI institute record ─────────────────────────────────
logger.info("=" * 60)
logger.info(f"SEED ENVIRONMENT ({'test' if test else 'full'} mode)")
logger.info("=" * 60)
logger.info("\n[1] KevlarAI institute record...")
try:
r = _rest_upsert(url, headers, "institutes", {
"id": KEVLARAI_ID,
"name": KEVLARAI_NAME,
"urn": KEVLARAI_URN,
"status": "active",
"website": "https://kevlarai.test",
"address": {"line1": "1 AI Lane", "city": "London", "postcode": "EC1A 1BB"},
"metadata": {"headteacher": "Alex Admin", "seeded": True},
}, on_conflict="id")
if r.status_code in (200, 201):
logger.info(" KevlarAI upserted ✓")
else:
raise Exception(r.text[:200])
except Exception as e:
errors.append(f"kevlarai_institute: {e}")
logger.error(f" {e}")
# ── Step 2: Create Greenfield Academy if needed ───────────────────────────
logger.info("[2] Greenfield Academy institute record...")
try:
neo4j_uuid_greenfield = GREENFIELD_ID.replace("-", "")
r = _rest_upsert(url, headers, "institutes", {
"id": GREENFIELD_ID,
"name": GREENFIELD_NAME,
"urn": GREENFIELD_URN,
"status": "active",
"website": "https://greenfieldacademy.test",
"address": {"line1": "1 Academy Road", "city": "Testville", "postcode": "TE1 1ST"},
"metadata": {"headteacher": "Alex Admin", "seeded": True},
"neo4j_uuid_string": neo4j_uuid_greenfield,
}, on_conflict="id")
if r.status_code in (200, 201):
logger.info(" Greenfield Academy upserted ✓")
else:
raise Exception(r.text[:200])
except Exception as e:
errors.append(f"greenfield_institute: {e}")
logger.error(f" {e}")
# ── Step 3: Provision Neo4j for both schools ──────────────────────────────
logger.info("[3] Neo4j school provisioning...")
provisioner = ProvisioningService()
school_dbs: Dict[str, str] = {}
for iid, name in [(KEVLARAI_ID, "KevlarAI"), (GREENFIELD_ID, "Greenfield Academy")]:
try:
result = provisioner.ensure_school(iid)
db = result["db_name"]
school_dbs[iid] = db
logger.info(f" {name}: {db}")
except Exception as e:
errors.append(f"ensure_school {name}: {e}")
logger.error(f" {name}: {e}")
# derive fallback db name
school_dbs[iid] = f"cc.institutes.{iid.replace('-', '')}"
# ── Step 4: Rebuild classroomcopilot global calendar ─────────────────────
logger.info("[4] classroomcopilot global calendar (20242028)...")
try:
neo4j_svc = Neo4jService()
neo4j_svc.create_database("classroomcopilot")
logger.info(" DB created, waiting 5s for availability...")
time.sleep(5)
start_dt = datetime(2024, 1, 1)
end_dt = datetime(2028, 12, 31)
create_calendar("classroomcopilot", start_dt, end_dt)
logger.info(" Calendar built ✓")
results["global_calendar"] = "ok"
except Exception as e:
errors.append(f"global_calendar: {e}")
logger.error(f" {e}")
results["global_calendar"] = "error"
# ── Step 5: Create / verify auth users ────────────────────────────────────
logger.info(f"[5] Creating auth users ({len(accounts)} accounts)...")
try:
existing = _auth_get(url, headers, "/users", {"per_page": 200}).get("users", [])
existing_by_email = {u["email"]: u for u in existing}
except Exception as e:
errors.append(f"list_auth_users: {e}")
existing_by_email = {}
created_users: Dict[str, str] = {} # email → uid
for spec in accounts:
email = spec["email"]
if email in existing_by_email:
created_users[email] = existing_by_email[email]["id"]
logger.info(f" {email}: exists [{created_users[email][:8]}]")
continue
r = _auth_post(url, headers, "/users", {
"email": email,
"password": spec["password"],
"email_confirm": True,
"user_metadata": {
"username": spec["username"],
"full_name": spec["full_name"],
"display_name": spec["display_name"],
"user_type": spec["user_type"],
},
})
if r.status_code in (200, 201):
uid = r.json()["id"]
created_users[email] = uid
logger.info(f" {email}: created [{uid[:8]}]")
else:
errors.append(f"create {email}: {r.text[:150]}")
logger.error(f" {email}: {r.text[:150]}")
time.sleep(0.2)
results["users_created"] = len(created_users)
# ── Step 6: Upsert profiles and memberships ───────────────────────────────
logger.info("[6] Upserting profiles and memberships...")
for spec in accounts:
uid = created_users.get(spec["email"])
if not uid:
continue
try:
_rest_upsert(url, headers, "profiles", {
"id": uid,
"email": spec["email"],
"user_type": spec["user_type"],
"username": spec["username"],
"full_name": spec["full_name"],
"display_name": spec["display_name"],
"school_id": spec["institute_id"],
"neo4j_sync_status": "pending",
}, on_conflict="id")
_rest_upsert(url, headers, "institute_memberships", {
"profile_id": uid,
"institute_id": spec["institute_id"],
"role": spec["role"],
"metadata": {},
}, on_conflict="profile_id,institute_id")
except Exception as e:
errors.append(f"profile/membership {spec['email']}: {e}")
logger.error(f" {spec['email']}: {e}")
logger.info(" Profiles and memberships upserted ✓")
# ── Step 7: Merge Neo4j Teacher/Student nodes ─────────────────────────────
logger.info("[7] Merging Neo4j worker nodes...")
try:
from modules.database.tools.neo4j_driver_tools import close_driver, get_driver
bolt_url = os.getenv("NEO4J_BOLT_URL") or os.getenv("APP_BOLT_URL")
neo4j_user = os.getenv("NEO4J_USER") or os.getenv("USER_NEO4J")
neo4j_password = os.getenv("NEO4J_PASSWORD") or os.getenv("PASSWORD_NEO4J")
auth = (neo4j_user, neo4j_password) if neo4j_user and neo4j_password else None
driver = get_driver(url=bolt_url, auth=auth) if bolt_url else get_driver()
if driver is None:
raise RuntimeError("Neo4j driver unavailable; check NEO4J_BOLT_URL/APP_BOLT_URL and NEO4J_PASSWORD/PASSWORD_NEO4J")
# Group by institute DB
by_db: Dict[str, List[Dict]] = {}
for spec in accounts:
uid = created_users.get(spec["email"])
if not uid:
continue
db = school_dbs.get(spec["institute_id"])
if not db:
continue
by_db.setdefault(db, []).append({**spec, "uid": uid})
for db, users in by_db.items():
with driver.session(database=db) as s:
for u in users:
label = "Teacher" if u["user_type"] == "teacher" else "Student"
s.run(
f"MERGE (n:{label} {{uuid_string: $uid}}) "
"SET n.worker_email = $email, "
" n.worker_name = $name, "
" n.worker_type = $utype",
uid=u["uid"], email=u["email"],
name=u["full_name"], utype=u["user_type"],
)
logger.info(f" [{db[:35]}] {len(users)} nodes merged ✓")
close_driver(driver)
results["neo4j_nodes"] = "ok"
except Exception as e:
errors.append(f"neo4j_nodes: {e}")
logger.error(f" {e}")
results["neo4j_nodes"] = "error"
# ── Ensure kcar is a platform super-admin ─────────────────────────────────
logger.info("[8] Ensuring kcar platform admin record...")
KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28"
try:
_rest_upsert(url, headers, "admin_profiles", {
"id": KCAR_ID,
"email": "kcar@kevlarai.com",
"display_name": "Kevin Carroll",
"admin_role": "super_admin",
"is_super_admin": True,
"metadata": {"seeded": True},
}, on_conflict="id")
logger.info(" kcar → admin_profiles ✓")
except Exception as e:
errors.append(f"kcar_admin: {e}")
logger.error(f" {e}")
# Fix kcar's auth user_metadata and profiles.user_type to "platform_admin".
# Without this, POST /user/init assigns kcar to the default school on first login.
try:
r = requests.put(
f"{url}/auth/v1/admin/users/{KCAR_ID}",
headers=headers,
json={"user_metadata": {"user_type": "platform_admin"}},
)
if r.status_code in (200, 201):
logger.info(" kcar → auth user_metadata: platform_admin ✓")
else:
logger.warning(f" kcar user_metadata patch failed ({r.status_code}): {r.text[:120]}")
except Exception as e:
errors.append(f"kcar_user_type: {e}")
logger.error(f" {e}")
try:
r = requests.patch(
f"{url}/rest/v1/profiles",
headers={**headers, "Prefer": "return=minimal"},
params={"id": f"eq.{KCAR_ID}"},
json={"school_id": None},
)
if r.status_code in (200, 204):
logger.info(" kcar → profiles.school_id: null ✓")
else:
logger.warning(f" kcar profiles patch failed ({r.status_code}): {r.text[:120]}")
except Exception as e:
errors.append(f"kcar_profile: {e}")
logger.error(f" {e}")
# ── Summary ───────────────────────────────────────────────────────────────
results["success"] = len(errors) == 0
results["errors"] = errors
_print_credential_sheet(created_users, accounts)
logger.info("\n" + "=" * 60)
if errors:
logger.info(f"SEED COMPLETE with {len(errors)} error(s)")
for e in errors:
logger.info(f"{e}")
else:
logger.info("SEED COMPLETE — all steps succeeded")
logger.info("=" * 60)
return results
def _print_credential_sheet(created_users: Dict[str, str], accounts: List[Dict]):
PAD = 36
include_passwords = os.getenv("PRINT_SEED_CREDENTIALS", "").lower() in {"1", "true", "yes", "on"}
logger.info("\n" + "=" * 70)
logger.info("CREDENTIAL SHEET" + ("" if include_passwords else " (passwords redacted; set PRINT_SEED_CREDENTIALS=true to print)"))
logger.info("=" * 70)
logger.info(f" {'ROLE':<16} {'EMAIL':<{PAD}} PASSWORD")
logger.info(f" {'-'*14} {'-'*(PAD-2)} -----------")
platform_password = get_seed_password("platform_admin") if include_passwords else "<redacted>"
logger.info(f" {'[platform admin]':<16} {'kcar@kevlarai.com':<{PAD}} {platform_password}")
logger.info("")
for school_id, domain, label in [
(KEVLARAI_ID, KEVLARAI_DOMAIN, "KevlarAI"),
(GREENFIELD_ID, GREENFIELD_DOMAIN, "Greenfield Academy"),
]:
logger.info(f" [{label}]")
for spec in accounts:
if spec["institute_id"] != school_id:
continue
uid = created_users.get(spec["email"], "")
status = f"[{uid[:8]}]" if uid != "" else "[MISSING]"
password = spec["password"] if include_passwords else "<redacted>"
logger.info(f" {spec['role']:<16} {spec['email']:<{PAD}} {password} {status}")
logger.info("")
logger.info("=" * 70)
if __name__ == "__main__":
import json
print(json.dumps(seed(test="--test" in os.sys.argv), indent=2, default=str))