One-off script to populate the new device_serials array for assignments
made before the assign/unassign sync existed. Treats each device's
user_list as the source of truth and sets every user's device_serials to
exactly the matching serials, so re-running it is a no-op.
Dry run by default; --apply writes (batched, <=400 per commit).
It also reports users whose doc ID != uid field (MQTT resolves users by the
uid field), users with no uid, devices with users but no serial, and
user_list entries pointing at non-existent users. user_list entries are
accepted as DocumentReferences, "users/{id}" paths or raw doc IDs, and an
entry that matches a uid field rather than a doc ID is mapped to its doc.
Dry run against current data: 13 devices, 13 users, 11 users to update,
0 doc-ID/uid mismatches, 1 dangling user_list entry
(Cx2Va72sUzDbr1T8Ebbh on BSVSPR-26I047-STD10R-88YFJP).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
5.3 KiB
Python
150 lines
5.3 KiB
Python
"""
|
|
Backfill `device_serials` on Firestore `users` docs from the devices' `user_list`.
|
|
|
|
`device_serials` is what the MQTT ACL uses to decide which vesper/{serial}/...
|
|
topics an app user may reach (see mqtt/app_users.py). New assignments keep it
|
|
in sync automatically; this script builds it for assignments that predate that.
|
|
|
|
The devices' user_list is treated as the source of truth: each user's
|
|
device_serials is set to exactly the serials of the devices that list them.
|
|
Running it twice is a no-op the second time.
|
|
|
|
Also reports:
|
|
- users whose document ID != their `uid` field (or who have no uid) — the
|
|
MQTT layer resolves users by the uid field, so this is informational,
|
|
but a user without a uid can never connect via the app
|
|
- devices that have users but no serial (can't be reached over MQTT)
|
|
- user_list entries that point to users that don't exist
|
|
|
|
Run from backend/ (locally or inside the backend container):
|
|
|
|
python scripts/backfill_user_device_serials.py # dry run
|
|
python scripts/backfill_user_device_serials.py --apply # write changes
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from google.cloud.firestore_v1 import DocumentReference # noqa: E402
|
|
|
|
from shared.firebase import init_firebase, get_db # noqa: E402
|
|
from users.service import device_serial_of # noqa: E402
|
|
|
|
BATCH_SIZE = 400 # Firestore batch limit is 500 writes
|
|
|
|
|
|
def _entry_user_id(entry) -> str:
|
|
"""user_list entries are DocumentReferences, "users/{id}" paths or raw doc IDs."""
|
|
if isinstance(entry, DocumentReference):
|
|
return entry.id
|
|
if isinstance(entry, str) and entry.strip():
|
|
return entry.strip().split("/")[-1]
|
|
return ""
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--apply", action="store_true", help="write changes (default: dry run)")
|
|
args = parser.parse_args()
|
|
|
|
init_firebase()
|
|
db = get_db()
|
|
if db is None:
|
|
print("[ERROR] Firebase not initialized — check FIREBASE_SERVICE_ACCOUNT_PATH")
|
|
return 1
|
|
|
|
mode = "APPLY" if args.apply else "DRY RUN"
|
|
print(f"=== backfill device_serials ({mode}) ===\n")
|
|
|
|
# 1. devices -> {user_doc_id: {serials}}
|
|
wanted: dict[str, set[str]] = defaultdict(set)
|
|
devices_without_serial = []
|
|
device_count = 0
|
|
for doc in db.collection("devices").stream():
|
|
device_count += 1
|
|
data = doc.to_dict() or {}
|
|
user_ids = [u for u in (_entry_user_id(e) for e in (data.get("user_list") or [])) if u]
|
|
if not user_ids:
|
|
continue
|
|
serial = device_serial_of(data)
|
|
if not serial:
|
|
devices_without_serial.append(doc.id)
|
|
continue
|
|
for uid in user_ids:
|
|
wanted[uid].add(serial)
|
|
|
|
# 2. users
|
|
users = {doc.id: (doc.reference, doc.to_dict() or {}) for doc in db.collection("users").stream()}
|
|
by_uid_field = {data.get("uid"): doc_id for doc_id, (_, data) in users.items() if data.get("uid")}
|
|
|
|
# user_list entries that don't match a doc ID but do match a uid field
|
|
dangling = []
|
|
for ref_id in list(wanted):
|
|
if ref_id in users:
|
|
continue
|
|
if ref_id in by_uid_field:
|
|
wanted[by_uid_field[ref_id]].update(wanted.pop(ref_id))
|
|
else:
|
|
dangling.append((ref_id, sorted(wanted.pop(ref_id))))
|
|
|
|
id_mismatch = []
|
|
no_uid = []
|
|
changes = []
|
|
for doc_id, (ref, data) in sorted(users.items()):
|
|
uid = data.get("uid") or ""
|
|
if not uid:
|
|
no_uid.append(doc_id)
|
|
elif uid != doc_id:
|
|
id_mismatch.append((doc_id, uid))
|
|
|
|
current = data.get("device_serials")
|
|
desired = sorted(wanted.get(doc_id, set()))
|
|
if current is None and not desired:
|
|
continue # nothing assigned and field absent — leave the doc alone
|
|
if list(current or []) != desired:
|
|
changes.append((doc_id, ref, list(current or []), desired))
|
|
|
|
# 3. report
|
|
print(f"Scanned {device_count} devices, {len(users)} users.\n")
|
|
|
|
print(f"Users to update: {len(changes)}")
|
|
for doc_id, _, current, desired in changes:
|
|
print(f" users/{doc_id}: {current} -> {desired}")
|
|
|
|
print(f"\nUsers whose doc ID != uid: {len(id_mismatch)}")
|
|
for doc_id, uid in id_mismatch:
|
|
print(f" users/{doc_id} uid={uid}")
|
|
|
|
print(f"\nUsers with no uid field (cannot use app MQTT login): {len(no_uid)}")
|
|
for doc_id in no_uid:
|
|
print(f" users/{doc_id}")
|
|
|
|
print(f"\nDevices with users but no serial_number/device_id: {len(devices_without_serial)}")
|
|
for doc_id in devices_without_serial:
|
|
print(f" devices/{doc_id}")
|
|
|
|
print(f"\nuser_list entries pointing to missing users: {len(dangling)}")
|
|
for ref_id, serials in dangling:
|
|
print(f" {ref_id} (on devices {serials})")
|
|
|
|
# 4. write
|
|
if not args.apply:
|
|
print("\nDry run — nothing written. Re-run with --apply to write.")
|
|
return 0
|
|
|
|
for i in range(0, len(changes), BATCH_SIZE):
|
|
batch = db.batch()
|
|
for _, ref, _, desired in changes[i:i + BATCH_SIZE]:
|
|
batch.update(ref, {"device_serials": desired})
|
|
batch.commit()
|
|
print(f"\nWrote device_serials on {len(changes)} user docs.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|