4444)
4545from api .agent .core .schedule_parser import ScheduleParser
4646from .admin_forms import (
47+ FindReleaseCandidatesForm ,
48+ ReleaseSmsNumbersForm ,
4749 TestSmsForm ,
4850 GrantPlanCreditsForm ,
4951 GrantCreditsByUserIdsForm ,
9799from .agent .files .filespace_service import enqueue_import_after_commit
98100from .tasks import sync_ip_block , backfill_missing_proxy_records , proxy_health_check_single , garbage_collect_timed_out_tasks
99101from .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+ )
101108from .services .global_skill_json import (
102109 import_global_skill_from_payload ,
103110 parse_global_skill_json_bytes ,
116123
117124import 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
121131admin .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 )
52295411class LinkShortenerAdmin (admin .ModelAdmin ):
0 commit comments