| """ |
| 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) |
|
|
|
|
| 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) |
|
|