Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
tree: b37573aba3
Fetching contributors…

Cannot retrieve contributors at this time

file 317 lines (253 sloc) 11.376 kb
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317
"""
overseer.models
~~~~~~~~~~~~~~~

A service's status should be:

- red if any(updates affecting service) are red
- yellow if any(updates affecting service) are yellow
- green if all(updates affecting service) are green

:copyright: (c) 2011 DISQUS.
:license: Apache License 2.0, see LICENSE for more details.
"""

import datetime
import oauth2
import urlparse
import uuid
import warnings

from django.core.mail import send_mail
from django.core.urlresolvers import reverse
from django.db import models
from django.db.models.signals import post_save, m2m_changed

from overseer import conf
from overseer.utils import SimpleTwitterClient

STATUS_CHOICES = (
    (0, 'No Problems'),
    (1, 'Some Issues'),
    (2, 'Unavailable'),
)

SUBSCRIPTION_EMAIL_TEMPLATE = """
A service's status has changed on %(name)s:

%(message)s

This update affects the following:

%(affects)s

----

To change your subscription settings, please visit %(sub_url)s

""".strip()

class Service(models.Model):
    """
A ``Service`` can describe any part of your architecture. Each
service can have many events, in which the last event should be shown
(unless the status is 'No Problems').
"""
    name = models.CharField(max_length=128)
    slug = models.SlugField(max_length=128, unique=True)
    description = models.TextField(blank=True, null=True)
    status = models.SmallIntegerField(choices=STATUS_CHOICES, editable=False, default=0)
    order = models.IntegerField(default=0)
    date_created = models.DateTimeField(default=datetime.datetime.now, editable=False)
    date_updated = models.DateTimeField(default=datetime.datetime.now, editable=False)
    
    class Meta:
        ordering = ('order', 'name')

    def __unicode__(self):
        return self.name

    @models.permalink
    def get_absolute_url(self):
        return ('overseer:service', [self.slug], {})

    @classmethod
    def handle_event_m2m_save(cls, sender, instance, action, reverse, model, pk_set, **kwargs):
        if not action.startswith('post_'):
            return
        if not pk_set:
            return
        
        if model is Service:
            for service in Service.objects.filter(pk__in=pk_set):
                service.update_from_event(instance)
        else:
            for event in Event.objects.filter(pk__in=pk_set):
                instance.update_from_event(event)

    @classmethod
    def handle_event_save(cls, instance, **kwargs):
        for service in instance.services.all():
            service.update_from_event(instance)

    def update_from_event(self, event):
        update_qs = Service.objects.filter(pk=self.pk)
        if event.date_updated > self.date_updated:
            # If the update is newer than the last update to the self
            update_qs.filter(date_updated__lt=event.date_updated)\
                     .update(date_updated=event.date_updated)
            self.date_updated = event.date_updated

        if event.status > self.status:
            # If our status more critical (higher) than the current
            # self status, update to match the current
            update_qs.filter(status__lt=event.status)\
                     .update(status=event.status)
            self.status = event.status

        elif event.status < self.status:
            # If no more events match the current self status, let's update
            # it to the current status
            if not Event.objects.filter(services=self, status=self.status)\
                                .exclude(pk=event.pk).exists():
                update_qs.filter(status__gt=event.status)\
                         .update(status=event.status)
                self.status = event.status

    def get_message(self):
        if self.status == 0:
            return 'This service is operating as expected.'
        elif self.status == 1:
            return 'This service is experiencing some issues.'
        elif self.status == 2:
            return 'This service may be unavailable.'
        return ''

def join_with_and(values):
    values = list(values)
    if len(values) == 2:
        return ' and '.join(values)
    elif len(values) > 2:
        return '%s, and %s' % (', '.join(values[:-1]), values[-1])
    return values[0]

class EventBase(models.Model):
    class Meta:
        abstract = True

    def get_message(self):
        if self.message:
            return self.message
        elif self.status == 0:
            return '%s operating as expected.' % join_with_and(a[1] for a in self.get_services())
        elif self.status == 1:
            return 'Experiencing some issues with %s.' % join_with_and(a[1] for a in self.get_services())
        elif self.status == 2:
            return '%s may be unavailable.' % join_with_and(a[1] for a in self.get_services())
        return ''

class Event(EventBase):
    """
An ``Event`` is a collection of updates related to one event.
- ``message`` stores the last message from ``StatusUpdate`` for this event.
"""
    services = models.ManyToManyField(Service)
    status = models.SmallIntegerField(choices=STATUS_CHOICES, editable=False, default=0)
    peak_status = models.SmallIntegerField(choices=STATUS_CHOICES, editable=False, default=0)
    description = models.TextField(null=True, blank=True, help_text='We will auto fill the description from the first event message if not set')
    message = models.TextField(null=True, blank=True, editable=False)
    date_created = models.DateTimeField(default=datetime.datetime.now, editable=False)
    date_updated = models.DateTimeField(default=datetime.datetime.now, editable=False)

    def __unicode__(self):
        return u"%s on %s" % (self.date_created, '; '.join(self.services.values_list('name', flat=True)))

    @models.permalink
    def get_absolute_url(self):
        return ('overseer:event', [self.pk], {})

    def get_services(self):
        return self.services.values_list('slug', 'name')

    def get_duration(self):
        return self.date_updated - self.date_created

    def post_to_twitter(self, message=None):
        """Update twitter status, i.e., post a tweet"""

        consumer = oauth2.Consumer(key=conf.TWITTER_CONSUMER_KEY,
                                  secret=conf.TWITTER_CONSUMER_SECRET)
        token = oauth2.Token(key=conf.TWITTER_ACCESS_TOKEN, secret=conf.TWITTER_ACCESS_SECRET)
        client = SimpleTwitterClient(consumer=consumer, token=token)

        if not message:
            message = self.get_message()
        
        hash_tag = '#status'
        
        if conf.BASE_URL:
            permalink = urlparse.urljoin(conf.BASE_URL, reverse('overseer:event_short', args=[self.pk]))
            if len(message) + len(permalink) + len(hash_tag) > 138:
                message = '%s.. %s %s' % (message[:140-4-len(hash_tag)-len(permalink)], permalink, hash_tag)
            else:
                message = '%s %s %s' % (message, permalink, hash_tag)
        else:
            if len(message) + len(hash_tag) > 139:
                message = '%s.. %s' % (message[:140-3-len(hash_tag)], hash_tag)
            else:
                message = '%s %s' % (message, hash_tag)
                
        return client.update_status(message)

    @classmethod
    def handle_update_save(cls, instance, created, **kwargs):
        event = instance.event

        if created:
            is_latest = True
        elif EventUpdate.objects.filter(event=event).order_by('-date_created')\
                                .values_list('event', flat=True)[0] == event.pk:
            is_latest = True
        else:
            is_latest = False

        if is_latest:
            update_kwargs = dict(
                status=instance.status,
                date_updated=instance.date_created,
                message=instance.message
            )

            if not event.description:
                update_kwargs['description'] = instance.message
                
            if not event.peak_status or event.peak_status < instance.status:
                update_kwargs['peak_status'] = instance.status

            Event.objects.filter(pk=event.pk).update(**update_kwargs)

            for k, v in update_kwargs.iteritems():
                setattr(event, k, v)

            # Without sending the signal Service will fail to update
            post_save.send(sender=Event, instance=event, created=False)

class EventUpdate(EventBase):
    """
An ``EventUpdate`` contains a single update to an ``Event``. The latest update
will always be reflected within the event, carrying over it's ``status`` and ``message``.
"""
    event = models.ForeignKey(Event)
    status = models.SmallIntegerField(choices=STATUS_CHOICES)
    message = models.TextField(null=True, blank=True)
    date_created = models.DateTimeField(default=datetime.datetime.now, editable=False)

    def __unicode__(self):
        return unicode(self.date_created)

    def get_services(self):
        return self.event.services.values_list('slug', 'name')

class BaseSubscription(models.Model):
    ident = models.CharField(max_length=32, unique=True)
    date_created = models.DateTimeField(default=datetime.datetime.now, editable=False)
    services = models.ManyToManyField(Service)

    class Meta:
        abstract = True

    def __unicode__(self):
        return self.email

    def save(self, *args, **kwargs):
        if not self.ident:
            self.ident = uuid.uuid4().hex
        super(BaseSubscription, self).save(*args, **kwargs)

class Subscription(BaseSubscription):
    """
Represents an email subscription.
"""
    email = models.EmailField(unique=True)

    @classmethod
    def handle_update_save(cls, instance, created, **kwargs):
        if not created:
            return

        if not conf.ALLOW_SUBSCRIPTIONS:
            return

        if not conf.FROM_EMAIL:
            # TODO: grab system default
            warnings.warn('Configuration error with Oveerseer: FROM_EMAIL is not set')
            return
        
        if not conf.BASE_URL:
            warnings.warn('Configuration error with Oveerseer: BASE_URL is not set')
            return
        
        services = list(instance.event.services.all())
        affects = '\n'.join('- %s' % s.name for s in services)
        message = instance.get_message()
        
        for email, ident in cls.objects.filter(services__in=services)\
                                       .values_list('email', 'ident')\
                                       .distinct():
            # send email
            body = SUBSCRIPTION_EMAIL_TEMPLATE % dict(
                sub_url = urlparse.urljoin(conf.BASE_URL, reverse('overseer:update_subscription', args=[ident])),
                message = message,
                affects = affects,
                name = conf.NAME,
            )
            send_mail('Status Change on %s' % conf.NAME, body, conf.FROM_EMAIL, [email],
                      fail_silently=True)

class UnverifiedSubscription(BaseSubscription):
    """
A temporary store for unverified subscriptions.
"""
    email = models.EmailField()

post_save.connect(Service.handle_event_save, sender=Event)
post_save.connect(Event.handle_update_save, sender=EventUpdate)
m2m_changed.connect(Service.handle_event_m2m_save, sender=Event.services.through)
post_save.connect(Subscription.handle_update_save, sender=EventUpdate)
Something went wrong with that request. Please try again.