319 lines
9.8 KiB
Python
319 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""User directory for Mattermost export search (resolve display names to usernames)."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
from typing import Any, Dict, Iterable, List, Optional
|
|
|
|
|
|
def _norm(s: Optional[str]) -> str:
|
|
return (s or "").strip()
|
|
|
|
|
|
def _full_name(first_name: str, last_name: str) -> str:
|
|
parts = [_norm(first_name), _norm(last_name)]
|
|
return " ".join(p for p in parts if p)
|
|
|
|
|
|
def ensure_users_schema(conn: sqlite3.Connection) -> None:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
username TEXT PRIMARY KEY,
|
|
first_name TEXT NOT NULL DEFAULT '',
|
|
last_name TEXT NOT NULL DEFAULT '',
|
|
nickname TEXT NOT NULL DEFAULT '',
|
|
full_name TEXT NOT NULL DEFAULT '',
|
|
updated_at TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_users_full_name ON users(full_name)")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_users_first_name ON users(first_name)")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_users_last_name ON users(last_name)")
|
|
|
|
|
|
def profile_from_api_user(user: Dict[str, Any]) -> Dict[str, str]:
|
|
first_name = _norm(user.get("first_name"))
|
|
last_name = _norm(user.get("last_name"))
|
|
nickname = _norm(user.get("nickname"))
|
|
username = _norm(user.get("username"))
|
|
return {
|
|
"username": username,
|
|
"first_name": first_name,
|
|
"last_name": last_name,
|
|
"nickname": nickname,
|
|
"full_name": _full_name(first_name, last_name),
|
|
}
|
|
|
|
|
|
def load_users_json(path: str) -> List[Dict[str, str]]:
|
|
with open(path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
if not isinstance(data, list):
|
|
raise ValueError(f"users.json must be a JSON array: {path}")
|
|
out: List[Dict[str, str]] = []
|
|
for item in data:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
username = _norm(item.get("username"))
|
|
if not username:
|
|
continue
|
|
first_name = _norm(item.get("first_name"))
|
|
last_name = _norm(item.get("last_name"))
|
|
nickname = _norm(item.get("nickname"))
|
|
out.append(
|
|
{
|
|
"username": username,
|
|
"first_name": first_name,
|
|
"last_name": last_name,
|
|
"nickname": nickname,
|
|
"full_name": _full_name(first_name, last_name),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def find_users_json(root: str) -> Optional[str]:
|
|
direct = os.path.join(root, "users.json")
|
|
if os.path.isfile(direct):
|
|
return direct
|
|
return None
|
|
|
|
|
|
def fetch_users_from_api(config_path: str) -> List[Dict[str, str]]:
|
|
with open(config_path, encoding="utf-8") as f:
|
|
config = json.load(f)
|
|
host = _norm(config.get("host"))
|
|
token = _norm(config.get("token"))
|
|
if not host or not token:
|
|
raise ValueError(f"config must contain host and token: {config_path}")
|
|
|
|
from mattermostdriver import Driver
|
|
|
|
d = Driver(
|
|
{
|
|
"url": host,
|
|
"token": token,
|
|
"scheme": "https",
|
|
"port": 443,
|
|
"verify": True,
|
|
}
|
|
)
|
|
d.login()
|
|
|
|
profiles: List[Dict[str, str]] = []
|
|
page = 0
|
|
while True:
|
|
users_resp = d.users.get_users(params={"per_page": 200, "page": page})
|
|
if not users_resp:
|
|
break
|
|
for user in users_resp:
|
|
profile = profile_from_api_user(user)
|
|
if profile["username"]:
|
|
profiles.append(profile)
|
|
page += 1
|
|
return profiles
|
|
|
|
|
|
def usernames_from_posts(conn: sqlite3.Connection) -> List[str]:
|
|
rows = conn.execute("SELECT DISTINCT username FROM posts WHERE username != '' ORDER BY username").fetchall()
|
|
return [str(row[0]) for row in rows]
|
|
|
|
|
|
def upsert_users(conn: sqlite3.Connection, profiles: Iterable[Dict[str, str]]) -> int:
|
|
ensure_users_schema(conn)
|
|
now = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
count = 0
|
|
for profile in profiles:
|
|
username = _norm(profile.get("username"))
|
|
if not username:
|
|
continue
|
|
first_name = _norm(profile.get("first_name"))
|
|
last_name = _norm(profile.get("last_name"))
|
|
nickname = _norm(profile.get("nickname"))
|
|
full_name = _norm(profile.get("full_name")) or _full_name(first_name, last_name)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO users (username, first_name, last_name, nickname, full_name, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(username) DO UPDATE SET
|
|
first_name = excluded.first_name,
|
|
last_name = excluded.last_name,
|
|
nickname = excluded.nickname,
|
|
full_name = excluded.full_name,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(username, first_name, last_name, nickname, full_name, now),
|
|
)
|
|
count += 1
|
|
return count
|
|
|
|
|
|
def refresh_user_directory(
|
|
db_path: str,
|
|
root: str,
|
|
*,
|
|
config_path: Optional[str] = None,
|
|
from_api: bool = False,
|
|
) -> Dict[str, Any]:
|
|
conn = sqlite3.connect(db_path)
|
|
ensure_users_schema(conn)
|
|
sources: List[str] = []
|
|
profiles: List[Dict[str, str]] = []
|
|
|
|
users_json = find_users_json(root)
|
|
if users_json:
|
|
profiles.extend(load_users_json(users_json))
|
|
sources.append(f"users.json:{users_json}")
|
|
|
|
if from_api:
|
|
cfg = config_path or os.environ.get("MM_CONFIG_PATH", "config.json")
|
|
if not os.path.isfile(cfg):
|
|
raise FileNotFoundError(f"Mattermost config not found: {cfg}")
|
|
profiles.extend(fetch_users_from_api(cfg))
|
|
sources.append(f"api:{cfg}")
|
|
|
|
with conn:
|
|
upserted_named = upsert_users(conn, profiles) if profiles else 0
|
|
post_only = 0
|
|
known = {p["username"] for p in profiles}
|
|
for username in usernames_from_posts(conn):
|
|
if username in known:
|
|
continue
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO users (username, first_name, last_name, nickname, full_name, updated_at)
|
|
VALUES (?, '', '', '', '', ?)
|
|
ON CONFLICT(username) DO NOTHING
|
|
""",
|
|
(username, dt.datetime.now(dt.timezone.utc).isoformat()),
|
|
)
|
|
post_only += 1
|
|
sources.append("posts:distinct_usernames")
|
|
|
|
total = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
|
conn.close()
|
|
return {
|
|
"db": db_path,
|
|
"root": root,
|
|
"sources": sources,
|
|
"upserted_profiles": upserted_named,
|
|
"added_from_posts": post_only,
|
|
"total_users": total,
|
|
}
|
|
|
|
|
|
def _user_row_to_dict(row: sqlite3.Row) -> Dict[str, Any]:
|
|
first_name = row["first_name"] or ""
|
|
last_name = row["last_name"] or ""
|
|
nickname = row["nickname"] or ""
|
|
full_name = row["full_name"] or _full_name(first_name, last_name)
|
|
display = full_name or nickname or row["username"]
|
|
return {
|
|
"username": row["username"],
|
|
"first_name": first_name,
|
|
"last_name": last_name,
|
|
"nickname": nickname,
|
|
"name": display,
|
|
"full_name": full_name,
|
|
}
|
|
|
|
|
|
def _match_score(ref: str, row: sqlite3.Row) -> int:
|
|
ref_l = ref.casefold()
|
|
username = row["username"] or ""
|
|
first_name = row["first_name"] or ""
|
|
last_name = row["last_name"] or ""
|
|
nickname = row["nickname"] or ""
|
|
full_name = row["full_name"] or _full_name(first_name, last_name)
|
|
|
|
if username.casefold() == ref_l:
|
|
return 100
|
|
for field in (first_name, last_name, nickname, full_name):
|
|
if field and field.casefold() == ref_l:
|
|
return 90
|
|
for field in (first_name, last_name, nickname, full_name, username):
|
|
if field and ref_l in field.casefold():
|
|
return 70
|
|
return 0
|
|
|
|
|
|
def _field_contains(field: str, ref: str) -> bool:
|
|
return ref.casefold() in (field or "").casefold()
|
|
|
|
|
|
def search_users(
|
|
db_path: str,
|
|
query: str,
|
|
*,
|
|
limit: int = 10,
|
|
) -> List[Dict[str, Any]]:
|
|
ref = _norm(query)
|
|
if not ref:
|
|
return []
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
ensure_users_schema(conn)
|
|
rows = conn.execute(
|
|
"SELECT username, first_name, last_name, nickname, full_name FROM users"
|
|
).fetchall()
|
|
conn.close()
|
|
|
|
matched = [
|
|
row
|
|
for row in rows
|
|
if any(
|
|
_field_contains(field, ref)
|
|
for field in (
|
|
row["username"],
|
|
row["first_name"],
|
|
row["last_name"],
|
|
row["nickname"],
|
|
row["full_name"],
|
|
)
|
|
)
|
|
]
|
|
scored = [(_match_score(ref, row), _user_row_to_dict(row)) for row in matched]
|
|
scored.sort(key=lambda item: (-item[0], item[1]["username"]))
|
|
return [item[1] for item in scored[:limit]]
|
|
|
|
|
|
def resolve_user(db_path: str, ref: str) -> Dict[str, Any]:
|
|
ref = _norm(ref)
|
|
if not ref:
|
|
raise ValueError("ref must be non-empty")
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
ensure_users_schema(conn)
|
|
rows = conn.execute(
|
|
"SELECT username, first_name, last_name, nickname, full_name FROM users"
|
|
).fetchall()
|
|
conn.close()
|
|
|
|
best = [row for row in rows if _match_score(ref, row) > 0]
|
|
best.sort(key=lambda row: (-_match_score(ref, row), row["username"]))
|
|
|
|
if not best:
|
|
return {"found": False, "ref": ref, "matches": []}
|
|
|
|
top = _match_score(ref, best[0])
|
|
top_matches = [row for row in best if _match_score(ref, row) == top]
|
|
result_matches = [_user_row_to_dict(row) for row in top_matches[:10]]
|
|
|
|
if len(result_matches) == 1:
|
|
user = result_matches[0]
|
|
return {"found": True, "ref": ref, "username": user["username"], **user}
|
|
|
|
return {
|
|
"found": True,
|
|
"ambiguous": True,
|
|
"ref": ref,
|
|
"matches": result_matches,
|
|
}
|