Skip to content

Commit 50784d2

Browse files
authored
Merge pull request #903 from gobii-ai/sms_tweak
SMS Tweaks
2 parents fb47588 + 0e6ff47 commit 50784d2

8 files changed

Lines changed: 1128 additions & 46 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,3 +367,4 @@ uv run celery -A config worker -l info --pool=threads --concurrency=4
367367
- Source code is licensed under [MIT](LICENSE).
368368
- Gobii name and logo are trademarks of Gobii, Inc. See [NOTICE](NOTICE).
369369
- Proprietary mode and non-MIT components require a commercial agreement with Gobii, Inc.
370+

api/admin.py

Lines changed: 215 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
)
4545
from api.agent.core.schedule_parser import ScheduleParser
4646
from .admin_forms import (
47+
FindReleaseCandidatesForm,
48+
ReleaseSmsNumbersForm,
4749
TestSmsForm,
4850
GrantPlanCreditsForm,
4951
GrantCreditsByUserIdsForm,
@@ -97,7 +99,12 @@
9799
from .agent.files.filespace_service import enqueue_import_after_commit
98100
from .tasks import sync_ip_block, backfill_missing_proxy_records, proxy_health_check_single, garbage_collect_timed_out_tasks
99101
from .tasks.sms_tasks import sync_twilio_numbers, send_test_sms
100-
from .services.sms_number_inventory import retire_sms_number
102+
from .services.sms_number_inventory import (
103+
SmsNumberReleaseCandidate,
104+
find_sms_number_release_candidates,
105+
release_sms_number,
106+
retire_sms_number,
107+
)
101108
from .services.global_skill_json import (
102109
import_global_skill_from_payload,
103110
parse_global_skill_json_bytes,
@@ -116,6 +123,9 @@
116123

117124
import zstandard as zstd
118125

126+
SMS_RELEASE_CANDIDATES_SESSION_KEY = "admin_sms_release_candidates_phone_numbers"
127+
SMS_RELEASE_CANDIDATES_PREFILL_SOURCE = "release_candidates"
128+
119129
# Replace dj-stripe's default registration
120130
# 2.10.1 has removed some fields we still want to see, but their own admin still references them
121131
admin.site.unregister(Customer)
@@ -534,12 +544,7 @@ def get_urls(self):
534544
return custom + urls
535545

536546
def grant_by_plan_view(self, request):
537-
from django.template.response import TemplateResponse
538-
from django.contrib import messages
539-
from django.db import transaction
540-
from django.utils import timezone
541547
from django.apps import apps
542-
from constants.plans import PlanNamesChoices
543548

544549
if not request.user.has_perm("api.add_taskcredit"):
545550
messages.error(request, "You do not have permission to grant task credits.")
@@ -662,10 +667,6 @@ def grant_by_plan_view(self, request):
662667
return TemplateResponse(request, "admin/grant_plan_credits.html", context)
663668

664669
def grant_by_user_ids_view(self, request):
665-
from django.template.response import TemplateResponse
666-
from django.contrib import messages
667-
from django.db import transaction
668-
from django.utils import timezone
669670
from django.apps import apps
670671

671672
if not request.user.has_perm("api.add_taskcredit"):
@@ -692,12 +693,10 @@ def grant_by_user_ids_view(self, request):
692693
export_csv = form.cleaned_data['export_csv']
693694

694695
# Parse IDs by commas or newlines
695-
import re
696696
ids = [s for s in re.split(r"[\s,]+", raw.strip()) if s]
697697

698698
TaskCredit = apps.get_model("api", "TaskCredit")
699699
User = get_user_model()
700-
from constants.plans import PlanNamesChoices
701700

702701
# ids are integers; invalid tokens are ignored by the filter
703702
users = list(User.objects.filter(id__in=ids, is_active=True))
@@ -4314,7 +4313,6 @@ def test_smtp_view(self, request, object_id):
43144313
pass
43154314

43164315
# Success
4317-
from django.utils import timezone
43184316
acct.connection_last_ok_at = timezone.now()
43194317
acct.connection_error = ""
43204318
acct.save(update_fields=['connection_last_ok_at', 'connection_error'])
@@ -4374,7 +4372,6 @@ def test_imap_view(self, request, object_id):
43744372
# Attempt IMAP connection
43754373
try:
43764374
import imaplib
4377-
from django.utils import timezone
43784375
if acct.imap_security == AgentEmailAccount.ImapSecurity.SSL:
43794376
client = imaplib.IMAP4_SSL(acct.imap_host, int(acct.imap_port or 993), timeout=30)
43804377
else:
@@ -5134,6 +5131,12 @@ def in_use(self, obj):
51345131
"""Return True if any agent SMS endpoint uses this number."""
51355132
return obj.is_in_use
51365133

5134+
@staticmethod
5135+
def _preview_items(items, limit=5, separator=", "):
5136+
preview = separator.join(items[:limit])
5137+
suffix = "..." if len(items) > limit else ""
5138+
return f"{preview}{suffix}"
5139+
51375140
fieldsets = (
51385141
('Basic Information', {
51395142
'fields': ('id', 'phone_number', 'is_active', 'released_at')
@@ -5150,25 +5153,6 @@ def in_use(self, obj):
51505153
}),
51515154
)
51525155

5153-
def changelist_view(self, request, extra_context=None):
5154-
"""Inject counts of numbers in use for the change list template."""
5155-
if extra_context is None:
5156-
extra_context = {}
5157-
5158-
in_use_numbers_qs = PersistentAgentCommsEndpoint.objects.filter(
5159-
channel=CommsChannel.SMS,
5160-
).values("address")
5161-
5162-
extra_context["in_use_count"] = SmsNumber.objects.filter(
5163-
phone_number__in=in_use_numbers_qs,
5164-
).count()
5165-
5166-
extra_context["total_count"] = SmsNumber.objects.count()
5167-
5168-
return super().changelist_view(request, extra_context=extra_context)
5169-
5170-
5171-
51725156
def get_urls(self):
51735157
urls = super().get_urls()
51745158
extra = [
@@ -5177,6 +5161,16 @@ def get_urls(self):
51775161
self.admin_site.admin_view(self.test_sms_view),
51785162
name="smsnumber_test_sms",
51795163
),
5164+
path(
5165+
"release/",
5166+
self.admin_site.admin_view(self.release_view),
5167+
name="smsnumber_release",
5168+
),
5169+
path(
5170+
"release-candidates/",
5171+
self.admin_site.admin_view(self.release_candidates_view),
5172+
name="smsnumber_release_candidates",
5173+
),
51805174
path(
51815175
"sync/", # /admin/api/smsnumber/sync/
51825176
self.admin_site.admin_view(self.sync_view),
@@ -5224,6 +5218,194 @@ def test_sms_view(self, request, object_id):
52245218

52255219
return TemplateResponse(request, "admin/test_sms_form.html", context)
52265220

5221+
def changelist_view(self, request, extra_context=None):
5222+
"""Inject counts of numbers in use for the change list template."""
5223+
if extra_context is None:
5224+
extra_context = {}
5225+
5226+
inventory_numbers_qs = SmsNumber.objects.filter(released_at__isnull=True)
5227+
in_use_numbers_qs = PersistentAgentCommsEndpoint.objects.filter(
5228+
channel=CommsChannel.SMS,
5229+
).values("address")
5230+
5231+
extra_context["in_use_count"] = inventory_numbers_qs.filter(
5232+
phone_number__in=in_use_numbers_qs,
5233+
).count()
5234+
extra_context["inventory_count"] = inventory_numbers_qs.count()
5235+
extra_context["released_count"] = SmsNumber.objects.filter(released_at__isnull=False).count()
5236+
5237+
return super().changelist_view(request, extra_context=extra_context)
5238+
5239+
def release_view(self, request):
5240+
changelist_url = reverse("admin:api_smsnumber_changelist")
5241+
if not request.user.has_perm("api.change_smsnumber"):
5242+
messages.error(request, "Permission denied.")
5243+
return HttpResponseRedirect(changelist_url)
5244+
5245+
initial = {}
5246+
if request.method != "POST":
5247+
prefilled_numbers = (request.GET.get("phone_numbers") or "").strip()
5248+
if not prefilled_numbers and request.GET.get("prefill") == SMS_RELEASE_CANDIDATES_PREFILL_SOURCE:
5249+
prefilled_numbers = (
5250+
request.session.get(SMS_RELEASE_CANDIDATES_SESSION_KEY, "")
5251+
).strip()
5252+
if prefilled_numbers:
5253+
initial["phone_numbers"] = prefilled_numbers
5254+
5255+
form = ReleaseSmsNumbersForm(request.POST or None, initial=initial)
5256+
if request.method == "POST" and form.is_valid():
5257+
requested_numbers = form.cleaned_data["phone_numbers"]
5258+
sms_numbers_by_phone = {
5259+
sms_number.phone_number: sms_number
5260+
for sms_number in SmsNumber.objects.filter(phone_number__in=requested_numbers)
5261+
}
5262+
5263+
released_results = []
5264+
partial_results = []
5265+
missing_numbers = []
5266+
validation_errors = []
5267+
detached_endpoint_total = 0
5268+
5269+
for phone_number in requested_numbers:
5270+
sms_number = sms_numbers_by_phone.get(phone_number)
5271+
if sms_number is None:
5272+
missing_numbers.append(phone_number)
5273+
continue
5274+
5275+
try:
5276+
result = release_sms_number(sms_number)
5277+
except ValidationError as exc:
5278+
validation_errors.append(f"{phone_number}: {'; '.join(exc.messages)}")
5279+
continue
5280+
5281+
detached_endpoint_total += result.detached_endpoint_count
5282+
if result.succeeded:
5283+
released_results.append(result)
5284+
else:
5285+
partial_results.append(result)
5286+
5287+
if released_results:
5288+
released_numbers = [result.phone_number for result in released_results]
5289+
messages.success(
5290+
request,
5291+
f"Released {len(released_results)} SMS number(s) in Twilio: "
5292+
f"{self._preview_items(released_numbers)}",
5293+
)
5294+
5295+
if detached_endpoint_total:
5296+
messages.info(
5297+
request,
5298+
f"Detached {detached_endpoint_total} SMS endpoint(s) from agents before release.",
5299+
)
5300+
5301+
if partial_results:
5302+
partial_preview = self._preview_items(
5303+
[f"{result.phone_number} ({result.error})" for result in partial_results],
5304+
limit=3,
5305+
separator="; ",
5306+
)
5307+
messages.error(
5308+
request,
5309+
"Retired locally but failed to release in Twilio for "
5310+
f"{len(partial_results)} SMS number(s): {partial_preview}",
5311+
)
5312+
5313+
if missing_numbers:
5314+
messages.error(
5315+
request,
5316+
f"Skipped {len(missing_numbers)} number(s) not found in SMS inventory: "
5317+
f"{self._preview_items(missing_numbers)}",
5318+
)
5319+
5320+
if validation_errors:
5321+
messages.error(
5322+
request,
5323+
f"Could not release {len(validation_errors)} number(s): "
5324+
f"{self._preview_items(validation_errors, limit=3, separator=' | ')}",
5325+
)
5326+
5327+
if not any([released_results, partial_results, missing_numbers, validation_errors]):
5328+
messages.warning(request, "No SMS numbers were released.")
5329+
5330+
return HttpResponseRedirect(changelist_url)
5331+
5332+
context = dict(
5333+
self.admin_site.each_context(request),
5334+
opts=self.model._meta,
5335+
form=form,
5336+
title="Release SMS Numbers",
5337+
changelist_url=changelist_url,
5338+
)
5339+
5340+
return TemplateResponse(request, "admin/smsnumber_release_form.html", context)
5341+
5342+
def release_candidates_view(self, request):
5343+
changelist_url = reverse("admin:api_smsnumber_changelist")
5344+
if not request.user.has_perm("api.view_smsnumber"):
5345+
messages.error(request, "Permission denied.")
5346+
return HttpResponseRedirect(changelist_url)
5347+
5348+
form = FindReleaseCandidatesForm(request.GET or None)
5349+
has_results = bool(request.GET)
5350+
candidates = []
5351+
if has_results and form.is_valid():
5352+
candidates = find_sms_number_release_candidates(
5353+
unused_days=form.cleaned_data["unused_days"],
5354+
include_detached_unused=form.cleaned_data["include_detached_unused"],
5355+
include_free_dormant_unused=form.cleaned_data["include_free_dormant_unused"],
5356+
)
5357+
5358+
grouped_candidates = {
5359+
SmsNumberReleaseCandidate.DETACHED_UNUSED: [],
5360+
SmsNumberReleaseCandidate.FREE_DORMANT_UNUSED: [],
5361+
}
5362+
for candidate in candidates:
5363+
grouped_candidates.setdefault(candidate.tier, []).append(
5364+
{
5365+
"phone_number": candidate.phone_number,
5366+
"friendly_name": candidate.friendly_name,
5367+
"last_activity_at": candidate.last_activity_at,
5368+
"owner_agent_name": candidate.owner_agent_name,
5369+
"owner_email": candidate.owner_email,
5370+
"owner_plan": candidate.owner_plan,
5371+
"sms_number_change_url": reverse("admin:api_smsnumber_change", args=[candidate.sms_number_id]),
5372+
"endpoint_change_url": (
5373+
reverse("admin:api_persistentagentcommsendpoint_change", args=[candidate.endpoint_id])
5374+
if candidate.endpoint_id
5375+
else ""
5376+
),
5377+
"owner_agent_change_url": (
5378+
reverse("admin:api_persistentagent_change", args=[candidate.owner_agent_id])
5379+
if candidate.owner_agent_id
5380+
else ""
5381+
),
5382+
}
5383+
)
5384+
5385+
suggested_numbers = "\n".join(candidate.phone_number for candidate in candidates)
5386+
release_review_url = ""
5387+
if suggested_numbers:
5388+
request.session[SMS_RELEASE_CANDIDATES_SESSION_KEY] = suggested_numbers
5389+
release_review_url = (
5390+
f"{reverse('admin:smsnumber_release')}?prefill={SMS_RELEASE_CANDIDATES_PREFILL_SOURCE}"
5391+
)
5392+
else:
5393+
request.session.pop(SMS_RELEASE_CANDIDATES_SESSION_KEY, None)
5394+
5395+
context = dict(
5396+
self.admin_site.each_context(request),
5397+
opts=self.model._meta,
5398+
form=form,
5399+
title="Find SMS Release Candidates",
5400+
changelist_url=changelist_url,
5401+
has_results=has_results,
5402+
grouped_candidates=grouped_candidates,
5403+
suggested_numbers=suggested_numbers,
5404+
release_review_url=release_review_url,
5405+
)
5406+
5407+
return TemplateResponse(request, "admin/smsnumber_release_candidates.html", context)
5408+
52275409

52285410
@admin.register(LinkShortener)
52295411
class LinkShortenerAdmin(admin.ModelAdmin):

0 commit comments

Comments
 (0)