This commit is contained in:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user