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.
|
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):
|
||||||
|
|||||||
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):
|
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 · '
|
||||||
|
'<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")
|
@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)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user