From 456d5f96f233b0dd33f831888b866debbe4ef384 Mon Sep 17 00:00:00 2001 From: jp Date: Thu, 13 Aug 2026 13:49:03 +0100 Subject: [PATCH] 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 --- src/vm_contacts.py | 8 +- src/vm_web.py | 209 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 210 insertions(+), 7 deletions(-) diff --git a/src/vm_contacts.py b/src/vm_contacts.py index 0ddafeb..1b04c43 100644 --- a/src/vm_contacts.py +++ b/src/vm_contacts.py @@ -2,11 +2,9 @@ """ 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). - 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 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 -_BACKENDS = {"mysql": lookup_mysql, "file": lookup_file, "google": lookup_google, "carddav": lookup_carddav} +_BACKENDS = {"mysql": lookup_mysql} def resolve(caller_id, log=print): diff --git a/src/vm_web.py b/src/vm_web.py index 89ea30f..d6b092f 100644 --- a/src/vm_web.py +++ b/src/vm_web.py @@ -178,8 +178,9 @@ td{padding:6px 0;vertical-align:top} def page(title, body, mailbox=None, name=None): nav = "" if mailbox: - nav = ('MessagesSettings' - 'Log out' % (BASE, BASE, BASE)) + nav = ('MessagesContacts' + 'Settings' + 'Log out' % (BASE, BASE, BASE, BASE)) return HTMLResponse(""" %s @@ -493,6 +494,210 @@ def healthz(): return {"ok": True, "messages": n} +# ----------------------------------------------------------------- contacts mgmt +def _contact_form(action, c=None, msg=""): + banner = '
%s
' % 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 = ('' % c["id"]) + else: + title = "New contact" + submit = "Create" + extra = "" + return """%s
+
+%s + + + + + + +
+ Cancel + +
+
""" % (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 = '
%s
' % escape(msg) if msg else "" + if not rows: + return page("Contacts", banner + '
' + 'No contacts yet. Use the button below to import from your ' + 'address book, or add them one by one.
' + '
' + '+ New contact
', mb) + + out = [banner, '
' + '
%d contacts · ' + '+ New · ' + '
· ' + '' + '
' % (total, BASE, BASE, total), + '
' % BASE, + ''] + + 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("" + "" + "" % (who, num, em, BASE, r["id"], who, BASE, r["id"])) + + out.append("
%s
" + "%s · %s
" + "
" + "
" + "Edit" + "
") + 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") async def add_contact(request: Request, vm_session: str = Cookie(default=None)): """Add/update a contact via the web portal (authenticated users only)."""