Insight_UX_1.0 / participants.py
Aryaman25's picture
Update Insight_UX_1.0 with latest changes: add auth, participants, tests, and dev requirements
f9609df
Raw
History Blame Contribute Delete
5.11 kB
"""
participants.py
Local participant records for InsightUX's multi-participant tracking.
A participant is NOT an InsightUX login/profile (see auth.py) — it's a
tracking SUBJECT that an owner creates to keep another person's sessions
separate from their own, e.g. a UX researcher running sessions on Rahul,
Priya, Aman. Participants never authenticate and always live nested inside
their owner's own folder (users/<owner_id>/participants/<id>/), not as a
sibling top-level directory — so a participant's data cannot structurally
exist outside their owner's folder at all.
One profile.json per participant folder, not a shared index file — a
participant folder is always a complete, self-contained, movable/deletable
unit, the same way users/<owner_id>/ already is relative to DATA_DIR.
No heavy imports (stdlib only) — same reasoning as theme.py/auth.py.
"""
import os
import json
import shutil
import uuid
from datetime import datetime, timezone
class ParticipantError(Exception):
"""User-facing failures (missing name, not found, ...)."""
def participant_dir(owner_dir, participant_id):
return os.path.join(owner_dir, "participants", participant_id)
def _profile_path(owner_dir, participant_id):
return os.path.join(participant_dir(owner_dir, participant_id), "profile.json")
def _save_profile(owner_dir, participant_id, record):
path = _profile_path(owner_dir, participant_id)
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(record, f, indent=2)
os.replace(tmp, path) # atomic on POSIX and Windows
def create_participant(owner_dir, name, notes=""):
name = (name or "").strip()
if not name:
raise ParticipantError("Name is required.")
participant_id = uuid.uuid4().hex[:12]
record = {
"id": participant_id,
"name": name,
"notes": (notes or "").strip(),
"created_at": datetime.now(timezone.utc).isoformat(),
"session_count": 0,
"last_session_at": None,
}
_save_profile(owner_dir, participant_id, record)
return record
def get_participant(owner_dir, participant_id):
if not participant_id:
return None
path = _profile_path(owner_dir, participant_id)
if not os.path.exists(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
def list_participants(owner_dir):
"""Sorted by name — same predictable-picker convention as
auth.list_profiles()."""
root = os.path.join(owner_dir, "participants")
out = []
if os.path.isdir(root):
for name in os.listdir(root):
if not os.path.isdir(os.path.join(root, name)):
continue
record = get_participant(owner_dir, name)
if record:
out.append(record)
out.sort(key=lambda p: p["name"].lower())
return out
def update_participant(owner_dir, participant_id, name=None, notes=None):
"""In-place edit — name/notes are only touched when explicitly passed
(None means "leave as-is"), so a caller updating just one field can't
accidentally blank out the other. id/created_at/session_count/
last_session_at are never touched here; renaming a participant must
never look like a new one to anything reading session_count."""
record = get_participant(owner_dir, participant_id)
if not record:
raise ParticipantError("That participant no longer exists.")
if name is not None:
name = name.strip()
if not name:
raise ParticipantError("Name is required.")
record["name"] = name
if notes is not None:
record["notes"] = notes.strip()
_save_profile(owner_dir, participant_id, record)
return record
def delete_participant(owner_dir, participant_id):
"""Removes the participant's entire folder — profile.json, sessions/,
calibration.pkl, everything nested under it. Irreversible; the caller
(Api.delete_participant()) is expected to have already confirmed with
the user and to have refused this while that participant is the
active tracking subject mid-session."""
if not participant_id:
raise ParticipantError("No participant specified.")
pdir = participant_dir(owner_dir, participant_id)
if not os.path.isdir(pdir):
raise ParticipantError("That participant no longer exists.")
shutil.rmtree(pdir)
def touch_session_stats(owner_dir, participant_id):
"""Called once a session for this participant actually starts — keeps
session_count/last_session_at current without needing to re-scan every
session folder just to show them in the picker/details view later."""
record = get_participant(owner_dir, participant_id)
if not record:
return
record["session_count"] = record.get("session_count", 0) + 1
record["last_session_at"] = datetime.now(timezone.utc).isoformat()
_save_profile(owner_dir, participant_id, record)