Initial commit
This commit is contained in:
commit
5f2db08b43
100 files changed
+16985
No files matched your search
@@ -0,0 +1,181 @@
|
||||
"""Personalization store + watch/topic toggles + a minimal dev auth (spec §5).
|
||||
|
||||
The persistence is a small JSON file (documented in DASHBOARD.md) holding one
|
||||
preferences blob per user_id, matching API_CONTRACT section 3.3. It is
|
||||
intentionally simple and swappable: replacing PrefsStore with a DB-backed store
|
||||
is a local change; the preferences *shape* (and therefore the whole contract)
|
||||
is auth-method-independent, which is the point of Open Decision D1.
|
||||
|
||||
A minimal LOCAL account (LocalAuth) is provided as the D1a default so the
|
||||
dashboard is runnable end-to-end without an IdP. It is explicitly a development
|
||||
placeholder (no password hashing, single-host JSON): swap it for a real auth
|
||||
service in production. Auth is NOT modelled in the data contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preferences store (API_CONTRACT section 3.3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PrefsStore:
|
||||
"""File-backed per-user preferences. One blob per user_id.
|
||||
|
||||
On-disk shape is `{"u_001": {...}, ...}` so the store is human-readable and
|
||||
diff-stable. All mutating ops are idempotent: re-adding an existing
|
||||
watch/topic is a no-op, and removing an absent one does not error.
|
||||
"""
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self._data = self._load()
|
||||
|
||||
# -- persistence --------------------------------------------------
|
||||
def _load(self):
|
||||
if not os.path.exists(self.path):
|
||||
return {}
|
||||
try:
|
||||
with open(self.path, encoding="utf-8") as fh:
|
||||
return json.load(fh) or {}
|
||||
except (ValueError, OSError):
|
||||
return {}
|
||||
|
||||
def _save(self):
|
||||
os.makedirs(os.path.dirname(os.path.abspath(self.path)) or ".",
|
||||
exist_ok=True)
|
||||
tmp = self.path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(self._data, fh, indent=2, sort_keys=True)
|
||||
os.replace(tmp, self.path)
|
||||
|
||||
# -- reads --------------------------------------------------------
|
||||
def get(self, user_id):
|
||||
return self._data.get(user_id) or {}
|
||||
|
||||
def has(self, user_id):
|
||||
return user_id in self._data and bool(self._data[user_id])
|
||||
|
||||
def list_users(self):
|
||||
return sorted(k for k in self._data.keys() if self._data[k])
|
||||
|
||||
# -- writes -------------------------------------------------------
|
||||
def put(self, user_id, prefs):
|
||||
merged = self.get(user_id)
|
||||
merged.update(prefs or {})
|
||||
self._clean(merged)
|
||||
self._data[user_id] = merged
|
||||
self._save()
|
||||
return merged
|
||||
|
||||
def add_watch(self, user_id, legislator_id):
|
||||
prefs = self.get(user_id)
|
||||
watch = list(prefs.get("watched_legislators") or [])
|
||||
if legislator_id not in watch:
|
||||
watch.append(legislator_id)
|
||||
return self._set(user_id, "watched_legislators", watch)
|
||||
|
||||
def remove_watch(self, user_id, legislator_id):
|
||||
prefs = self.get(user_id)
|
||||
watch = [x for x in (prefs.get("watched_legislators") or [])
|
||||
if x != legislator_id]
|
||||
return self._set(user_id, "watched_legislators", watch)
|
||||
|
||||
def add_topic(self, user_id, tag):
|
||||
prefs = self.get(user_id)
|
||||
topics = list(prefs.get("opted_in_tags") or [])
|
||||
if tag not in topics:
|
||||
topics.append(tag)
|
||||
return self._set(user_id, "opted_in_tags", topics)
|
||||
|
||||
def remove_topic(self, user_id, tag):
|
||||
prefs = self.get(user_id)
|
||||
topics = [t for t in (prefs.get("opted_in_tags") or []) if t != tag]
|
||||
return self._set(user_id, "opted_in_tags", topics)
|
||||
|
||||
def _set(self, user_id, key, value):
|
||||
prefs = self.get(user_id)
|
||||
prefs[key] = value
|
||||
self._clean(prefs)
|
||||
self._data[user_id] = prefs
|
||||
self._save()
|
||||
return prefs
|
||||
|
||||
@staticmethod
|
||||
def _clean(prefs):
|
||||
for key in ("watched_legislators", "opted_in_tags", "districts"):
|
||||
prefs.setdefault(key, [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal local account (Open Decision D1: D1a default — dev placeholder)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LocalAuth:
|
||||
"""A development local-account auth (email + a derived bearer token).
|
||||
|
||||
This is the D1a default so the dashboard runs with no external IdP. It is
|
||||
deliberately NOT production-grade: no password hashing, no rotation, single
|
||||
host. Production should swap in a real auth service or the D1b OAuth path;
|
||||
the preferences shape is identical, so nothing else in the system changes.
|
||||
"""
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self._data = self._load()
|
||||
|
||||
def _load(self):
|
||||
if not os.path.exists(self.path):
|
||||
return {}
|
||||
try:
|
||||
with open(self.path, encoding="utf-8") as fh:
|
||||
return json.load(fh) or {}
|
||||
except (ValueError, OSError):
|
||||
return {}
|
||||
|
||||
def _save(self):
|
||||
os.makedirs(os.path.dirname(os.path.abspath(self.path)) or ".",
|
||||
exist_ok=True)
|
||||
tmp = self.path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(self._data, fh, indent=2, sort_keys=True)
|
||||
os.replace(tmp, self.path)
|
||||
|
||||
@staticmethod
|
||||
def _new_user_id():
|
||||
return "u_" + uuid.uuid4().hex[:8]
|
||||
|
||||
@staticmethod
|
||||
def _token_for(user_id, email):
|
||||
h = hashlib.sha256(("local:" + user_id + ":" + email).encode()).hexdigest()
|
||||
return "dev." + h[:24]
|
||||
|
||||
def register(self, email, user_id=None):
|
||||
email = (email or "").strip().lower()
|
||||
for uid, rec in self._data.items():
|
||||
if rec.get("email") == email:
|
||||
rec["token"] = self._token_for(uid, email)
|
||||
self._save()
|
||||
return {"user_id": uid, "token": rec["token"], "email": email}
|
||||
uid = user_id or self._new_user_id()
|
||||
self._data[uid] = {"email": email, "token": self._token_for(uid, email)}
|
||||
self._save()
|
||||
return {"user_id": uid, "token": self._data[uid]["token"], "email": email}
|
||||
|
||||
def verify_token(self, token):
|
||||
if not token:
|
||||
return None
|
||||
for uid, rec in self._data.items():
|
||||
t = rec.get("token") or ""
|
||||
if token == t or token == t[4:] or token == uid:
|
||||
return uid
|
||||
return None
|
||||
|
||||
def user_by_id(self, user_id):
|
||||
return self._data.get(user_id)
|
||||
Reference in new issue
Block a user