made client data browsable with dropdown and version diffs

This commit is contained in:
2026-06-01 13:29:10 +02:00
parent bee7b74e53
commit 3d56abd124
10 changed files with 971 additions and 83 deletions

View File

@ -1,6 +1,8 @@
#!/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_"
@ -8,7 +10,10 @@ PASSWORD = "socialvrlab"
CLIENT_PREFIX = "DEV-CL-"
SCALE = 5
# Newest questionnaire bundle (repo root); IDs are read from this file when present.
# 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 = [
@ -20,13 +25,15 @@ FALLBACK_QUESTIONNAIRE_IDS = [
"questionnaire_6_follow_up_survey",
]
PER_QUESTIONNAIRE = 40 * SCALE
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():
@ -40,6 +47,111 @@ def load_questionnaire_ids() -> list[str]:
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 14 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 24 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()
@ -59,40 +171,27 @@ def main():
"supervisor": f"{PREFIX}supervisor_{sv}",
})
clients = []
for i in range(1, NUM_CLIENTS + 1):
coach_idx = (i - 1) // 5 + 1
if coach_idx > NUM_COACHES:
coach_idx = NUM_COACHES
clients.append({
"clientCode": f"{CLIENT_PREFIX}{i:04d}",
"coach": f"{PREFIX}coach_{coach_idx:02d}",
})
clients_with_data = [
c["clientCode"]
for c in clients
if int(c["clientCode"].split("-")[-1]) <= NUM_CLIENTS - CLIENTS_WITHOUT_COMPLETIONS
clients = [
{"clientCode": f"{CLIENT_PREFIX}{i:04d}"}
for i in range(1, NUM_CLIENTS + 1)
]
assign_coaches(clients, coaches)
completions = []
slot = 0
for qn_id in questionnaire_ids:
for _ in range(PER_QUESTIONNAIRE):
client_code = clients_with_data[slot % len(clients_with_data)]
completions.append({
"clientCode": client_code,
"questionnaireID": qn_id,
})
slot += 1
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": 1,
"fixtureVersion": 2,
"description": (
f"Dev/test users, clients, and completed questionnaires ({SCALE}x scale). "
f"Questionnaires aligned with {bundle_note}. Skip-on-conflict import; password socialvrlab."
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,
@ -110,8 +209,9 @@ def main():
"coaches": len(coaches),
"clients": len(clients),
"clientsWithoutCompletions": CLIENTS_WITHOUT_COMPLETIONS,
"completions": len(completions),
"perQuestionnaire": PER_QUESTIONNAIRE,
"completionRecords": len(completions),
"totalUploads": total_uploads,
"multiVersionPairs": multi_version_pairs,
"questionnaires": len(questionnaire_ids),
},
}