#!/usr/bin/env python3 """Generate dev-test-fixture.json for admin Dev tab import.""" import json import random from datetime import datetime, timedelta from pathlib import Path PREFIX = "dev_" PASSWORD = "socialvrlab" CLIENT_PREFIX = "DEV-CL-" SCALE = 5 # Fixed RNG + anchor time so the file is reproducible but not grid-like. RNG = random.Random(42) ANCHOR = datetime(2026, 5, 27, 15, 30, 0) BUNDLE_PATH = Path(__file__).resolve().parents[2] / "questionnaires_bundle_2026-05-28.json" FALLBACK_QUESTIONNAIRE_IDS = [ "questionnaire_1_demographic_information", "questionnaire_2_rhs", "questionnaire_3_integration_index", "questionnaire_4_consultation_results", "questionnaire_5_final_interview", "questionnaire_6_follow_up_survey", ] NUM_ADMINS = 2 * SCALE NUM_SUPERVISORS = 3 * SCALE NUM_COACHES = 20 * SCALE NUM_CLIENTS = 100 * SCALE CLIENTS_WITHOUT_COMPLETIONS = 15 * SCALE # Roughly how many clients (with a coach) get at least one questionnaire. COMPLETION_PARTICIPATION = 0.82 def load_questionnaire_ids() -> list[str]: if not BUNDLE_PATH.is_file(): return FALLBACK_QUESTIONNAIRE_IDS bundle = json.loads(BUNDLE_PATH.read_text(encoding="utf-8")) ids = [ item["questionnaire"]["questionnaireID"] for item in bundle.get("questionnaires", []) if item.get("questionnaire", {}).get("questionnaireID") ] return ids or FALLBACK_QUESTIONNAIRE_IDS def ts_days_ago(min_days: float, max_days: float) -> int: days = RNG.uniform(min_days, max_days) return int((ANCHOR - timedelta(days=days)).timestamp()) def variant_seed(client_code: str, qn_id: str, version_index: int) -> int: raw = f"{client_code}:{qn_id}:v{version_index}" return int.from_bytes(raw.encode(), "big") % 100_000 def assign_coaches(clients: list[dict], coaches: list[dict]) -> None: """Uneven coach load: a few busy coaches, several light ones.""" coach_names = [c["username"] for c in coaches] weights = [] for i, _ in enumerate(coach_names): # Coaches 1–4 and 7, 12 get more clients; tail is lighter. base = RNG.uniform(0.4, 1.0) if (i % 7) in (0, 1, 2): base *= RNG.uniform(2.0, 4.5) if i % 11 == 0: base *= RNG.uniform(0.15, 0.45) weights.append(base) for row in clients: row["coach"] = RNG.choices(coach_names, weights=weights, k=1)[0] def build_completions( questionnaire_ids: list[str], clients_with_data: list[str], ) -> list[dict]: """ Natural-ish completion patterns: - Sequential questionnaire funnel (not random qn per slot) - Irregular timestamps and gaps between uploads - ~20% of client/qn pairs have 2–4 archived versions """ completions: list[dict] = [] qn_count = len(questionnaire_ids) # How many questionnaires a client completes (skewed toward mid funnel). qn_count_weights = [8, 14, 22, 24, 18, 10, 4][:qn_count] if len(qn_count_weights) < qn_count: qn_count_weights += [2] * (qn_count - len(qn_count_weights)) for client_code in clients_with_data: if RNG.random() > COMPLETION_PARTICIPATION: continue num_qn = RNG.choices( list(range(1, qn_count + 1)), weights=qn_count_weights[:qn_count], k=1, )[0] selected = questionnaire_ids[:num_qn] # First activity somewhere in the last ~6 months; some clients are very recent. cursor = ts_days_ago(3, 185) if RNG.random() < 0.12: cursor = ts_days_ago(0.5, 14) for qn_id in selected: version_count = 1 roll = RNG.random() if roll < 0.08 and num_qn >= 2: version_count = 4 elif roll < 0.20: version_count = 3 elif roll < 0.38: version_count = 2 uploads = [] for v in range(version_count): if v > 0: # Re-upload after days or weeks (sometimes same day correction). if RNG.random() < 0.18: gap_sec = RNG.randint(2 * 3600, 36 * 3600) else: gap_sec = RNG.randint(2 * 86400, 55 * 86400) cursor += gap_sec else: # First upload for this qn: often a few days after previous qn. cursor += RNG.randint(0, 12 * 86400) duration = RNG.randint(4 * 60, 55 * 60) started = cursor - duration completed = cursor + RNG.randint(0, 120) uploads.append({ "submittedAt": completed, "startedAt": started, "variant": variant_seed(client_code, qn_id, v), }) completions.append({ "clientCode": client_code, "questionnaireID": qn_id, "versions": version_count, "uploads": uploads, }) RNG.shuffle(completions) return completions def main(): questionnaire_ids = load_questionnaire_ids() admins = [ {"username": f"{PREFIX}admin_{i}", "location": f"Dev Admin Standort {i}"} for i in range(1, NUM_ADMINS + 1) ] supervisors = [ {"username": f"{PREFIX}supervisor_{i}", "location": f"Dev Supervisor Region {i}"} for i in range(1, NUM_SUPERVISORS + 1) ] coaches = [] for i in range(1, NUM_COACHES + 1): sv = (i - 1) % NUM_SUPERVISORS + 1 coaches.append({ "username": f"{PREFIX}coach_{i:02d}", "supervisor": f"{PREFIX}supervisor_{sv}", }) clients = [ {"clientCode": f"{CLIENT_PREFIX}{i:04d}"} for i in range(1, NUM_CLIENTS + 1) ] assign_coaches(clients, coaches) all_codes = [c["clientCode"] for c in clients] no_data_codes = set(RNG.sample(all_codes, min(CLIENTS_WITHOUT_COMPLETIONS, len(all_codes)))) clients_with_data = [c for c in all_codes if c not in no_data_codes] completions = build_completions(questionnaire_ids, clients_with_data) total_uploads = sum(c["versions"] for c in completions) multi_version_pairs = sum(1 for c in completions if c["versions"] > 1) bundle_note = BUNDLE_PATH.name if BUNDLE_PATH.is_file() else "fallback questionnaire list" fixture = { "fixtureVersion": 2, "description": ( f"Dev/test users, clients, completions with uneven coach load and upload history " f"({SCALE}x scale). Questionnaires from {bundle_note}. Password: socialvrlab." ), "prefix": PREFIX, "defaultPassword": PASSWORD, "sourceBundle": bundle_note, "questionnaireIDs": questionnaire_ids, "admins": admins, "supervisors": supervisors, "coaches": coaches, "clients": clients, "completions": completions, "stats": { "scale": SCALE, "admins": len(admins), "supervisors": len(supervisors), "coaches": len(coaches), "clients": len(clients), "clientsWithoutCompletions": CLIENTS_WITHOUT_COMPLETIONS, "completionRecords": len(completions), "totalUploads": total_uploads, "multiVersionPairs": multi_version_pairs, "questionnaires": len(questionnaire_ids), }, } out = Path(__file__).resolve().parents[2] / "dev-test-fixture.json" out.write_text(json.dumps(fixture, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(f"Wrote {out}") print(json.dumps(fixture["stats"], indent=2)) if __name__ == "__main__": main()