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:
@ -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):
|
||||
|
||||
209
src/vm_web.py
209
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 = ('<a href="%s/">Messages</a><a href="%s/settings">Settings</a>'
|
||||
'<a href="%s/logout">Log out</a>' % (BASE, BASE, BASE))
|
||||
nav = ('<a href="%s/">Messages</a><a href="%s/contacts">Contacts</a>'
|
||||
'<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">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%s</title><style>%s</style></head><body>
|
||||
@ -493,6 +494,210 @@ def healthz():
|
||||
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 · '
|
||||
'<a href="%s/contacts/new">+ New</a> · '
|
||||
'<form method="post" action="%s/contacts/import_vcf" '
|
||||
'style="display:inline"><button type="submit" class="btn">'
|
||||
'Import from VCF</button></form> · '
|
||||
'<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 · %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")
|
||||
async def add_contact(request: Request, vm_session: str = Cookie(default=None)):
|
||||
"""Add/update a contact via the web portal (authenticated users only)."""
|
||||
|
||||
Reference in New Issue
Block a user