JS delete + add-contact modal + MySQL contacts table
- vm_web.py: deleteVoicemail() XHR removes .card node on success - vm_web.py: openAddContact() modal with name/number/email fields - vm_web.py: POST /add_contact adds/updates MySQL contacts + backfills messages - show '+ Add to contacts' button when caller not in contacts - contacts.conf: backends=mysql,file - vm_import_contacts.py: VCF -> MySQL contacts import (527 rows) - Apache CSP: script-src 'self' 'unsafe-inline' for inline JS handlers - contacts table created in asterisk MySQL DB (number_e164 UNIQUE)
This commit is contained in:
117
src/vm_web.py
117
src/vm_web.py
@ -26,6 +26,7 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
|||||||
from html import escape
|
from html import escape
|
||||||
|
|
||||||
import vm_auth
|
import vm_auth
|
||||||
|
import vm_contacts
|
||||||
import vm_store
|
import vm_store
|
||||||
|
|
||||||
SESSION_HOURS = int(os.environ.get("VM_SESSION_HOURS", "12"))
|
SESSION_HOURS = int(os.environ.get("VM_SESSION_HOURS", "12"))
|
||||||
@ -158,6 +159,19 @@ border-radius:8px;margin-bottom:12px;font-size:14px}
|
|||||||
border-radius:8px;margin-bottom:12px;font-size:14px}
|
border-radius:8px;margin-bottom:12px;font-size:14px}
|
||||||
table{width:100%;border-collapse:collapse}
|
table{width:100%;border-collapse:collapse}
|
||||||
td{padding:6px 0;vertical-align:top}
|
td{padding:6px 0;vertical-align:top}
|
||||||
|
.modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);
|
||||||
|
align-items:center;justify-content:center;z-index:1000}
|
||||||
|
.modal-bg.open{display:flex}
|
||||||
|
.modal{background:#fff;border-radius:12px;padding:22px 24px;max-width:420px;
|
||||||
|
width:92%;box-shadow:0 6px 24px rgba(0,0,0,.25)}
|
||||||
|
.modal h3{margin:0 0 12px;font-size:16px}
|
||||||
|
.modal label{font-weight:600;font-size:14px;margin:10px 0 4px;display:block}
|
||||||
|
.modal input{width:100%;padding:10px 12px;border:1px solid #d4dcea;
|
||||||
|
border-radius:8px;font:15px inherit;background:#fff}
|
||||||
|
.modal .row{display:flex;gap:8px;margin-top:14px;justify-content:flex-end}
|
||||||
|
.add-btn{font-size:12px;padding:5px 10px;border-radius:6px;background:#eef3ff;
|
||||||
|
color:#2f6fed;border:1px solid #c5d4ff;cursor:pointer;font-weight:600}
|
||||||
|
.add-btn:hover{background:#dce6ff}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@ -170,11 +184,60 @@ def page(title, body, mailbox=None, name=None):
|
|||||||
<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>
|
||||||
<header><h1>✉ Voicemail</h1>%s<span class="sp"></span>%s</header>
|
<header><h1>✉ Voicemail</h1>%s<span class="sp"></span>%s</header>
|
||||||
<div class="wrap">%s</div></body></html>""" % (
|
<div class="wrap">%s</div>
|
||||||
|
|
||||||
|
<div id="addContactBg" class="modal-bg">
|
||||||
|
<form class="modal" method="post" action="%s/add_contact">
|
||||||
|
<h3>Add contact</h3>
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" name="name" required autocomplete="name">
|
||||||
|
<label>Phone number</label>
|
||||||
|
<input type="text" name="number" autocomplete="tel">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" name="email" autocomplete="email">
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" class="btn" onclick="closeAddContact()">Cancel</button>
|
||||||
|
<button type="submit" class="primary">Save contact</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openAddContact(number, name){
|
||||||
|
var bg = document.getElementById('addContactBg');
|
||||||
|
var inputs = bg.querySelectorAll('input');
|
||||||
|
inputs[0].value = (name || '');
|
||||||
|
inputs[1].value = (number || '');
|
||||||
|
bg.classList.add('open');
|
||||||
|
inputs[(name ? 1 : 0)].focus();
|
||||||
|
}
|
||||||
|
function closeAddContact(){
|
||||||
|
document.getElementById('addContactBg').classList.remove('open');
|
||||||
|
}
|
||||||
|
document.getElementById('addContactBg').addEventListener('click', function(e){
|
||||||
|
if (e.target === this) closeAddContact();
|
||||||
|
});
|
||||||
|
|
||||||
|
function deleteVoicemail(id, el){
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '%s/delete/' + id, true);
|
||||||
|
xhr.onload = function(){
|
||||||
|
if (xhr.status === 200 || xhr.status === 303){
|
||||||
|
var card = el.closest('.card');
|
||||||
|
if (card) card.remove();
|
||||||
|
} else {
|
||||||
|
alert('Delete failed: ' + (xhr.responseText || xhr.status));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = function(){ alert('Network error during delete'); };
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body></html>""" % (
|
||||||
escape(title), CSS,
|
escape(title), CSS,
|
||||||
('<span style="font-size:14px;color:#dce6ff">%s · mailbox %s</span>'
|
('<span style="font-size:14px;color:#dce6ff">%s · mailbox %s</span>'
|
||||||
% (escape(name or ""), escape(mailbox))) if mailbox else "",
|
% (escape(name or ""), escape(mailbox))) if mailbox else "",
|
||||||
nav, body))
|
nav, body, BASE, BASE))
|
||||||
|
|
||||||
|
|
||||||
def fmt_time(ts):
|
def fmt_time(ts):
|
||||||
@ -291,9 +354,8 @@ def index(vm_session: str = Cookie(default=None), msg: str = ""):
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<form method="post" action="%s/read/%d"><button>%s</button></form>
|
<form method="post" action="%s/read/%d"><button>%s</button></form>
|
||||||
<a class="btn" href="%s/audio/%d?dl=1">Download</a>
|
<a class="btn" href="%s/audio/%d?dl=1">Download</a>
|
||||||
<form method="post" action="%s/delete/%d"
|
<button type="button" class="danger" onclick="deleteVoicemail(%d, this)">Delete</button>
|
||||||
onsubmit="return confirm('Delete this voicemail permanently?')">
|
%s
|
||||||
<button class="danger">Delete</button></form>
|
|
||||||
</div></div>""" % (
|
</div></div>""" % (
|
||||||
"" if r["is_read"] else " unread",
|
"" if r["is_read"] else " unread",
|
||||||
escape(r["contact_name"] or r["callerid"] or "Unknown caller"),
|
escape(r["contact_name"] or r["callerid"] or "Unknown caller"),
|
||||||
@ -303,7 +365,11 @@ def index(vm_session: str = Cookie(default=None), msg: str = ""):
|
|||||||
('<div style="margin-top:8px">%s</div>' % tags) if tags else "",
|
('<div style="margin-top:8px">%s</div>' % tags) if tags else "",
|
||||||
callback, audio, transcript,
|
callback, audio, transcript,
|
||||||
BASE, r["id"], "Mark unread" if r["is_read"] else "Mark read",
|
BASE, r["id"], "Mark unread" if r["is_read"] else "Mark read",
|
||||||
BASE, r["id"], BASE, r["id"]))
|
BASE, r["id"], r["id"],
|
||||||
|
('<button type="button" class="add-btn" onclick="openAddContact(%r, %r)">+ Add to contacts</button>'
|
||||||
|
% (vm_contacts.extract_number(r["callerid"]) or "",
|
||||||
|
(r["contact_name"] or "").strip() or ""))
|
||||||
|
if not r["contact_name"] and vm_contacts.extract_number(r["callerid"]) else ""))
|
||||||
|
|
||||||
return page("Messages", "".join(out), mb, name)
|
return page("Messages", "".join(out), mb, name)
|
||||||
|
|
||||||
@ -425,3 +491,42 @@ def healthz():
|
|||||||
n = con.execute("SELECT COUNT(*) c FROM messages").fetchone()["c"]
|
n = con.execute("SELECT COUNT(*) c FROM messages").fetchone()["c"]
|
||||||
con.close()
|
con.close()
|
||||||
return {"ok": True, "messages": n}
|
return {"ok": True, "messages": n}
|
||||||
|
|
||||||
|
|
||||||
|
@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)."""
|
||||||
|
mb = require(vm_session)
|
||||||
|
form = await request.form()
|
||||||
|
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 + "/?msg=Name+is+required", status_code=303)
|
||||||
|
|
||||||
|
normalized = vm_contacts.normalize_uk(vm_contacts.digits_of(num)) if num else None
|
||||||
|
|
||||||
|
con = vm_store.connect()
|
||||||
|
if normalized:
|
||||||
|
row = con.execute(
|
||||||
|
"SELECT id FROM contacts WHERE number_e164 = ?", (normalized,)
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
con.execute(
|
||||||
|
"UPDATE contacts SET name=?, email=?, updated_at=NOW() WHERE id=?",
|
||||||
|
(name, email, row["id"]),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO contacts (number_e164, name, email) VALUES (?, ?, ?)",
|
||||||
|
(normalized, name, email),
|
||||||
|
)
|
||||||
|
# If a voicemail row already has this number, backfill the name.
|
||||||
|
con.execute(
|
||||||
|
"UPDATE messages SET contact_name=?, contact_email=? "
|
||||||
|
"WHERE callerid LIKE ? AND (contact_name IS NULL OR contact_name = '')",
|
||||||
|
(name, email, "%%%s%%" % normalized),
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
return RedirectResponse(BASE + "/?msg=Contact+saved", status_code=303)
|
||||||
|
|||||||
Reference in New Issue
Block a user