class AddressType(Model): ''' The different types of addresses. You won't need to change these. The initial data fixture makes sure you get BILLING, DELIVERY, POSTAL, VISIT and OTHER. The Relation model uses these types to get the billing_address and so forth from the available addresses. ''' identifier = SafeCharField( _('identifier'), max_length=16, help_text=_('An identifier for machine lookups: e.g. "BILLING".')) description = SafeCharField( _('description'), max_length=63, help_text=_('A descriptive name: e.g. "Postal address".')) def __repr__(self): return 'AddressType::%s' % self.identifier def __unicode__(self): return self.description class Meta: """Django metaclass information.""" verbose_name = _('address type') verbose_name_plural = _('address types')
class Country(Model): ''' The country. These are filled in by a fixture. No need to touch them. ''' code = SafeCharField(_('code'), max_length=2, primary_key=True, help_text=_('The ISO 3166 alpha2 code in lowercase.')) name = SafeCharField(_('name'), max_length=63, help_text=_('The country name.')) order = models.PositiveIntegerField( _('order'), default=0, help_text=_( 'A non-zero number orders the countries highest first in select boxes ' '(use this for commonly used countries).')) def __unicode__(self): return self.name class Meta: """Django metaclass information.""" ordering = ('-order', 'name') verbose_name = _('country') verbose_name_plural = _('countries')
class Address(Model): ''' Every relation can have zero or more addresses. Most often, the relation will have only one address, with the types BILLING, DELIVERY, POSTAL and VISIT set. Using the address_type many2many field, you don't need to keep four addresses in sync. ''' relation = models.ForeignKey( Relation, verbose_name=_('relation'), help_text=_('The relation this address belongs to.')) address_type = models.ManyToManyField( AddressType, verbose_name=_('address type'), help_text=_('Select one or more types of addresses.')) number = models.PositiveIntegerField( _('number'), help_text= _('The house number must be an integer, see the next field for extensions.' )) complement = SafeCharField(_('complement'), max_length=32, blank=True, help_text=_('Optional house number suffixes.')) street = SafeCharField(_('street'), max_length=127, help_text=_('The street name, without number.')) city = models.ForeignKey(City, verbose_name=_('city'), help_text=_('The city.')) zipcode = SafeCharField(_('zip code'), max_length=16, help_text=_('Zip/postal code.')) contact = models.ForeignKey(Contact, blank=True, null=True, help_text=_('For the attention of')) def __unicode__(self): return _(u'%(relation)s addresses: %(address_types)s') % { 'relation': self.relation, 'address_types': ', '.join(unicode(addr) for addr in self.address_type.all()), } class Meta: """Django metaclass information.""" ordering = ('relation__name', ) permissions = (('view_address', 'Can view address'), ) verbose_name = _('address') verbose_name_plural = _('addresses')
class Payout(Model): ''' Payout contains the relation between the telecom operator, the shortcode, the MO/MT (Mobile Originated/Terminated) text message tariff and the payout to this business (which can be significantly lower than the tariff). ''' operator = models.ForeignKey(Operator, help_text=_('The GSM operator.')) local_address = SafeCharField( max_length=32, blank=True, help_text=_( 'Local phone number, e.g. a shortcode. Leave empty to match all.')) tariff_cent = models.PositiveIntegerField( blank=True, null=True, help_text=_( 'The MT SMS tariff in cents. Leave NULL to set the MO payout. ' '(Watch out for dupes. The unique constraint will not work with NULL values.)' )) payout_cent = DecimalField(help_text=_( 'The Payout (in cents!) by the GSM operator for this tariff.')) class Meta: unique_together = ('operator', 'local_address', 'tariff_cent')
class TextMessageExtra(models.Model): ''' Model that contains SMS-provider specific information. The meta thing was a failed idea. Make sure you use a transactional database when using this for SMS sending. If you don't and you use a separate thread/process to empty your SMS queue, you might end up with messages sent for free because this object didn't exist yet when polling for new messages. ''' textmessage = models.OneToOneField( TextMessage, related_name='extra', help_text=_('The textmessage that the extra info is about.')) shortcode = models.PositiveIntegerField( blank=True, null=True, help_text=_( 'Shortcode that this message was received on, or is sent from.')) keyword = SafeCharField( max_length=63, blank=True, null=True, help_text=_( 'Keyword that this message was received for, or is sent from.')) tariff_cent = models.PositiveIntegerField( default=0, help_text=_('Consumer price (MT) for sent SMS.')) foreign_reference = SafeCharField( max_length=31, blank=True, db_index=True, help_text=_('Foreign reference (e.g. mid for Mollie).')) foreign_status = SafeCharField( max_length=31, blank=True, default='', help_text=_('Same as status, but SMS-provider specific.')) @property def tariff(self): return float(self.tariff_cent) / 100 def __str__(self): return 'SMS Info #%d @ %s %s \u00a4 %d (%s, %s)' % ( self.textmessage_id, self.shortcode, self.keyword, self.tariff_cent, self.foreign_reference, self.foreign_status or '...')
class DeliveryReportForwardLog(models.Model): datetime = models.DateTimeField( auto_now_add=True, help_text=_('The datetime the response was forwarded.')) post_data = SafeCharField(max_length=512, help_text=_('The POST data.')) destination = SafeCharField( max_length=200, help_text=_('The destination the delivery report was forwarded to.')) batch_prefix = SafeCharField( max_length=64, help_text=_('The batch prefix that was matched.')) response = SafeCharField( max_length=512, blank=True, help_text=_('The response from the DLR destination.')) def __unicode__(self): return '%s: %s => %s' % (self.datetime, self.batch_prefix, self.destination)
class DeliveryReportForward(models.Model): batch_prefix = SafeCharField( max_length=64, unique=True, help_text=_('The batch prefix to match delivery reports.')) destination = models.URLField( help_text=_('The URL to forward the delivery report to.')) def __unicode__(self): return '%s => %s' % (self.batch_prefix, self.destination)
class PhoneNumber(Model): ''' Phone usage and thereby assumptions based on phone numbers and phone number types are blurring. Therefore this PhoneNumber lacks a PhoneNumberType field. Instead, you get to pour your heart out in the freeform comment field. ("Work", "Home", "Mobile", "Only on sundays between 12:05 and 13:05" are all fine ;-) One special case could be "Fax" which you might want to pick automatically.) Because the comments and the activity-flag may differ, the phone numbers are not unique+m2m, but non-unique+one2one. ''' relation = models.ForeignKey( Relation, verbose_name=_('relation'), related_name='phonenumber_set', help_text=_('The relation this phone number belongs to.')) number = PhoneNumberField(_('number'), help_text=_('The telephone number.')) active = models.BooleanField( _('active'), blank=True, default=True, help_text=_('Whether one should use this number.')) comment = SafeCharField( _('comment'), max_length=63, blank=True, help_text=_('Optional comments about the number\'s use (or "Fax").')) def __unicode__(self): return _( u'%(relation)s phone number: %(number)s%(comment)s%(active)s') % { 'relation': self.relation, 'number': self.number, 'comment': (u'', u' (%s)' % self.comment)[self.comment != ''], 'active': (_(' INACTIVE'), u'')[self.active], } class Meta: """Django metaclass information.""" ordering = ('relation__name', ) verbose_name = _('phone number') verbose_name_plural = _('phone numbers')
class Contact(Model): ''' A contact (a person) tied to a Relation. ''' # XXX: 'name' should really sync itself with the user.forename / # user.surname if it is an authenticatable contact (and.. should we # split this name into a fore-/surname?) relation = models.ForeignKey(Relation, verbose_name=_('relation')) name = SafeCharField(_('name'), max_length=63, help_text=_('The full name of the contact.')) email = models.EmailField(_('e-mail address'), blank=True) def __unicode__(self): return self.name class Meta: """Django metaclass information.""" permissions = (('view_contact', 'Can view contact'), ) verbose_name = _('contact') verbose_name_plural = _('contacts')
class Operator(Model): ''' A GSM operator. E.g. KPN Telecom (204-08) in the Netherlands. ''' if Country: country = models.ForeignKey( Country, help_text=_('The country (found through the first part ' 'of the code).')) else: country = models.CharField(max_length=2, help_text=_('Two letter country code.')) code = models.PositiveIntegerField( help_text=_('The GSM operator code, e.g. 8 for 204-08 KPN Telecom.')) name = SafeCharField(max_length=64, blank=True, help_text=_('A friendly name, e.g. "KPN Telecom".')) objects = OperatorManager() def entire_code(self, separator='-'): return '%3d%s%02d' % (OperatorCountryCode.objects.get( country=self.country).code, separator, self.code) if Country: def __unicode__(self): return u'%s %s (%s)' % (self.entire_code(), self.name, self.country.code) else: def __unicode__(self): return u'%s %s (%s)' % (self.entire_code(), self.name, self.country) class Meta: unique_together = ('code', 'country')
class City(Model): ''' The city. You may need to add lots of these. ''' country = models.ForeignKey( Country, verbose_name=_('country'), help_text=_('Select the country the city lies in.')) name = SafeCharField(_('name'), max_length=63, help_text=_('The city name.')) def __unicode__(self): return (_(u'%(city)s (%(countrycode)s)') % { 'city': self.name, 'countrycode': self.country.code }) class Meta: """Django metaclass information.""" ordering = ('name', ) verbose_name = _('city') verbose_name_plural = _('cities')
class Item(Model): ''' Generic model to dynamically store various additional database fields. The TEXT datatype strips excess spaces from the value at save time. The datatype used to be a separate model, but (a) no other data types have been defined since the inception of this app and (b) MongoDB does not like the fact that it used integers as primary keys. At the time, the best fix was to replace the 'datatype' foreign key with a 'datatype_id' integer. This maintains backwards compatibility and fixes so stuff just works on Mongo. As an added bonus, the save() is now quicker, because it doesn't have to look up the PK for TEXT any more. ''' # One of DATATYPE_TEXT, ... datatype_id = models.IntegerField(verbose_name=_('data type'), default=1) # Key can be something like "asterisk.management.username" key = SafeCharField(_('key'), max_length=63, primary_key=True) # The value, stored in a text field. Other data types may at one point fix # type casting when using the aboutconfig() utility function. value = models.TextField(_('value'), blank=True) def save(self, *args, **kwargs): if self.datatype_id == DATATYPE_TEXT: self.value = self.value.strip() super(Item, self).save(*args, **kwargs) def __unicode__(self): return self.key if not py2: __str__ = __unicode__ # py3 class Meta: ordering = ('key', ) verbose_name = _('advanced/hidden configuration item') verbose_name_plural = _('advanced/hidden configuration items')
class TextMessage(models.Model): ''' A text message of the short message service (SMS) kind. If you need to store more information, use a one2one mapping that stores e.g. whether you're done with the message or who owns the message an so on. ''' created = models.DateTimeField( auto_now_add=True, db_index=True, help_text=_('When the text message was created in this system.')) modified = models.DateTimeField( auto_now=True, help_text=_('When the text message was last modified.')) status = models.CharField( choices=STATUS_CHOICES, max_length=max(len(i[0]) for i in STATUS_CHOICES), help_text=_('The status of the message (includes direction).')) local_address = SafeCharField( max_length=32, blank=True, help_text=_('Local phone number. This does not necessarily ' 'need to be a phone number.')) remote_address = PhoneNumberField( help_text=_('The phone number of the remote end: the originator ' 'on inbound and the recipient on outbound ' '(with country code, without leading zeroes).')) remote_operator = models.ForeignKey( Operator, blank=True, null=True, help_text=_('Optionally the GSM operator of the remote end.')) body = models.TextField( help_text=_('The message body. In case of a simple messaging ' 'server, this should be at most 160 characters long.')) body_count = models.PositiveIntegerField( default=1, help_text=_('How many messages this message is composed of.') ) # could be important for reply counts or billing purposes delivery_date = models.DateTimeField( blank=True, null=True, db_index=True, help_text=_('The delivery date. On an outbound message, this ' 'should be set first on acknowledgement of receipt.')) metadata = models.TextField( blank=True, help_text=_('Optional metadata as a pickled python object. By ' 'convention this is either None or a list of ' 'dictionaries.')) objects = TextMessageManager() def __init__(self, *args, **kwargs): self.connection = kwargs.pop('connection', None) super(TextMessage, self).__init__(*args, **kwargs) def _meta_read(self): if self.metadata == '': return None # Python pickle routines using protocol 0 use both low and high # bytestring characters (<0x20 and >0x7f). We could do # return pickle.loads(self.metadata.encode('latin1')) # here and # self.metadata = pickle.dumps(value).decode('latin1') # in _meta_write. (latin1 is just an example, any encoding that # uses the all 8bit characters is fine.) But then we still get # the low ascii which is fragile in browser textboxes. # # See python issue http://bugs.python.org/issue2980 for the # >0x7f part of the issue. return pickle.loadascii(self.metadata) def _meta_write(self, value): if value in (None, ''): self.metadata = '' else: self.metadata = pickle.dumpascii(value) def _meta_delete(self): self.metadata = '' meta = property(_meta_read, _meta_write, _meta_delete, 'Get or set freeform metadata.') def meta_append(self, dict_or_none, commit=True): ''' Append a dictionary to the metadata. If dict_or_none is None, nothing is appended. Note that the object is saved anyway if commit is True. ''' if dict_or_none is not None: meta = self.meta if meta is None: meta = [dict_or_none] else: assert isinstance( meta, list ), 'Expected meta to be a list of dictionaries, not %r.' % meta meta.append(dict_or_none) self.meta = meta if commit: self.save() def create_reply(self, body): return TextMessage.objects.create( status='out', local_address=self.local_address.split( ' ', 1)[0], # use only shortcode, not any keywords remote_address=self.remote_address, remote_operator=self.remote_operator, body=body) def get_connection(self, fail_silently=False): from osso.sms import get_connection if not self.connection: self.connection = get_connection(fail_silently=fail_silently) return self.connection def get_keywords(self, max_keywords, mode='loose'): ''' Get the N keywords starting at the shortcode, moving on to the words in the SMS body. Yes, get_keywords(1)[0] is the shortcode! Observe that we ignore the keywords supplied by the provider. If you want to use that, you can get it from the local_address, but in most cases these keywords are still in the message body. This is the preferred way to match messages by keyword, as upstream providers may or may not know about your particular keyword, so checking the local_address will probably not be enough. Keywords returned from here are guaranteed to be in [0-9A-Z]. The mode flag can be one of 'loose', 'normal', 'strict'. They separate the keywords by '\\W+', '\\s+' and ' ' respectively. >>> from osso.sms.models import TextMessage >>> t = TextMessage(local_address='1008 X', body="i -wouldn't t99") >>> t.get_keywords(0) [] >>> t.get_keywords(1) ['1008'] >>> t.get_keywords(2) ['1008', 'I'] >>> t.get_keywords(3) ['1008', 'I', 'WOULDN'] >>> t.get_keywords(4) ['1008', 'I', 'WOULDN', 'T'] >>> t.get_keywords(5) ['1008', 'I', 'WOULDN', 'T', 'T99'] >>> t.get_keywords(6) ['1008', 'I', 'WOULDN', 'T', 'T99'] >>> t = TextMessage(local_address='1008', body=" i -wouldn't t99") >>> t.get_keywords(3, mode='loose') ['1008', 'I', 'WOULDN'] >>> t.get_keywords(3, mode='normal') ['1008', 'I', "-WOULDN'T"] >>> t.get_keywords(3, mode='strict') ['1008', '', 'I'] >>> t = TextMessage(local_address='1008', body="\\n\\r\\t\\v pleenty\\tspace\\n\\r\\t\\v ") >>> t.get_keywords(4, mode='loose') ['1008', 'PLEENTY', 'SPACE'] >>> t.get_keywords(4, mode='normal') ['1008', 'PLEENTY', 'SPACE'] >>> t = TextMessage(local_address='1008 STOP', body="stop") >>> t.get_keywords(3) ['1008', 'STOP'] >>> t = TextMessage(local_address='1008', body="sToP!!.@") >>> t.get_keywords(3) ['1008', 'STOP'] Test the non-loose (sloppy) modes. >>> t = TextMessage(local_address='1008 X', body="i -wouldn't t99 ") >>> t.get_keywords(0, mode='normal'), t.get_keywords(0, mode='strict') ([], []) >>> t.get_keywords(1, mode='normal'), t.get_keywords(1, mode='strict') (['1008'], ['1008']) >>> t.get_keywords(3, mode='normal'), t.get_keywords(3, mode='strict') (['1008', 'I', "-WOULDN'T"], ['1008', 'I', "-WOULDN'T"]) >>> t.get_keywords(4, mode='normal') ['1008', 'I', "-WOULDN'T", 'T99'] >>> t.get_keywords(4, mode='strict') ['1008', 'I', "-WOULDN'T", ''] >>> t.get_keywords(9, mode='normal') ['1008', 'I', "-WOULDN'T", 'T99'] >>> t.get_keywords(9, mode='strict') ['1008', 'I', "-WOULDN'T", '', 'T99', ''] >>> t = TextMessage(local_address='1008', body="sToP!!.@") >>> t.get_keywords(3, mode='normal'), t.get_keywords(3, mode='strict') (['1008', 'STOP!!.@'], ['1008', 'STOP!!.@']) ''' assert mode in ('loose', 'normal', 'strict') assert max_keywords >= 0 if max_keywords == 0: return [] shortcode = self.local_address.split(' ')[0] if max_keywords == 1: return [shortcode] if mode == 'strict': tmp = self.body.split(' ', max_keywords - 1)[0:(max_keywords - 1)] return [shortcode] + [i.upper() for i in tmp] split_re = (NON_ALNUM_RE, BLANK_RE)[mode == 'normal'] return ( [shortcode] + [i.upper() for i in split_re.split(self.body) if i != ''])[0:max_keywords] @property def is_inbound(self): return not self.is_outbound @property def is_outbound(self): return self.status not in ('in', 'rd') def send(self, reply_to=None, shortcode_keyword=None, tariff_cent=None, fail_silently=False): ''' Send out the message. Don't call this unless you're prepared to wait a while and prepared to handle errors. See the sms management command for a sample sms sending cron job. ''' # XXX: hope that we can obsolete reply_to, shortcode_keyword, # tariff_cent and have everyone use TextMessageExtra for that # purpose. assert self.pk is not None, 'Attempting to a send a message without a primary key.' assert self.status == 'out', 'Attempting to a send a message that is not in state outbound-unsent.' if not self.remote_address: if fail_silently: return 0 else: raise DestinationError('Empty remote address') return self.get_connection(fail_silently).send_messages( [self], reply_to=reply_to, shortcode_keyword=shortcode_keyword, tariff_cent=tariff_cent) def __repr__(self): return 'TextMessage(id=%d)' % self.id def __str__(self): if self.status in ('in', 'rd'): return 'Inbound SMS from %s at %s' % ( self.remote_address, self.created.strftime('%Y-%m-%d %H:%M')) else: return 'Outbound SMS to %s at %s' % ( self.remote_address, self.created.strftime('%Y-%m-%d %H:%M')) class Meta: ordering = ('-id', 'remote_address') permissions = (('view_textmessagestatus', 'Can view textmessage status'), )
class Relation(Model): ''' A relation model: this can be a customer company, a business partner, a private person and even yourself. The owner is an optional parent to allow for recursive relationships. The name is freeform. The code is an optional code you use to identify your relation by. The foreign code is an optional code the relation uses to identify you by. >>> from osso.relation.models import Relation, Country, City, Address, AddressType >>> osso, osso_is_new = Relation.objects.get_or_create( ... owner=None, name=u'OSSO B.V.', code=u'', foreign_code=u'') >>> gntel, gntel_is_new = Relation.objects.get_or_create( ... owner=osso, name=u'gnTel B.V.', code=u'006', foreign_code=u'040055') >>> nl, is_new = Country.objects.get_or_create(code='nl', defaults={'name': 'NL'}) >>> groningen, groningen_is_new = City.objects.get_or_create( ... country=nl, name=u'Groningen') >>> mediacentrale, is_new = Address.objects.get_or_create( ... relation=gntel, ... number=294, ... complement=u'A', ... street=u'Helperpark', ... city=groningen ... ) >>> at, is_new = AddressType.objects.get_or_create(identifier='POSTAL') >>> mediacentrale.address_type.add(*AddressType.objects.exclude(identifier='OTHER')) >>> gntel.postal_address.street u'Helperpark' >>> int(gntel.postal_address.number) 294 Assert that nameless relations get their pk as name. >>> nameless = Relation.objects.create(owner=gntel, code=u'123') >>> nameless.name == u'relation%d' % nameless.id True >>> nameless.code u'123' Clean up after ourself. >>> nameless.delete() >>> if gntel_is_new: gntel.delete() >>> if osso_is_new: osso.delete() >>> if groningen_is_new: groningen.delete() ''' owner = ParentField(_('owner'), related_name='owned_set', help_text=_( 'This allows for reseller-style relationships. ' 'Set to NULL for the system owner.')) name = SafeCharField(_('name'), max_length=63, help_text=_( 'The relation name: a company name or a person ' 'name in case of a private person.')) code = SafeCharField( _('code'), max_length=16, blank=True, help_text= _('A human readable short relation identifier; should be unique per owner.' )) foreign_code = SafeCharField( _('foreign code'), max_length=16, blank=True, help_text= _('A human readable identifier that the relation uses to identify you by.' )) objects = RelationManager() @property def billing_address(self): """Return the billing address for this relation.""" return car( self.address_set.filter( address_type__identifier='BILLING').order_by('created')) @property def delivery_address(self): """Return the delivery address for this relation.""" return car( self.address_set.filter( address_type__identifier='DELIVERY').order_by('created')) @property def postal_address(self): """Return the postal address for this relation.""" return car( self.address_set.filter( address_type__identifier='POSTAL').order_by('created')) @property def visiting_address(self): """Return the visiting address for this relation.""" return car( self.address_set.filter( address_type__identifier='VISITING').order_by('created')) def is_descendant_of(self, relation, include=False): ''' Whether this is a child or grandchild of relation. Currently does N queries for N levels of grandparents. Beware! ''' parent = (self.owner, self)[include] while parent and parent != relation: parent = parent.owner return parent is not None def save(self, *args, **kwargs): """Save the model to the database.""" ParentField.check(self, 'owner') super(Relation, self).save(*args, **kwargs) if self.name == '': self.name = 'relation%d' % self.id super(Relation, self).save(force_update=True) def __unicode__(self): if self.code == '': return self.name return u'%s - %s' % (self.code, self.name) class Meta: """Django metaclass information.""" ordering = ('name', ) permissions = (('view_relation', 'Can view relation'), ) verbose_name = _('relation') verbose_name_plural = _('relations')
class Channel(Model): ''' A channel that holds a set of chat messages and binds them to a particular relation (company). ''' relation = models.ForeignKey( Relation, help_text=_('The relation whose authenticated contacts can read/' 'write to this channel.')) name = SafeCharField( max_length=32, help_text=_('The name of the channel, e.g. "Operator chat".')) groups = models.ManyToManyField( Group, help_text=_('Users must be a member of one of these groups to ' 'read/write to this channel.')) max_age = models.PositiveIntegerField( default=86400, help_text=_('The max age of the messages (in seconds) that are ' 'kept for this channel. Set to 0 for eternity.')) max_count = models.PositiveIntegerField( default=2000, help_text=_('The max amount of messages that are kept for this ' 'channel. Set to 0 for unlimited.')) def create_message(self, body, sender=None): return Message.objects.create(channel=self, sender=sender, body=body) def prune(self): ''' Make sure the channel gets cleared of old messages. ''' if self.max_age != 0: old = datetime.now() - timedelta(seconds=self.max_age) self.messages.filter(timestamp__lt=old).delete() if self.max_count != 0: count = self.messages.count() if count > self.max_count: # Don't delete all (count - max_count) for two reasons: # (1) If we're in a race condition, it's possible we'll # delete way too many records. # (2) The queries might take too long. # (note, these two cases only happen when someone has # reduced the max_count value recently) limit = min(100, count - self.max_count) # Wrap the queryset in a list because of django-1.1 bug #12328 # http://code.djangoproject.com/ticket/12328 qs = Message.objects.order_by('timestamp')[0:limit] message_ids = list(qs.values_list('id', flat=True)) Message.objects.filter(id__in=message_ids).delete() def get_absolute_url(self): return reverse('userchat_channel', kwargs={'channel_id': self.id}) def __unicode__(self): return repr(self) def __repr__(self): return (u'Channel(name=%s, relation_id=%d)' % (repr(self.name), self.relation.id)) class Meta: ordering = ('id', ) unique_together = ('relation', 'name')