Contacts management page + MySQL-only backend

- vm_contacts: mysql-only backend (removed file/google/carddav from _BACKENDS)
- contacts.conf: backends=mysql (no fallback)
- vm_web.py: /contacts CRUD list (list, new, edit, delete, delete_all)
- vm_web.py: /contacts/import_vcf re-imports from VCF (skips junk names)
- nav: Contacts link added for authenticated users
- fixes: _MySQLCon.__iter__ for get_settings loop; add_contact row.id check
This commit is contained in:
jp
2026-08-13 13:49:03 +01:00
parent 247c8f7318
commit 456d5f96f2
2 changed files with 210 additions and 7 deletions

View File

@ -2,11 +2,9 @@
""" """
Caller-ID -> contact name resolution for Asterisk voicemail notifications. Caller-ID -> contact name resolution for Asterisk voicemail notifications.
Backends (tried in the order given by contacts.conf 'backends='): Backends (contacts are resolved exclusively from the MySQL `contacts` table
on the asterisk DB):
mysql - MySQL contacts table on the asterisk DB (primary, fast, offline). mysql - MySQL contacts table on the asterisk DB (primary, fast, offline).
file - local vCard (.vcf) or CSV export; no auth, offline, fast.
google - Google People API via the Hermes OAuth token.
carddav - generic CardDAV with username + app password.
Everything here is best-effort: a lookup failure returns None and the caller Everything here is best-effort: a lookup failure returns None and the caller
falls back to the raw caller-ID string. Results (hits AND misses) are cached falls back to the raw caller-ID string. Results (hits AND misses) are cached
@ -393,7 +391,7 @@ def lookup_carddav(cfg, num_digits, n, log):
# ---------------------------------------------------------------- entrypoint # ---------------------------------------------------------------- entrypoint
_BACKENDS = {"mysql": lookup_mysql, "file": lookup_file, "google": lookup_google, "carddav": lookup_carddav} _BACKENDS = {"mysql": lookup_mysql}
def resolve(caller_id, log=print): def resolve(caller_id, log=print):

View File

@ -178,8 +178,9 @@ td{padding:6px 0;vertical-align:top}
def page(title, body, mailbox=None, name=None): def page(title, body, mailbox=None, name=None):
nav = "" nav = ""
if mailbox: if mailbox:
nav = ('<a href="%s/">Messages</a><a href="%s/settings">Settings</a>' nav = ('<a href="%s/">Messages</a><a href="%s/contacts">Contacts</a>'
'<a href="%s/logout">Log out</a>' % (BASE, BASE, BASE)) '<a href="%s/settings">Settings</a>'
'<a href="%s/logout">Log out</a>' % (BASE, BASE, BASE, BASE))
return HTMLResponse("""<!DOCTYPE html><html><head><meta charset="utf-8"> return HTMLResponse("""<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s</title><style>%s</style></head><body> <title>%s</title><style>%s</style></head><body>
@ -493,6 +494,210 @@ def healthz():
return {"ok": True, "messages": n} return {"ok": True, "messages": n}
# ----------------------------------------------------------------- contacts mgmt
def _contact_form(action, c=None, msg=""):
banner = '<div class="ok">%s</div>' % escape(msg) if msg else ""
name = escape((c["name"] if c else ""))
num = escape((vm_contacts.normalize_uk(vm_contacts.digits_of(c["number_e164"]))
if c and c.get("number_e164") else ""))
email_val = escape((c["email"] or "") if c else "")
if action == "edit":
title = "Edit contact"
submit = "Save"
extra = ('<input type="hidden" name="id" value="%d">' % c["id"])
else:
title = "New contact"
submit = "Create"
extra = ""
return """%s<div class="card">
<form method="post" action="%s/contacts/save">
%s
<label>Name</label>
<input type="text" name="name" required value="%s">
<label>Phone number</label>
<input type="text" name="number" value="%s">
<label>Email</label>
<input type="email" name="email" value="%s">
<div class="row">
<a class="btn" href="%s/contacts">Cancel</a>
<button type="submit" class="primary">%s</button>
</div>
</form></div>""" % (banner, BASE, extra, name, num, email_val, BASE, submit)
@app.get("/contacts", response_class=HTMLResponse)
def contacts_list(vm_session: str = Cookie(default=None), msg: str = ""):
mb = require(vm_session)
con = vm_store.connect()
total = con.execute("SELECT COUNT(*) c FROM contacts").fetchone()["c"]
rows = con.execute(
"SELECT id, number_e164, name, email, updated_at FROM contacts "
"ORDER BY name ASC, id DESC"
).fetchall()
con.close()
banner = '<div class="ok">%s</div>' % escape(msg) if msg else ""
if not rows:
return page("Contacts", banner + '<div class="card empty">'
'No contacts yet. Use the button below to import from your '
'address book, or add them one by one.</div>'
'<div class="row"><a class="btn" href="%s/contacts/new">'
'+ New contact</a></div>', mb)
out = [banner, '<div class="card">'
'<div class="meta">%d contacts &middot; '
'<a href="%s/contacts/new">+ New</a> &middot; '
'<form method="post" action="%s/contacts/import_vcf" '
'style="display:inline"><button type="submit" class="btn">'
'Import from VCF</button></form> &middot; '
'<button type="button" class="danger" '
'onclick="if(confirm(\'Delete ALL %d contacts? This cannot be undone.\'))'
' document.getElementById(\'delAllForm\').submit()">Delete all</button>'
'</form></div>' % (total, BASE, BASE, total),
'<form id="delAllForm" method="post" action="%s/contacts/delete_all" '
'style="display:none"></form>' % BASE,
'<table style="width:100%%;border-collapse:collapse">']
for r in rows:
num = escape(r.get("number_e164") or "")
who = escape(r["name"] or "Unnamed")
em = escape(r.get("email") or "")
out.append("<tr style='border-bottom:1px solid #e3e9f5'>"
"<td style='padding:10px 6px'><b>%s</b><br>"
"<span style='color:#6b7686;font-size:13px'>%s &middot; %s</span></td>"
"<td style='padding:10px 6px;text-align:right;white-space:nowrap'>"
"<form method='post' action='%s/contacts/delete/%d' "
"onsubmit='return confirm(\"Delete %s?\")' "
"style='display:inline'>"
"<button type='submit' class='danger'>Delete</button></form> "
"<a class='btn' href='%s/contacts/edit/%d'>Edit</a>"
"</td></tr>" % (who, num, em, BASE, r["id"], who, BASE, r["id"]))
out.append("</table></div>")
return page("Contacts", "".join(out), mb)
@app.get("/contacts/new", response_class=HTMLResponse)
def contact_new(vm_session: str = Cookie(default=None)):
require(vm_session)
return page("New contact", _contact_form("new"))
@app.get("/contacts/edit/{cid}", response_class=HTMLResponse)
def contact_edit(cid: int, vm_session: str = Cookie(default=None)):
mb = require(vm_session)
con = vm_store.connect()
c = con.execute("SELECT * FROM contacts WHERE id=?", (cid,)).fetchone()
con.close()
if not c:
return RedirectResponse(BASE + "/contacts?msg=Contact+not+found", status_code=303)
return page("Edit contact", _contact_form("edit", c))
@app.post("/contacts/save")
async def contact_save(request: Request, vm_session: str = Cookie(default=None)):
mb = require(vm_session)
form = await request.form()
cid = (form.get("id") or "").strip()
num = (form.get("number") or "").strip()
name = (form.get("name") or "").strip()
email = (form.get("email") or "").strip() or None
if not name:
return RedirectResponse(BASE + "/contacts?msg=Name+required", status_code=303)
normalized = vm_contacts.normalize_uk(vm_contacts.digits_of(num)) if num else ""
con = vm_store.connect()
try:
if cid:
row = con.execute("SELECT id FROM contacts WHERE id=?", (cid,)).fetchone()
if row and row.get("id"):
con.execute(
"UPDATE contacts SET name=?, email=?, number_e164=?, "
"updated_at=NOW() WHERE id=?",
(name, email, normalized, cid),
)
con.execute(
"UPDATE messages SET contact_name=?, contact_email=? "
"WHERE callerid LIKE ? AND (contact_name IS NULL OR contact_name = '')",
(name, email, "%%%s%%" % normalized),
)
else:
return RedirectResponse(BASE + "/contacts?msg=Not+found", status_code=303)
else:
con.execute(
"INSERT INTO contacts (number_e164, name, email) VALUES (?, ?, ?)",
(normalized, name, email),
)
con.commit()
finally:
con.close()
return RedirectResponse(BASE + "/contacts?msg=Saved", status_code=303)
@app.post("/contacts/delete/{cid}")
def contact_delete(cid: int, vm_session: str = Cookie(default=None)):
mb = require(vm_session)
con = vm_store.connect()
con.execute("DELETE FROM contacts WHERE id=?", (cid,))
con.commit()
con.close()
return RedirectResponse(BASE + "/contacts?msg=Deleted", status_code=303)
@app.post("/contacts/delete_all")
def contact_delete_all(vm_session: str = Cookie(default=None)):
mb = require(vm_session)
con = vm_store.connect()
con.execute("DELETE FROM contacts")
con.commit()
con.close()
return RedirectResponse(BASE + "/contacts?msg=All+contacts+deleted", status_code=303)
@app.post("/contacts/import_vcf")
def contact_import_vcf(vm_session: str = Cookie(default=None)):
mb = require(vm_session)
path = "/var/lib/vm-transcribe/contacts.vcf"
if not os.path.exists(path):
return RedirectResponse(BASE + "/contacts?msg=VCF+not+found", status_code=303)
try:
with open(path, encoding="utf-8", errors="replace") as fh:
entries = vm_contacts._parse_vcf(fh.read())
except Exception as e:
return RedirectResponse(BASE + "/contacts?msg=VCF+error+" + escape(str(e)), status_code=303)
con = vm_store.connect()
existing = {
r["number_e164"] for r in con.execute("SELECT number_e164 FROM contacts").fetchall()
}
added = skipped = 0
for name, nums, emails in entries:
if vm_contacts._is_junk_name(name):
skipped += 1
continue
email = emails[0] if emails else None
for raw_num in nums:
e164 = vm_contacts.normalize_uk(vm_contacts.digits_of(raw_num))
if not e164:
continue
if e164 in existing:
skipped += 1
continue
try:
con.execute(
"INSERT INTO contacts (number_e164, name, email) VALUES (?, ?, ?)",
(e164, name, email),
)
existing.add(e164)
added += 1
except Exception:
skipped += 1
con.commit()
con.close()
return RedirectResponse(
BASE + "/contacts?msg=Imported+%d+new+contacts" % added, status_code=303)
@app.post("/add_contact") @app.post("/add_contact")
async def add_contact(request: Request, vm_session: str = Cookie(default=None)): async def add_contact(request: Request, vm_session: str = Cookie(default=None)):
"""Add/update a contact via the web portal (authenticated users only).""" """Add/update a contact via the web portal (authenticated users only)."""