Commits

Victor Gavro committed 9f0fb79

Added admin, docstrings, view for callback, changed statuses, model structure, other major changes

  • Participants
  • Parent commits 9ad60df

Comments (0)

Files changed (12)

File README

-Application to send SMS via various SMS Gates API (not email gates).
-
-currently supported backends:
-messagebox.com.ua
-
-You can easilly extend application with another backend, contribute it to project would be nice too.
-
-#TODO: i18n
-#TODO: validation of sms text,standart validators using regexp
-#TODO: PhoneField
-#TODO: отправка больших смс-сообщений, которые разбиваются на несколько штук? #need to add "credits" field
+'''
+Application to send SMS via various SMS Gates API backends and store it status into db. You may use it to work with sms messages using GSM modem, HTTP server, etc.
+
+Currently supported backends:
+mssgbox.com
+
+You can easilly extend application with another backend, contribute it to project would be nice too.
+
+USAGE
+
+Sending sms:
+------------
+
+To create sms:
+from smsgate.models import Sms
+Sms.create('Hello, world!',('380506175125',))
+
+You may send sms immediately or by cron using "manage.py sms_mass_send". See SMSGATE_SEND_ON_SAVE setting.
+
+If you don\'t need to store sent sms, you may send sms this way:
+import smsgate
+smsgate.send_sms('hello',('380506175125','380667566228'))
+
+Use smsgate.send_mass_sms for mass sending.
+
+Retrieving sms statuses:
+------------------------
+
+You may use callback url to recieve sms statuses from backend. See "SMSGATE_CALLBACK_URL" option in settings.
+
+Alternativelly, you may retrieve sms statuses using "manage.py sms_get_statuses" command on cron (if your gate stores this information after callback), or use function smsgate.get_sms_statuses directly.
+'''
+
+
 from smsgate import settings
 from django.core.exceptions import ImproperlyConfigured
 
     return callback
 
 def send_mass_sms(datatuple,sender=settings.SENDER,callback=False,fail_silently=False):
+  '''
+  Given a datatuple of (text,phones,sms_id) (sms_id is optional) sends each sms to each phones. Returns the tuple of results.
+  For results and arguments description see send_sms function docstring.
+  '''
   callback_url = _get_callback_url(callback)
   try:
     if hasattr(backend,'send_mass_sms'):
     else:
       result = []
       for args in datatuple:
+        if len(args) < 3:
+          args[2] = None
         result.append(
           backend.send_sms(args[0],args[1],args[2],sender=sender,callback_url=callback_url)
         )
     if not fail_silently:
       raise
 
-def send_sms(message,recipient_list,sms_ids=None,sender=settings.SENDER,callback=False,fail_silently=False):
+def send_sms(message,recipient_list,sms_id=None,sender=settings.SENDER,callback=False,fail_silently=False):
+  '''
+  Function for sending sms message.
+  Returns tuple of (sms_id,status,status_text), for each recipient in recipient_list.
+
+  message - unicode string of sms body.
+  recipient_list - tuple of phones in international phormat, without +.
+  You may specify sms_id. You need this ONLY if your gate doesn\'t support sms_id generation by itself, AND you need to get status of this sms later. In that case backend would generate unique id based on this value and phone number.
+  If sender is not specified, SMSGATE_SENDER setting is used.
+  You may specify callback url, or set callback=True to use SMSGATE_CALLBACK_URL. By default callback is false (note that on some servers callback_url setting must be set on serverside).
+  If fail_silently is True, all exceptions are suppressed.
+  '''
   callback_url = _get_callback_url(callback)
   try:
     return backend.send_sms(message,recipient_list,sms_ids=sms_ids,sender=sender,callback_url=callback_url)
     if not fail_silently:
       raise
 
-def get_sms_status(sms_ids,fail_silently=False):
+def get_sms_statuses(sms_ids,fail_silently=False):
+  '''
+  Function for retrieving sms statuses from server (Note that if you use callback_url, you don\'t need to retrieve status by yourself).
+  Returns tuple of statuses, corresponding to tuple of sms_ids.
+  If fail_silently is True, all exceptions are suppressed.
+  '''
   try:
-    return backend.get_sms_status(sms_ids)
+    return backend.get_sms_statuses(sms_ids)
   except:
     if not fail_silently:
       raise
 
-def sms_callback(request):
-  return backend.sms_callback(request)
-
 def sms_admins(message,fail_silently=False):
+  '''Send sms message to admins, specified in SMSGATE_ADMINS_PHONES setting.'''
   if settings.ADMINS_PHONES:
     return send_sms(message,settings.ADMINS_PHONES,fail_silently=fail_silently)
 
 def sms_managers(message,fail_silently=False):
+  '''Send sms message to managers, specified in SMSGATE_MANAGERS_PHONES setting.'''
   if settings.MANAGERS_PHONES:
     return send_sms(message,settings.MANAGERS_PHONES,fail_silently=fail_silently)
+from django.contrib import admin
+from smsgate.models import Sms,SmsRecipient
+from smsgate import settings
+from django.utils.translation import ugettext_lazy as _
+
+def sms_send_queued(self, request, queryset):
+  for sms in queryset:
+    sms.send()
+  self.message_user(request, _('%s messages sent') % len(queryset))
+send_sms.short_description = _('Send sms to queued recipients')
+
+def sms_recipient_get_status(self, request, queryset):
+  count = SmsRecipient.get_statuses(recipients=queryset)
+  self.message_user(request,_('%s sms statuses fetched') % count)
+sms_recipient_get_status.short_description = _('Retrieve current status')
+
+def sms_recipient_resend(self, request, queryset):
+  for sms_recipient in queryset:
+    sms_recipient.send()
+  self.message_user(request,_('%s messages sent') % len(queryset))
+sms_recipient_resend.short_description = _('Resend sms to recipient')
+
+def sms_start_text(obj):
+  return unicode(obj)
+def sms_recipients_count(obj):
+  return obj.recipients.all().count()
+def sms_success_count(obj):
+  return obj.recipients.filter('status__in': SUCCESS_STATUSES).count()
+
+class SmsRecipientInline(admin.TabularInline):
+  model = SmsRecipient
+  extra = 10
+
+class SmsAdmin(admin.ModelAdmin):
+  list_display = (sms_start_text,'sender',sms_recipients_count,sms_success_count)
+  actions = [sms_send_queued,]
+  inlines = (
+    SmsRecipientInline,
+  )
+
+class SmsRecipientAdmin(admin.ModelAdmin):
+  actions = [sms_recipient_get_status,sms_recipient_resend]
+  list_display = ('phone','sms','status','status_text','status_time','sent_time')
+
+admin.site.register(Sms)
+admin.site.register(SmsRecipient)

File backends/mssgbox_com/constants.py

 from smsgate.constants import *
+from django.utils.translation import ugettext_noop as _
+#using ugettext_noop so we can store message in database as strings, and have them translated after rendering
 
 ERROR_CODES = {
-  '1': 'Authentication failed',
-  '2': 'Unknown username',
-  '3': 'Invalid password',
-  '4': 'Invalid or missing API ID',
-  '5': 'Invalid or expired session ID',
-  '6': 'Account locked',
-  '7': 'IP Lockdown violation',
-  '101': 'Invalid or missing parameters',
-  '102': 'Unknown message UID',
-  '103': 'Missing message UID',
-  '104': 'Invalid destination address',
-  '105': 'Invalid source address',
-  '106': 'Empty message',
-  '107': 'Invalid sender id',
-  '108': 'Invalid delivery time',
-  '120': 'Cannot route message',
-  '121': 'Destination mobile number blocked',
-  '201': 'No credit left',
-  '202': 'Max allowed credit',
-  '301': 'Internal error',
+  '1': _('Authentication failed'),
+  '2': _('Unknown username'),
+  '3': _('Invalid password'),
+  '4': _('Invalid or missing API ID'),
+  '5': _('Invalid or expired session ID'),
+  '6': _('Account locked'),
+  '7': _('IP Lockdown violation'),
+  '101': _('Invalid or missing parameters'),
+  '102': _('Unknown message UID'),
+  '103': _('Missing message UID'),
+  '104': _('Invalid destination address'),
+  '105': _('Invalid source address'),
+  '106': _('Empty message'),
+  '107': _('Invalid sender id'),
+  '108': _('Invalid delivery time'),
+  '120': _('Cannot route message'),
+  '121': _('Destination mobile number blocked'),
+  '201': _('No credit left'),
+  '202': _('Max allowed credit'),
+  '301': _('Internal error'),
 }
 
 STATUS_CODES = {
-  '1': (STATUS_CANCELED, 'Message unknown'),
-  '2': (STATUS_QUEUED, 'Message queued'),
-  '3': (STATUS_DELIVERED, 'Delivered to gateway'),
-  '4': (STATUS_DELIVERED, 'Recieved by recipient'),
-  '5': (STATUS_UNDELIVERED,'Error with message'),
-  '6': (STATUS_CANCELED, 'User cancelled message delivery'),
-  '7': (STATUS_UNDELIVERED, 'Error delivering message'),
-  '8': (STATUS_SENT, 'OK'),
-  '9': (STATUS_UNDELIVERED, 'Routing error'),
-  '10': (STATUS_EXPIRED, 'Message expired'),
-  '11': (STATUS_QUEUED, 'Message queued for later delivery'),
-  '12': (STATUS_CANCELED, 'Out of credit'),
+  '1': (STATUS_UNSENT, _('Message unknown')),
+  '2': (STATUS_REMOTE_QUEUED, _('Message queued')),
+  '3': (STATUS_DELIVERED, _('Delivered to gateway')),
+  '4': (STATUS_DELIVERED, _('Recieved by recipient')),
+  '5': (STATUS_UNDELIVERED,_('Error with message')),
+  '6': (STATUS_CANCELED, _('User cancelled message delivery')),
+  '7': (STATUS_UNDELIVERED, _('Error delivering message')),
+  '8': (STATUS_SENT, _('OK')),
+  '9': (STATUS_UNDELIVERED, _('Routing error')),
+  '10': (STATUS_EXPIRED, _('Message expired')),
+  '11': (STATUS_REMOTE_QUEUED, _('Message queued for later delivery')),
+  '12': (STATUS_UNSENT, _('Out of credit')),
 }

File backends/mssgbox_com/http_v1.py

   '''Returns session_id for mass sms sending.'''
   match = parse(send_command('auth'),'(OK|ERR): ([0-9a-f]+)')[0]
   if match[0] == 'ERR':
-    raise SmsGateError(match[1],ERROR_CODES.get(match[1],'Unknown code: %s' % match[1]))
+    raise SmsGateError(match[1],ERROR_CODES.get(match[1],u'Unknown code: %s' % match[1]))
   return match[1]
 
 def sendMsg(message,recipient_list,sender,**options):
   for match in match_all:
     #TODO Assuming that recipient statuses returned with same order as we posted them
     if match[0] == 'ERR':
-      result.append((None,STATUS_CANCELED,ERROR_CODES.get(match[1],'Unknown code: %s' % match[1])))
+      result.append((None,STATUS_UNSENT,ERROR_CODES.get(match[1],u'Unknown code: %s' % match[1])))
     else:
       result.append((match[1],STATUS_SENT,None))
   return tuple(result)
    response = send_command('queryMsg',uid=sms_id,**options)
    match = parse(response,'(Status|ERR): (\d+)')[0]
    if match[0] == 'ERR':
-     return None,ERROR_CODES.get(match[1],'Unknown code: %s' % match[1])
-   return STATUS_CODES.get(match[1],(None,'Unknown status: %s' % match[1]))
+     return STATUS_UNKNOWN,ERROR_CODES.get(match[1],u'Unknown error code: %s' % match[1])
+   return STATUS_CODES.get(match[1],(STATUS_UNKNOWN,u'Unknown status: %s' % match[1]))
 
-def get_sms_status(sms_ids):
+def get_sms_statuses(sms_ids):
   kwargs = {}
   if len(sms_ids) > 1:
     kwargs['session_id'] = auth()
     result.append(queryMsg(sms_id,**kwargs))
   return result
 
-def send_sms(message,recipient_list,sms_ids,sender,callback_url):
+def send_sms(message,recipient_list,sms_id,sender,callback_url):
   return sendMsg(message,recipient_list,sender,msg_callback=bool(callback_url))
 
 def send_mass_sms(datatuple,sender,callback_url):
   session_id = auth()
-  for message,recipient_list,sms_ids in datatuple:
+  for message,recipient_list,sms_id in datatuple:
     yield sendMsg(message,recipient_list,sender,msg_callback=bool(callback_url),session_id=session_id)

File constants.py

-#Status only for models - to save into db messages that was\n't
-STATUS_LOCAL_QUEUED = 1
-#Message queued on server
-STATUS_QUEUED = 6
-#Message was not sent, and never will be
-STATUS_CANCELED = 7
-#Message is sent ok, but status unknown
-STATUS_SENT = 2
-#Message delivered to recipient
-STATUS_DELIVERED = 3
-#Message undelivered to recipient
-STATUS_UNDELIVERED = 4
-#Message was sent, but requesting status expired
-STATUS_EXPIRED = 5
-
+STATUS_QUEUED = 1 #Message local queued.
+STATUS_CANCELED = 2 #Message was canceled before sending
+STATUS_SENT = 3 #Message is sent ok, but status unknown.
+STATUS_UNSENT = 4 #Message was not sent, and never will be.
+STATUS_UNKNOWN = 5 #Message status is unknown
+STATUS_REMOTE_QUEUED = 6 #Message queued on remote server
+STATUS_DELIVERED = 7 #Message delivered to recipient
+STATUS_UNDELIVERED = 8 #Message undelivered to recipient
+STATUS_EXPIRED = 9 #Message was sent, but requesting status expired

File management/commands/sms_get_statuses.py

+from django.core.management.base import BaseCommand, CommandError
+from optparse import make_option
+from smsgate.models import SmsRecipient
+
+class Command(BaseCommand):
+  help = 'Retrieve statuses of sent messages.\nIf you want to fetch statuses not only on sent messages, see --sms_statuses option.'
+  option_list = BaseCommand.option_list + (
+    make_option('--sms_statuses',
+      metavar='STATUS_LIST',
+      dest='sms_statuses',
+      default='3,5',
+      help='Specify comma separated sms statuses ids (see smsgate.constants module)'),
+    )
+
+  def handle(self, *args, **options):
+    statuses = map(int,options['sms_statuses'].split(','))
+    count = SmsRecipient.get_statuses(statuses)
+    print '%s messages sent.' % count

File management/commands/sms_mass_send.py

 
   def handle(self, *args, **options):
     statuses = map(int,options['sms_statuses'].split(','))
-    mass_sent_data = Sms.mass_send(statuses)
-    print '%s messages sent.' % len(mass_sent_data)
-
+    count = Sms.mass_send(statuses)
+    print '%s messages sent.' % count
-#coding:utf8
 from smsgate import settings
 from smsgate import send_sms, send_mass_sms
 from smsgate.constants import *
+
 from django.db import models
+from django.core.validators import RegexValidator
+from django.utils.translation import ugettext_lazy as _
+
 from datetime import datetime
 
+phone_validator = RegexValidator('\+?\d{12}',message=_('Telephone number must be in international format, see http://en.wikipedia.org/wiki/List_of_country_calling_codes for your country telephone format.'))
+
 class Sms(models.Model):
-  text = models.TextField('SMS text')
-  sender = models.CharField(max_length=32,default=settings.SENDER)
+  text = models.TextField(_('SMS text'))
+  sender = models.CharField(_('Sender'),max_length=32,default=settings.SENDER)
 
   def __unicode__(self):
-    return self.text
+    u'%s...' % sms.text[:16]
 
   def send(self,statuses=(STATUS_QUEUED,),recipients=None):
     if recipients == None:
     sent_data = send_sms(
       self.text,
       [r.phone for r in recipients],
-      [r.id for r in recipients],
+      self.id,
       sender = self.sender,
       callback = settings.CALLBACK_URL,
     )
       datatuple.append((
         sms.text,
         [r.phone for r in recipients],
-        [r.id for r in recipients]
+        self.id,
       ))
       recipients_lists.append(recipients)
 
         for n,(sms_id,status,status_text) in enumerate(sent_data):
           recipients[n].set_processed(sms_id,status,status_text,save=True)
 
-    return mass_sent_data
+    return len(mass_sent_data)
 
 class SmsRecipient(models.Model):
   STATUS_CHOICES = (
-    (STATUS_QUEUED, u'В очереди на отправку'),
-    (STATUS_CANCELED, u'Отменено'),
-    (STATUS_SENT, u'Отправлено'),
-    (STATUS_DELIVERED, u'Доставлено'),
-    (STATUS_UNDELIVERED, u'Не доставлено'),
-    (STATUS_EXPIRED, u'Срок доставки истек'),
+    (STATUS_QUEUED, _('Queued')),
+    (STATUS_CANCELED, _('Canceled')),
+    (STATUS_SENT, _('Sent')),
+    (STATUS_UNSENT, _('Not sent')),
+    (STATUS_UNKNOWN, _('Unknown')),
+    (STATUS_REMOTE_QUEUED, _('Queued on server')),
+    (STATUS_DELIVERED, _('Delivered')),
+    (STATUS_UNDELIVERED, _('Undelivered')),
+    (STATUS_EXPIRED, _('Expired')),
   )
-  phone = models.CharField(max_length=13)
-  sms_id = models.CharField(max_length=32,editable=False)
-  status = models.SmallIntegerField(choices=STATUS_CHOICES,default=STATUS_QUEUED,editable=False)
-  queued_time = models.DateTimeField(default=datetime.now,editable=False)
-  status_time = models.DateTimeField(null=True,blank=True,editable=False)
-  sent_time = models.DateTimeField(null=True,blank=True,editable=False)
-  status_message = models.CharField(max_length=64,null=True,blank=True,editable=False)
+  phone = models.CharField(_('Phone number'),max_length=12,validators=[phone_validator,])
+  sms_id = models.CharField(_('Remote id'),max_length=32,editable=False)
+  status = models.SmallIntegerField(_('Status'),choices=STATUS_CHOICES,default=STATUS_QUEUED)
+  queued_time = models.DateTimeField(_('Queued time'),default=datetime.now,editable=False)
+  status_time = models.DateTimeField(_('Status renew time'),null=True,blank=True,editable=False)
+  sent_time = models.DateTimeField(_('Sent time'),null=True,blank=True,editable=False)
+  status_text = models.CharField(_('Status message'),max_length=64,null=True,blank=True,editable=False)
+  credits = models.IntegerField(_('Credits'),default=1) #You may use this field to store data for your billing system for big messages or different operators
   sms = models.ForeignKey(Sms,editable=False,related_name='recipients')
 
   class Meta:
   def __unicode__(self):
     return u'%s: %s' % (self.id,self.phone)
 
-  def set_status(status,time=None,save=True):
-    self.status = status
+  def save(self,*args,**kwargs):
+    #We want user to pass telephones with and without +, but saves them only without +. Validators can\'t set new values, but save method of course doesn\'t invoke on form validation, so we have redundant validation as workaround.
+    phone_validator(self.phone)
+    if self.phone[0] == '+':
+      self.phone = self.phone[1:]
+    return super(SmsRecipient,self).save(*args,**kwargs)
+
+  def send(self):
+    self.sms.send(recipients=(self,))
+
+  def set_status(status,status_text=None,time=None,save=True):
+    self.status,self.status_text = status,status_text
     self.status_time = time if time else datetime.now()
     if save:
       self.save()
     self.sent_time = time if time else datetime.now()
     if sms_id:
       self.sms_id = sms_id
-    self.set_status(status,time=self.sent_time,save=False)
+    self.set_status(status,status_text,time=self.sent_time,save=False)
     if save:
       self.save()
 
   def get_status(self,save=True):
     if self.sms_id:
-      self.set_status(get_sms_status((self.sms_id)),save=save)
+      status,status_text = get_sms_statuses((self.sms_id,))[0]
+      self.set_status(status,status_text,save=save)
 
   @classmethod
-  def get_statuses(cls,statuses=(STATUS_SENT,),set_status=True):
-    today = date.today()
-    recipients = []
-    for obj in cls.objects.filter(status__in=statuses):
-      if ((today - obj.time_sent.date()).days > settings.STATUS_MAX_DAYS):
-        obj.status = STATUS_EXPIRED
-        obj.save()
+  def get_statuses(cls,statuses=(STATUS_SENT,STATUS_UNKNOWN),set_status=True,set_expired=True,recipients=None):
+    time = datetime.now()
+    if not recipients:
+      recipients = cls.objects.filter(status__in=statuses,sms_id__isnull=False)
+
+    #we can fetch recipients only with sms_id
+    fetch_recipients = []
+    for obj in recipients:
+      if set_expired and (time.today() - obj.time_sent.date()).days > settings.STATUS_MAX_DAYS:
+        obj.set_status(STATUS_EXPIRED,time=time)
       elif obj.sms_id:
-        recipients.append(obj)
-    fetched_statuses = get_sms_status([r.sms_id for r in recipients])
+        fetched_recipients.append(obj)
+
+    fetched_statuses = get_sms_statuses([r.sms_id for r in fetch_recipients])
     if set_status:
-      for n,status in enumerate(fetched_statuses):
-        recipients[n].set_status(status,save=True)
-    return fetched_statuses
+      for n,(status,status_text) in enumerate(fetched_statuses):
+        fetch_recipients[n].set_status(status,status_text,time=time,save=True)
+    return len(fetched_statuses)
 from django.conf import settings
+from smsgate.constants import *
 
 def _get(name,default):
   return getattr(settings,'SMSGATE_' + name, default)
 #Send sms on saving sms in database. You may turn it off to send messages by cron.
 SEND_ON_SAVE = _get('SEND_ON_SAVE',True)
 
-#Callback url for recieving sms statuses by smsgates
-#Do not forget to include it to your urls.py
-#Note, that on some backends CALLBACK_URL configured on sms-gate server side and isn\'t sent, in that case you may set CALLBACK_URL=True
-CALLBACK_URL = _get('CALLBACK_URL',None)
+#Callback url for recieving sms statuses from gates.
+#If you want to use callback to get sms statuses, you should include function sms_callback to urls.py of your project.
+#EXAMPLE:
+#settings.py: SMSGATE_CALLBACK_URL = "http://example.com/MY_CALLBACK_URL"
+#urls.py: (r'^MY_CALLBACK_URL', 'smsgate.views.sms_callback'),
+#Note, that on some backends CALLBACK_URL configured directly on gate server side and isn\'t sent, in that case you should set CALLBACK_URL=True
+CALLBACK_URL = _get('CALLBACK_URL',False)
+CALLBACK_IPS = _get('CALLBACK_IPS',('127.0.0.1',)) #Allow connection to callback_url only for specified ips
 
 STATUS_MAX_DAYS = 60
 
 
 ADMINS_PHONES = _get('ADMINS_PHONES',())
 MANAGERS_PHONES = _get('MANAGERS_PHONES',())
+
+SUCCESS_STATUSES = (STATUS_SENT,STATUS_DELIVERED)

File tests.py

-"""
-This file demonstrates two different styles of tests (one doctest and one
-unittest). These will both pass when you run "manage.py test".
-
-Replace these with more appropriate tests for your application.
-"""
-
-from django.test import TestCase
-
-class SimpleTest(TestCase):
-    def test_basic_addition(self):
-        """
-        Tests that 1 + 1 always equals 2.
-        """
-        self.failUnlessEqual(1 + 1, 2)
-
-__test__ = {"doctest": """
-Another way to test that 1 + 1 is equal to 2.
-
->>> 1 + 1 == 2
-True
-"""}
-
-# Create your views here.
+from smsgate import backend
+from smsgate import settings
+from smsgate.models import SmsRecipient
+from django.http import HttpResponse
+
+def sms_callback(request):
+  'This is callback view function, which should process http request, recieved from gate, and save sms statuses to database.'
+  if request.META['REMOTE_ADDR'] not in settings.CALLBACK_IPS:
+    return HttpResponse(status=403)
+  for sms_id,status,status_text in backend.sms_callback(request):
+    SmsRecipient.objects.get(sms_id=sms_id).set_status(status,status_text,save=True)
+  return HttpResponse(status=200)
+