merging users
Build and Push Docker Image / build (push) Successful in 9s

This commit is contained in:
2026-05-28 15:56:36 -05:00
parent a963bd6e31
commit 98793fbecf
4 changed files with 925 additions and 38 deletions
+278 -11
View File
@@ -104,6 +104,31 @@ def init_db():
"ALTER TABLE user_cache ADD COLUMN filtered INTEGER NOT NULL DEFAULT 0"
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS persons (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
filtered INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS person_members (
person_id TEXT NOT NULL,
controller_id TEXT NOT NULL,
actor_id TEXT NOT NULL,
PRIMARY KEY (controller_id, actor_id),
FOREIGN KEY (person_id) REFERENCES persons(id) ON DELETE CASCADE
)
"""
)
db.execute(
"CREATE INDEX IF NOT EXISTS idx_person_members_person ON person_members(person_id)"
)
# Seed a Default controller from env vars when the table is empty.
existing = db.execute("SELECT COUNT(*) AS n FROM controllers").fetchone()["n"]
default_id = None
@@ -313,17 +338,23 @@ def first_badge_status():
sql = """
SELECT
b.actor_id,
b.controller_id,
c.name AS source,
MIN(b.ts) AS first_ts,
MAX(b.ts) AS latest_ts,
COALESCE(pm.person_id, b.controller_id || '|' || b.actor_id) AS group_key,
pm.person_id AS person_id,
COALESCE(
p.display_name,
u.full_name,
'Unknown (' || SUBSTR(b.actor_id,1,8) || '...)'
) AS name,
COALESCE(u.filtered, 0) AS filtered
COALESCE(p.filtered, u.filtered, 0) AS filtered,
MIN(b.ts) AS first_ts,
MAX(b.ts) AS latest_ts,
GROUP_CONCAT(DISTINCT c.name) AS sources,
GROUP_CONCAT(DISTINCT b.controller_id) AS controller_ids,
GROUP_CONCAT(DISTINCT b.actor_id) AS actor_ids
FROM badge_events b
LEFT JOIN person_members pm
ON pm.controller_id = b.controller_id AND pm.actor_id = b.actor_id
LEFT JOIN persons p ON p.id = pm.person_id
LEFT JOIN user_cache u
ON u.actor_id = b.actor_id AND u.controller_id = b.controller_id
LEFT JOIN controllers c ON c.id = b.controller_id
@@ -334,8 +365,8 @@ def first_badge_status():
sql += " AND b.controller_id = ?"
params.append(controller_filter)
if not include_filtered:
sql += " AND COALESCE(u.filtered, 0) = 0"
sql += " GROUP BY b.actor_id, b.controller_id ORDER BY first_ts ASC"
sql += " AND COALESCE(p.filtered, u.filtered, 0) = 0"
sql += " GROUP BY group_key ORDER BY first_ts ASC"
with get_db() as db:
rows = db.execute(sql, params).fetchall()
@@ -344,11 +375,22 @@ def first_badge_status():
for r in rows:
first = r["first_ts"]
latest = r["latest_ts"]
sources_csv = r["sources"] or ""
ctrl_ids_csv = r["controller_ids"] or ""
actor_ids_csv = r["actor_ids"] or ""
sources_list = [s for s in sources_csv.split(",") if s]
ctrl_ids_list = [s for s in ctrl_ids_csv.split(",") if s]
actor_ids_list = [s for s in actor_ids_csv.split(",") if s]
result.append({
"actor_id": r["actor_id"],
"controller_id": r["controller_id"],
"person_id": r["person_id"],
"actor_id": actor_ids_list[0] if actor_ids_list else None,
"controller_id": ctrl_ids_list[0] if ctrl_ids_list else None,
"actor_ids": actor_ids_list,
"controller_ids": ctrl_ids_list,
"name": r["name"],
"source": r["source"] or "",
"source": sources_list[0] if sources_list else "",
"sources": sources_list or [""],
"merged": bool(r["person_id"]),
"first_ts": first,
"latest_ts": latest if latest != first else None,
"status": "ON TIME" if first <= cutoff_end else "LATE",
@@ -678,6 +720,231 @@ def update_user(controller_id, actor_id):
})
# ---------------------------------------------------------------------------
# Persons (merge identities across controllers)
# ---------------------------------------------------------------------------
def _person_to_dict(db, person_row):
members = db.execute(
"""
SELECT pm.controller_id, pm.actor_id,
c.name AS controller_name,
u.full_name
FROM person_members pm
LEFT JOIN controllers c ON c.id = pm.controller_id
LEFT JOIN user_cache u
ON u.controller_id = pm.controller_id AND u.actor_id = pm.actor_id
WHERE pm.person_id = ?
ORDER BY c.name, u.full_name
""",
(person_row["id"],),
).fetchall()
return {
"id": person_row["id"],
"display_name": person_row["display_name"],
"filtered": bool(person_row["filtered"]),
"created_at": person_row["created_at"],
"members": [
{
"controller_id": m["controller_id"],
"controller_name": m["controller_name"] or "",
"actor_id": m["actor_id"],
"full_name": m["full_name"] or f"User {m['actor_id'][:8]}",
}
for m in members
],
}
@app.route("/api/persons", methods=["GET"])
def list_persons():
with get_db() as db:
rows = db.execute("SELECT * FROM persons ORDER BY display_name").fetchall()
return jsonify([_person_to_dict(db, r) for r in rows])
@app.route("/api/persons", methods=["POST"])
def create_person():
body = request.get_json(silent=True) or {}
name = (body.get("display_name") or "").strip()
members = body.get("members") or []
if not name:
return jsonify({"error": "display_name is required"}), 400
if not isinstance(members, list) or len(members) < 1:
return jsonify({"error": "at least one member required"}), 400
cleaned = []
for m in members:
cid = (m.get("controller_id") or "").strip()
aid = (m.get("actor_id") or "").strip()
if not cid or not aid:
return jsonify({"error": "each member needs controller_id and actor_id"}), 400
cleaned.append((cid, aid))
person_id = str(uuid.uuid4())
now_iso = datetime.now(timezone.utc).isoformat()
with get_db() as db:
existing = db.execute(
f"""
SELECT controller_id, actor_id FROM person_members
WHERE (controller_id, actor_id) IN ({",".join(["(?,?)"] * len(cleaned))})
""",
[v for pair in cleaned for v in pair],
).fetchall()
if existing:
conflicts = [f"{r['controller_id']}/{r['actor_id'][:8]}" for r in existing]
return jsonify({
"error": "some members already belong to another person",
"conflicts": conflicts,
}), 409
db.execute(
"INSERT INTO persons (id, display_name, filtered, created_at) VALUES (?, ?, 0, ?)",
(person_id, name, now_iso),
)
db.executemany(
"INSERT INTO person_members (person_id, controller_id, actor_id) VALUES (?, ?, ?)",
[(person_id, cid, aid) for cid, aid in cleaned],
)
db.commit()
row = db.execute("SELECT * FROM persons WHERE id = ?", (person_id,)).fetchone()
return jsonify(_person_to_dict(db, row)), 201
@app.route("/api/persons/<person_id>", methods=["PATCH"])
def update_person(person_id):
body = request.get_json(silent=True) or {}
fields, values = [], []
if "display_name" in body:
name = (body["display_name"] or "").strip()
if not name:
return jsonify({"error": "display_name cannot be empty"}), 400
fields.append("display_name = ?"); values.append(name)
if "filtered" in body:
fields.append("filtered = ?"); values.append(1 if body["filtered"] else 0)
if not fields:
return jsonify({"error": "no updatable fields provided"}), 400
values.append(person_id)
with get_db() as db:
cur = db.execute(
f"UPDATE persons SET {', '.join(fields)} WHERE id = ?", values
)
db.commit()
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
row = db.execute("SELECT * FROM persons WHERE id = ?", (person_id,)).fetchone()
return jsonify(_person_to_dict(db, row))
@app.route("/api/persons/<person_id>", methods=["DELETE"])
def delete_person(person_id):
with get_db() as db:
cur = db.execute("DELETE FROM persons WHERE id = ?", (person_id,))
db.commit()
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
return jsonify({"status": "ok"})
@app.route("/api/persons/<person_id>/members", methods=["POST"])
def add_person_member(person_id):
body = request.get_json(silent=True) or {}
cid = (body.get("controller_id") or "").strip()
aid = (body.get("actor_id") or "").strip()
if not cid or not aid:
return jsonify({"error": "controller_id and actor_id are required"}), 400
with get_db() as db:
if not db.execute("SELECT 1 FROM persons WHERE id = ?", (person_id,)).fetchone():
return jsonify({"error": "person not found"}), 404
existing = db.execute(
"SELECT person_id FROM person_members WHERE controller_id = ? AND actor_id = ?",
(cid, aid),
).fetchone()
if existing and existing["person_id"] != person_id:
return jsonify({"error": "actor already belongs to another person"}), 409
db.execute(
"""
INSERT OR IGNORE INTO person_members (person_id, controller_id, actor_id)
VALUES (?, ?, ?)
""",
(person_id, cid, aid),
)
db.commit()
row = db.execute("SELECT * FROM persons WHERE id = ?", (person_id,)).fetchone()
return jsonify(_person_to_dict(db, row))
@app.route("/api/persons/<person_id>/members/<controller_id>/<actor_id>", methods=["DELETE"])
def remove_person_member(person_id, controller_id, actor_id):
with get_db() as db:
cur = db.execute(
"""
DELETE FROM person_members
WHERE person_id = ? AND controller_id = ? AND actor_id = ?
""",
(person_id, controller_id, actor_id),
)
if cur.rowcount == 0:
return jsonify({"error": "member not found"}), 404
# If that was the last member, drop the person too.
remaining = db.execute(
"SELECT COUNT(*) AS n FROM person_members WHERE person_id = ?", (person_id,)
).fetchone()["n"]
if remaining == 0:
db.execute("DELETE FROM persons WHERE id = ?", (person_id,))
db.commit()
return jsonify({"status": "ok", "person_dissolved": True})
db.commit()
row = db.execute("SELECT * FROM persons WHERE id = ?", (person_id,)).fetchone()
return jsonify(_person_to_dict(db, row))
@app.route("/api/persons/suggestions", methods=["GET"])
def person_suggestions():
"""Return groups of (controller, actor) rows that share an exact full_name
across different controllers and are NOT already part of a person."""
with get_db() as db:
rows = db.execute(
"""
SELECT u.controller_id, u.actor_id, u.full_name,
c.name AS controller_name
FROM user_cache u
LEFT JOIN controllers c ON c.id = u.controller_id
LEFT JOIN person_members pm
ON pm.controller_id = u.controller_id AND pm.actor_id = u.actor_id
WHERE pm.person_id IS NULL
AND u.full_name IS NOT NULL
AND TRIM(u.full_name) <> ''
AND u.full_name NOT LIKE 'User %'
ORDER BY LOWER(u.full_name), c.name
"""
).fetchall()
groups = {}
for r in rows:
key = r["full_name"].strip().lower()
groups.setdefault(key, []).append({
"controller_id": r["controller_id"],
"controller_name": r["controller_name"] or "",
"actor_id": r["actor_id"],
"full_name": r["full_name"],
})
suggestions = []
for key, members in groups.items():
# Only suggest when the same name spans at least 2 distinct controllers
controllers = {m["controller_id"] for m in members}
if len(controllers) >= 2:
suggestions.append({
"display_name": members[0]["full_name"],
"members": members,
})
suggestions.sort(key=lambda s: s["display_name"].lower())
return jsonify(suggestions)
# ---------------------------------------------------------------------------
# Misc admin
# ---------------------------------------------------------------------------