Allow assigning a personal number to a group, not just a single extension

The dashboard's Personal Numbers card now accepts a group (stored as
"@GroupName", unambiguous against a same-named numeric extension) as well
as a plain extension. A group-owned DID rings every CURRENT member whose
own tier/approved-numbers authorize the caller, computed fresh on every
call by a generated pstn-personal-group-ring.sh (invoked via the
dialplan's SHELL() function) rather than unrolled at install time, since
group membership can change any time via the dashboard with no reinstall
- unlike the shared ring-group, which is fixed at install/update time.

Applies the identical per-member permission check the shared ring-group
already bakes into the dialplan, just computed in a plain shell loop
against the same two config files - group ownership doesn't bypass the
tier/approved-numbers model. Tested standalone against mock config data
(full/restricted/internal mix, matching/non-matching caller, empty and
nonexistent groups) - all four cases behaved correctly. The dialplan's own
SHELL() invocation is still unverified against a real call.

Group ownership never touches pstn-permissions.conf's personal_did
(outbound Caller-ID override) field, since there's no single extension to
hang that on for a group.
This commit is contained in:
Claude
2026-07-24 03:00:13 +00:00
parent f5e93ec35b
commit dd3ea131ac
2 changed files with 143 additions and 21 deletions
+47 -15
View File
@@ -1395,24 +1395,42 @@ def write_personal_did(did, owner):
it, and giving an extension a new personal DID drops whichever one it
had before — this always leaves a clean 1:1 mapping in both files,
rather than requiring the caller to clean up the old assignment
itself."""
itself.
owner may also be a group reference, written as "@GroupName" (the '@'
makes it unambiguous against a same-named numeric extension - group
names are free text and could otherwise collide, e.g. a group literally
named "201"). A group-owned DID rings every CURRENT member whose own
tier/approved-numbers authorize the caller, computed fresh on every
call (see pstn-personal-group-ring.sh) rather than baked in at
assignment time - membership changes take effect immediately, unlike
the Groups card's other bulk actions. Group ownership has no single
extension to hang an outbound Caller-ID override on, so it never
touches pstn-permissions.conf the way a single-extension owner does."""
if not ASTERISK_CONFIG_DIR:
return False, "No Asterisk install detected on this box"
did = str(did).strip()
owner = str(owner).strip()
if not PERSONAL_DID_RE.match(did):
return False, "DID must be a 10-digit US number"
if not EXTEN_RE.match(owner):
is_group = owner.startswith("@")
group_name = owner[1:] if is_group else ""
if is_group:
if not group_name or not _read_groups_cp().has_section(group_name):
return False, "Group '%s' not found" % group_name
elif not EXTEN_RE.match(owner):
return False, "Invalid owner extension"
dids_cp = _read_personal_dids_cp()
perms_cp = _read_permissions_cp()
for section in perms_cp.sections():
if section != owner and perms_cp.get(section, "personal_did", fallback="") == did:
perms_cp.remove_option(section, "personal_did")
if not perms_cp.options(section):
perms_cp.remove_section(section)
if not is_group:
for section in perms_cp.sections():
if section != owner and perms_cp.get(section, "personal_did", fallback="") == did:
perms_cp.remove_option(section, "personal_did")
if not perms_cp.options(section):
perms_cp.remove_section(section)
for section in list(dids_cp.sections()):
if section != did and dids_cp.get(section, "owner", fallback="") == owner:
@@ -1422,13 +1440,17 @@ def write_personal_did(did, owner):
dids_cp.add_section(did)
dids_cp.set(did, "owner", owner)
ok, err = _write_ini_cp(_personal_dids_path(), PERSONAL_DIDS_HEADER, dids_cp)
if not ok:
return False, err
if is_group:
return True, "Assigned %s to group %s" % (did, group_name)
if not perms_cp.has_section(owner):
perms_cp.add_section(owner)
perms_cp.set(owner, "personal_did", did)
ok, err = _write_ini_cp(_personal_dids_path(), PERSONAL_DIDS_HEADER, dids_cp)
if not ok:
return False, err
ok, err = _write_ini_cp(_permissions_path(), PERMISSIONS_HEADER, perms_cp)
if not ok:
return False, err
@@ -1941,9 +1963,14 @@ async function loadPstnPermissions() {
const data = await res.json();
const exts = data.extensions || [];
const grpRes = await fetch("/api/pstn-groups");
const grpData = await grpRes.json();
const groups = grpData.groups || [];
const ownerSel = document.getElementById("pd-owner");
ownerSel.innerHTML = exts.map(e => `<option value="${esc(e.ext)}">${esc(e.ext)} — ${esc(e.name)}</option>`).join("")
|| '<option value="">No extensions found</option>';
const extOptions = exts.map(e => `<option value="${esc(e.ext)}">${esc(e.ext)} — ${esc(e.name)}</option>`).join("");
const groupOptions = groups.map(g => `<option value="@${esc(g.name)}">Group: ${esc(g.name)}</option>`).join("");
ownerSel.innerHTML = (extOptions + groupOptions) || '<option value="">No extensions found</option>';
const tbody = document.querySelector("#pstn-table tbody");
if (!exts.length) {
@@ -1991,11 +2018,16 @@ async function loadPersonalDids() {
const data = await res.json();
const dids = data.dids || [];
const tbody = document.querySelector("#pd-table tbody");
tbody.innerHTML = dids.map(d => `<tr>
tbody.innerHTML = dids.map(d => {
const ownerDisplay = d.owner.startsWith("@")
? "Group: " + esc(d.owner.slice(1))
: esc(d.owner) + (d.owner_name ? " — " + esc(d.owner_name) : "");
return `<tr>
<td>${esc(d.did)}</td>
<td>${esc(d.owner)}${d.owner_name ? " — " + esc(d.owner_name) : ""}</td>
<td>${ownerDisplay}</td>
<td><button class="action" onclick="removePersonalDid('${esc(d.did)}')">Remove</button></td>
</tr>`).join("") || "<tr><td colspan=3 class=muted>No personal numbers assigned — every extension shares the main trunk DID.</td></tr>";
</tr>`;
}).join("") || "<tr><td colspan=3 class=muted>No personal numbers assigned — every extension shares the main trunk DID.</td></tr>";
}
document.getElementById("pd-save").addEventListener("click", async () => {