├── example_project ├── __init__.py ├── manage.py ├── urls.py └── settings.py ├── overseer ├── migrations │ ├── __init__.py │ ├── 0001_initial.py │ └── 0002_auto__add_subscription__add_unverifiedsubscription.py ├── media │ ├── images │ │ ├── red_light_16x16.png │ │ ├── red_light_32x32.png │ │ ├── green_light_16x16.png │ │ ├── green_light_32x32.png │ │ ├── red_light_128x128.png │ │ ├── green_light_128x128.png │ │ ├── yellow_light_128x128.png │ │ ├── yellow_light_16x16.png │ │ └── yellow_light_32x32.png │ └── css │ │ └── base.css ├── templates │ └── overseer │ │ ├── 500.html │ │ ├── 404.html │ │ ├── unsubscribe_confirmed.html │ │ ├── invalid_subscription_token.html │ │ ├── create_subscription_complete.html │ │ ├── subscription_confirmed.html │ │ ├── update_subscription.html │ │ ├── event.html │ │ ├── create_subscription.html │ │ ├── service.html │ │ ├── base.html │ │ └── index.html ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── overseer_twitter_auth.py ├── templatetags │ ├── __init__.py │ └── overseer_helpers.py ├── tests │ ├── __init__.py │ ├── urls.py │ └── tests.py ├── __init__.py ├── context_processors.py ├── conf.py ├── urls.py ├── forms.py ├── admin.py ├── utils.py ├── views.py └── models.py ├── .gitignore ├── MANIFEST.in ├── docs ├── admin.rst ├── index.rst ├── setup.rst ├── config.rst ├── Makefile ├── make.bat └── conf.py ├── CHANGES ├── README.rst ├── runtests.py ├── setup.py └── LICENSE /example_project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /overseer/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.sqlite 3 | *.egg 4 | *.egg-info/ 5 | /build 6 | /dist 7 | local_settings.py 8 | /docs/_build -------------------------------------------------------------------------------- /overseer/media/images/red_light_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/disqus/overseer/HEAD/overseer/media/images/red_light_16x16.png -------------------------------------------------------------------------------- /overseer/media/images/red_light_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/disqus/overseer/HEAD/overseer/media/images/red_light_32x32.png -------------------------------------------------------------------------------- /overseer/media/images/green_light_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/disqus/overseer/HEAD/overseer/media/images/green_light_16x16.png -------------------------------------------------------------------------------- /overseer/media/images/green_light_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/disqus/overseer/HEAD/overseer/media/images/green_light_32x32.png -------------------------------------------------------------------------------- /overseer/media/images/red_light_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/disqus/overseer/HEAD/overseer/media/images/red_light_128x128.png -------------------------------------------------------------------------------- /overseer/media/images/green_light_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/disqus/overseer/HEAD/overseer/media/images/green_light_128x128.png -------------------------------------------------------------------------------- /overseer/media/images/yellow_light_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/disqus/overseer/HEAD/overseer/media/images/yellow_light_128x128.png -------------------------------------------------------------------------------- /overseer/media/images/yellow_light_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/disqus/overseer/HEAD/overseer/media/images/yellow_light_16x16.png -------------------------------------------------------------------------------- /overseer/media/images/yellow_light_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/disqus/overseer/HEAD/overseer/media/images/yellow_light_32x32.png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include setup.py README MANIFEST.in LICENSE 2 | recursive-include overseer/templates * 3 | recursive-include overseer/media * 4 | global-exclude *~ -------------------------------------------------------------------------------- /docs/admin.rst: -------------------------------------------------------------------------------- 1 | Administration 2 | ============== 3 | 4 | As of the current version, the only way to administer the application is via the ``django.contrib.admin`` integration. -------------------------------------------------------------------------------- /overseer/templates/overseer/500.html: -------------------------------------------------------------------------------- 1 | {% extends "overseer/base.html" %} 2 | 3 | {% block content %} 4 |

Error

5 |

Something went wrong... :(

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /overseer/management/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.management 3 | ~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | -------------------------------------------------------------------------------- /overseer/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.templatetags 3 | ~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | -------------------------------------------------------------------------------- /overseer/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.tests 3 | ~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | from tests import * -------------------------------------------------------------------------------- /overseer/templates/overseer/404.html: -------------------------------------------------------------------------------- 1 | {% extends "overseer/base.html" %} 2 | 3 | {% block content %} 4 |

Not Found

5 |

Whatever you expected to be here, isn't here.

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /overseer/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.management.commands 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | 0.2.2 2 | 3 | * Fixed an issue that would cause subscribers to receive multiple emails on a single subscription. 4 | 5 | 0.2.1 6 | 7 | * Fixed an issue that would cause short status URL redirects to error on some webservers. -------------------------------------------------------------------------------- /overseer/templates/overseer/unsubscribe_confirmed.html: -------------------------------------------------------------------------------- 1 | {% extends "overseer/base.html" %} 2 | 3 | {% load overseer_helpers %} 4 | 5 | {% block content %} 6 |

Unsubscribed

7 | 8 |

You will no longer receive updates at {{ email }}.

9 | 10 | {% endblock %} -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | -------- 2 | Overseer 3 | -------- 4 | 5 | Overseer is a simple status board app written in Django. 6 | 7 | Docs: http://readthedocs.org/docs/overseer/en/latest/index.html 8 | 9 | .. image:: http://f.cl.ly/items/2y3J0h0w2M3h3E322A1V/Screen%20shot%202011-01-21%20at%202.19.52%20PM.png 10 | -------------------------------------------------------------------------------- /overseer/templates/overseer/invalid_subscription_token.html: -------------------------------------------------------------------------------- 1 | {% extends "overseer/base.html" %} 2 | 3 | {% load overseer_helpers %} 4 | 5 | {% block content %} 6 |

Error

7 | 8 |

The subscription token for your request is not valid. Maybe you already confirmed your subscription?

9 | 10 | {% endblock %} -------------------------------------------------------------------------------- /overseer/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer 3 | ~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | try: 10 | VERSION = __import__('pkg_resources') \ 11 | .get_distribution('Overseer').version 12 | except Exception, e: 13 | VERSION = 'unknown' 14 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Overseer 2 | ======== 3 | 4 | Overseer is a simple status board app written in Django. 5 | 6 | For a live example, see http://status.disqus.com 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | 11 | setup 12 | config 13 | admin 14 | 15 | .. image:: http://f.cl.ly/items/2y3J0h0w2M3h3E322A1V/Screen%20shot%202011-01-21%20at%202.19.52%20PM.png -------------------------------------------------------------------------------- /overseer/templates/overseer/create_subscription_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "overseer/base.html" %} 2 | 3 | {% load overseer_helpers %} 4 | 5 | {% block content %} 6 |

Confirm Subscription

7 | 8 |

We have sent an email to {{ subscription.email }} to confirm your subscription. You must follow the instructions in order to activate email notifications.

9 | 10 | {% endblock %} -------------------------------------------------------------------------------- /overseer/templates/overseer/subscription_confirmed.html: -------------------------------------------------------------------------------- 1 | {% extends "overseer/base.html" %} 2 | 3 | {% load overseer_helpers %} 4 | 5 | {% block content %} 6 |

Subscription Confirmed

7 | 8 |

We have verified your subscription settings and you will now receive updates on the following at {{ subscription.email }}:

9 | 10 | 15 | 16 | {% endblock %} -------------------------------------------------------------------------------- /overseer/context_processors.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.context_processors 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | from django.core.urlresolvers import reverse 10 | 11 | import overseer 12 | from overseer import conf 13 | 14 | def default(request): 15 | return { 16 | 'request': request, 17 | 'OVERSEER_TITLE': conf.TITLE, 18 | 'OVERSEER_NAME': conf.NAME, 19 | 'OVERSEER_MEDIA_PREFIX': (conf.MEDIA_PREFIX or reverse('overseer:media')).rstrip('/'), 20 | 'OVERSEER_VERSION': overseer.VERSION, 21 | 'OVERSEER_ALLOW_SUBSCRIPTIONS': conf.ALLOW_SUBSCRIPTIONS, 22 | } -------------------------------------------------------------------------------- /example_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from django.core.management import execute_manager 3 | try: 4 | import settings # Assumed to be in the same directory. 5 | except ImportError, exc: 6 | import sys 7 | import traceback 8 | sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) 9 | sys.stderr.write("\nFor debugging purposes, the exception was:\n\n") 10 | traceback.print_exc() 11 | sys.exit(1) 12 | 13 | if __name__ == "__main__": 14 | execute_manager(settings) 15 | -------------------------------------------------------------------------------- /overseer/conf.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.conf 3 | ~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | from django.conf import settings 10 | 11 | base = getattr(settings, 'OVERSEER_CONFIG', {}) 12 | 13 | TITLE = base.get('TITLE', 'Service Status') 14 | NAME = base.get('NAME', 'Service Status') 15 | MEDIA_PREFIX = base.get('MEDIA_PREFIX', None) 16 | 17 | TWITTER_CONSUMER_KEY = base.get('TWITTER_CONSUMER_KEY') 18 | TWITTER_CONSUMER_SECRET = base.get('TWITTER_CONSUMER_SECRET') 19 | 20 | # Run manage.py overseer_twitter_auth to generate an access key 21 | TWITTER_ACCESS_TOKEN = base.get('TWITTER_ACCESS_TOKEN') 22 | TWITTER_ACCESS_SECRET = base.get('TWITTER_ACCESS_SECRET') 23 | 24 | BASE_URL = base.get('BASE_URL') 25 | 26 | ALLOW_SUBSCRIPTIONS = base.get('ALLOW_SUBSCRIPTIONS', False) 27 | 28 | FROM_EMAIL = base.get('FROM_EMAIL') -------------------------------------------------------------------------------- /overseer/templates/overseer/update_subscription.html: -------------------------------------------------------------------------------- 1 | {% extends "overseer/base.html" %} 2 | 3 | {% load overseer_helpers %} 4 | 5 | {% block content %} 6 |

Subscription Settings

7 | 8 |
9 | 10 | {% csrf_token %} 11 | 12 |

Choose the services you wish to receive updates for:

13 | 14 |
15 | 18 | {{ form.services }} 19 |
20 | {% if form.services.errors %} 21 | {{ form.services.errors }} 22 | {% endif %} 23 | 24 | 25 | 26 |
27 | 28 | {% endblock %} -------------------------------------------------------------------------------- /example_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | 3 | from django.contrib import admin 4 | 5 | admin.autodiscover() 6 | 7 | def handler500(request): 8 | """ 9 | 500 error handler. 10 | 11 | Templates: `500.html` 12 | Context: None 13 | """ 14 | from django.template import Context, loader 15 | from django.http import HttpResponseServerError 16 | from overseer.context_processors import default 17 | import logging 18 | import sys 19 | try: 20 | context = default(request) 21 | except Exception, e: 22 | logging.error(e, exc_info=sys.exc_info(), extra={'request': request}) 23 | context = {} 24 | 25 | context['request'] = request 26 | 27 | t = loader.get_template('500.html') # You need to create a 500.html template. 28 | return HttpResponseServerError(t.render(Context(context))) 29 | 30 | urlpatterns = patterns('', 31 | url(r'^admin/', include(admin.site.urls)), 32 | url(r'^', include('overseer.urls', namespace='overseer')), 33 | ) -------------------------------------------------------------------------------- /docs/setup.rst: -------------------------------------------------------------------------------- 1 | Setup 2 | ===== 3 | 4 | If you haven't already, start by downloading Overseer. The easiest way is with *pip*:: 5 | 6 | pip install overseer --upgrade 7 | 8 | Or with *setuptools*:: 9 | 10 | easy_install -U overseer 11 | 12 | Once installed, you're going to need to configure a basic Django project. You can either use Overseer within your existing project, or 13 | base it on the provided shell in ``example_project``. 14 | 15 | Existing Project 16 | ---------------- 17 | 18 | To use it within an existing project, adjust the following in ``settings.py``:: 19 | 20 | INSTALLED_APPS = ( 21 | ... 22 | 'overseer', 23 | ) 24 | 25 | You'll also need to include the appropriate ``urls.py``:: 26 | 27 | urlpatterns = patterns('', 28 | (r'^status', include('overseer.urls', namespace='overseer')), 29 | ) 30 | 31 | New Project 32 | ----------- 33 | 34 | Simple copy the ``example_project`` directory included with the package, and adjust ``settings.py` as needed. 35 | 36 | You may now continue to :doc:`config` for configuration options. -------------------------------------------------------------------------------- /overseer/templates/overseer/event.html: -------------------------------------------------------------------------------- 1 | {% extends "overseer/base.html" %} 2 | 3 | {% load overseer_helpers %} 4 | 5 | {% block content %} 6 |

Event Details

7 | 8 |
9 |

{{ event.get_message }}

10 | 11 | affects {% for slug, name in event.get_services %}{{ name }}{% if not forloop.last %}, {% endif %}{% endfor %} 12 |
13 | 14 |
15 |

Updates (newest first)

16 | 24 |
25 | {% endblock %} -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | runtests 4 | ~~~~~~~~ 5 | 6 | :copyright: (c) 2011 DISQUS. 7 | :license: Apache License 2.0, see LICENSE for more details. 8 | """ 9 | 10 | import sys 11 | from os.path import dirname, abspath 12 | 13 | from django.conf import settings 14 | 15 | if not settings.configured: 16 | settings.configure( 17 | DATABASE_ENGINE='sqlite3', 18 | INSTALLED_APPS=[ 19 | 'django.contrib.auth', 20 | 'django.contrib.admin', 21 | 'django.contrib.contenttypes', 22 | 'django.contrib.sessions', 23 | 'django.contrib.sites', 24 | 25 | 'overseer', 26 | ], 27 | ROOT_URLCONF='', 28 | DEBUG=False, 29 | ) 30 | 31 | from django.test.simple import run_tests 32 | 33 | def runtests(*test_args): 34 | if not test_args: 35 | test_args = ['overseer'] 36 | parent = dirname(abspath(__file__)) 37 | sys.path.insert(0, parent) 38 | failures = run_tests(test_args, verbosity=1, interactive=True) 39 | sys.exit(failures) 40 | 41 | if __name__ == '__main__': 42 | runtests(*sys.argv[1:]) -------------------------------------------------------------------------------- /example_project/settings.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | OVERSEER_ROOT = os.path.dirname(__import__('overseer').__file__) 4 | 5 | DEBUG = True 6 | 7 | DATABASE_ENGINE = 'sqlite3' 8 | DATABASE_NAME = 'overseer.sqlite' 9 | 10 | INSTALLED_APPS = ( 11 | 'django.contrib.auth', 12 | 'django.contrib.admin', 13 | 'django.contrib.contenttypes', 14 | 'django.contrib.sessions', 15 | 'django.contrib.sites', 16 | 17 | 'devserver', 18 | 'overseer', 19 | 'south', 20 | ) 21 | 22 | ADMIN_MEDIA_PREFIX = '/admin/media/' 23 | 24 | ROOT_URLCONF = 'urls' 25 | 26 | DEVSERVER_MODULES = () 27 | 28 | TEMPLATE_DIRS = ( 29 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 30 | # Always use forward slashes, even on Windows. 31 | # Don't forget to use absolute paths, not relative paths. 32 | os.path.join(OVERSEER_ROOT, 'templates', 'overseer'), 33 | ) 34 | 35 | TEMPLATE_CONTEXT_PROCESSORS = ( 36 | 'django.contrib.auth.context_processors.auth', 37 | 'overseer.context_processors.default', 38 | ) 39 | 40 | try: 41 | from local_settings import * 42 | except ImportError: 43 | pass -------------------------------------------------------------------------------- /overseer/tests/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.tests.urls 3 | ~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | from django.conf.urls.defaults import * 10 | 11 | from django.contrib import admin 12 | 13 | admin.autodiscover() 14 | 15 | def handler500(request): 16 | """ 17 | 500 error handler. 18 | 19 | Templates: `500.html` 20 | Context: None 21 | """ 22 | from django.template import Context, loader 23 | from django.http import HttpResponseServerError 24 | from overseer.context_processors import default 25 | import logging 26 | import sys 27 | try: 28 | context = default(request) 29 | except Exception, e: 30 | logging.error(e, exc_info=sys.exc_info(), extra={'request': request}) 31 | context = {} 32 | 33 | context['request'] = request 34 | 35 | t = loader.get_template('500.html') # You need to create a 500.html template. 36 | return HttpResponseServerError(t.render(Context(context))) 37 | 38 | urlpatterns = patterns('', 39 | url(r'^admin/', include(admin.site.urls)), 40 | url(r'^', include('overseer.urls', namespace='overseer')), 41 | ) -------------------------------------------------------------------------------- /overseer/templates/overseer/create_subscription.html: -------------------------------------------------------------------------------- 1 | {% extends "overseer/base.html" %} 2 | 3 | {% load overseer_helpers %} 4 | 5 | {% block content %} 6 |

Subscription Settings

7 | 8 |
9 | 10 | {% csrf_token %} 11 | 12 | {% if form.errors %} 13 |
14 |

There were some errors processing your subscription.

15 |
16 | {% endif %} 17 | 18 |

Tell us where you'd like to receive updates:

19 | 20 |

{{ form.email }}

21 | {% if form.email.errors %} 22 | {{ form.email.errors }} 23 | {% endif %} 24 | 25 |

Choose the services you wish to receive updates for:

26 | 27 |
28 | {{ form.services }} 29 |
30 | {% if form.services.errors %} 31 | {{ form.services.errors }} 32 | {% endif %} 33 | 34 | 35 | 36 |
37 | 38 | {% endblock %} -------------------------------------------------------------------------------- /overseer/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.urls 3 | ~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | from django.conf.urls.defaults import * 10 | 11 | import os.path 12 | 13 | urlpatterns = patterns('', 14 | url(r'^media/(?P.+)?$', 'django.views.static.serve', { 15 | 'document_root': os.path.join(os.path.dirname(__file__), 'media'), 16 | 'show_indexes': True 17 | }, name='media'), 18 | 19 | url(r'^$', 'overseer.views.index', name='index'), 20 | url(r'^service/(?P[^/]+)/$', 'overseer.views.service', name='service'), 21 | url(r'^service/(?P[^/]+)/last-event/$', 'overseer.views.last_event', name='last_event'), 22 | url(r'^event/(?P[^/]+)/$', 'overseer.views.event', name='event'), 23 | url(r'^(?P\d+)$', 'django.views.generic.simple.redirect_to', {'url': 'event/%(id)s/'}, name='event_short'), 24 | url(r'^subscribe/$', 'overseer.views.create_subscription', name='create_subscription'), 25 | url(r'^subscription/(?P[^/]+)/$', 'overseer.views.update_subscription', name='update_subscription'), 26 | url(r'^subscription/(?P[^/]+)/verify/$', 'overseer.views.verify_subscription', name='verify_subscription'), 27 | ) -------------------------------------------------------------------------------- /overseer/forms.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.forms 3 | ~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | from django import forms 10 | 11 | from overseer.models import Service, Subscription, UnverifiedSubscription 12 | 13 | class BaseSubscriptionForm(forms.ModelForm): 14 | services = forms.ModelMultipleChoiceField(queryset=Service.objects.all(), widget=forms.CheckboxSelectMultiple()) 15 | 16 | class Meta: 17 | fields = ('services',) 18 | model = Subscription 19 | 20 | class NewSubscriptionForm(BaseSubscriptionForm): 21 | email = forms.EmailField(widget=forms.TextInput(attrs={'placeholder': 'you@example.com'})) 22 | 23 | class Meta: 24 | fields = ('email', 'services',) 25 | model = UnverifiedSubscription 26 | 27 | def clean_email(self): 28 | value = self.cleaned_data.get('email') 29 | if value: 30 | value = value.lower() 31 | return value 32 | 33 | class UpdateSubscriptionForm(BaseSubscriptionForm): 34 | unsubscribe = forms.BooleanField(required=False) 35 | services = forms.ModelMultipleChoiceField(queryset=Service.objects.all(), widget=forms.CheckboxSelectMultiple(), required=False) 36 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | try: 4 | from setuptools import setup, find_packages 5 | from setuptools.command.test import test 6 | except ImportError: 7 | from ez_setup import use_setuptools 8 | use_setuptools() 9 | from setuptools import setup, find_packages 10 | from setuptools.command.test import test 11 | 12 | 13 | class mytest(test): 14 | def run(self, *args, **kwargs): 15 | from runtests import runtests 16 | runtests() 17 | 18 | setup( 19 | name='Overseer', 20 | version='0.2.2', 21 | author='DISQUS', 22 | author_email='opensource@disqus.com', 23 | url='http://github.com/disqus/overseer', 24 | description = 'A status board built with Django', 25 | packages=find_packages(), 26 | zip_safe=False, 27 | install_requires=[ 28 | 'Django>=1.2.4', 29 | 'South', 30 | 'django-devserver', 31 | 'oauth2>=1.5.169', 32 | 'uuid', 33 | ], 34 | license='Apache License 2.0', 35 | test_suite = 'overseer.tests', 36 | include_package_data=True, 37 | cmdclass={"test": mytest}, 38 | classifiers=[ 39 | 'Framework :: Django', 40 | 'Intended Audience :: Developers', 41 | 'Intended Audience :: System Administrators', 42 | 'Operating System :: OS Independent', 43 | 'Topic :: Software Development' 44 | ], 45 | ) -------------------------------------------------------------------------------- /overseer/templates/overseer/service.html: -------------------------------------------------------------------------------- 1 | {% extends "overseer/base.html" %} 2 | 3 | {% load overseer_helpers %} 4 | 5 | {% block content %} 6 | 7 | 8 | 9 | 10 | 17 | 18 | 19 |
{{ service.get_status_display }} 11 |

{{ service.name }}

12 | {% if service.description %} 13 |

{{ service.description }}

14 | {% endif %} 15 |

{{ service.get_message }}

16 |
20 | 21 |
22 |

Recent Events

23 | {% if event_list %} 24 |
    25 | {% for event in event_list %} 26 |
  • 27 |

    {{ event.description }}

    28 | 29 | affects {% for slug, name in event.get_services %}{{ name }}{% if not forloop.last %}, {% endif %}{% endfor %} 30 |
  • 31 | {% endfor %} 32 |
33 |
34 | {% else %} 35 |

There are no events recorded for this service.

36 | {% endif %} 37 | 38 | {% endblock %} -------------------------------------------------------------------------------- /overseer/templatetags/overseer_helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.templatetags.overseer_helpers 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | import datetime 10 | 11 | from django import template 12 | from django.template.defaultfilters import stringfilter 13 | 14 | register = template.Library() 15 | 16 | @register.filter 17 | def timesince(value): 18 | from django.template.defaultfilters import timesince 19 | if not value: 20 | return 'Never' 21 | if value < datetime.datetime.now() - datetime.timedelta(days=5): 22 | return value.date() 23 | value = (' '.join(timesince(value).split(' ')[0:2])).strip(',') 24 | if value == '0 minutes': 25 | return 'Just now' 26 | if value == '1 day': 27 | return 'Yesterday' 28 | return value + ' ago' 29 | 30 | @register.filter(name='truncatechars') 31 | @stringfilter 32 | def truncatechars(value, arg): 33 | """ 34 | Truncates a string after a certain number of chars. 35 | 36 | Argument: Number of chars to truncate after. 37 | """ 38 | try: 39 | length = int(arg) 40 | except ValueError: # Invalid literal for int(). 41 | return value # Fail silently. 42 | if len(value) > length: 43 | return value[:length] + '...' 44 | return value 45 | truncatechars.is_safe = True 46 | 47 | @register.filter 48 | def duration(value): 49 | if isinstance(value, datetime.timedelta): 50 | value = value.days * 24 * 3600 + value.seconds 51 | hours, minutes, seconds = 0, 0, 0 52 | if value > 3600: 53 | hours = value / 3600 54 | value = value % 3600 55 | if value > 60: 56 | minutes = value / 60 57 | value = value % 60 58 | seconds = value 59 | if hours: 60 | return '%s hours' % (hours,) 61 | if minutes: 62 | return '%s minutes' % (minutes,) 63 | if seconds: 64 | return '%s seconds' % (seconds,) 65 | return 'n/a' -------------------------------------------------------------------------------- /overseer/templates/overseer/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {{ OVERSEER_TITLE }} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 | 29 |
30 | 31 |
32 |
33 |
34 | {% block content %}{% endblock %} 35 | {% if OVERSEER_ALLOW_SUBSCRIPTIONS %} 36 |

Want to get notified when things change? Subscribe to status updates.

37 | {% endif %} 38 |
39 |
40 |
41 | 42 | 47 |
48 | 49 | -------------------------------------------------------------------------------- /overseer/admin.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.admin 3 | ~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | from django import forms 10 | from django.contrib import admin 11 | 12 | from overseer import conf 13 | from overseer.models import Service, Event, EventUpdate, Subscription 14 | 15 | class SubscriptionInline(admin.StackedInline): 16 | model = Subscription.services.through 17 | extra = 1 18 | 19 | class ServiceAdmin(admin.ModelAdmin): 20 | list_display = ('name', 'status', 'order', 'date_updated') 21 | search_fields = ('name', 'description') 22 | prepopulated_fields = {'slug': ('name',)} 23 | inlines = [SubscriptionInline] 24 | 25 | class EventForm(forms.ModelForm): 26 | if conf.TWITTER_ACCESS_TOKEN and conf.TWITTER_ACCESS_SECRET: 27 | post_to_twitter = forms.BooleanField(required=False, label="Post to Twitter", help_text="This will send a tweet with a brief summary, the permalink to the event (if BASE_URL is defined), and the hashtag of #status for EACH update you add below.") 28 | 29 | class Meta: 30 | model = EventUpdate 31 | 32 | class EventUpdateInline(admin.StackedInline): 33 | model = EventUpdate 34 | extra = 1 35 | 36 | class EventAdmin(admin.ModelAdmin): 37 | form = EventForm 38 | list_display = ('date_created', 'description', 'status', 'date_updated') 39 | search_fields = ('description', 'message') 40 | list_filter = ('services',) 41 | inlines = [EventUpdateInline] 42 | 43 | def save_formset(self, request, form, formset, change): 44 | instances = formset.save() 45 | if 'post_to_twitter' in form.cleaned_data and form.cleaned_data['post_to_twitter']: 46 | for obj in instances: 47 | obj.event.post_to_twitter(obj.get_message()) 48 | 49 | class EventUpdateAdmin(admin.ModelAdmin): 50 | list_display = ('date_created', 'message', 'status', 'event') 51 | search_fields = ('message',) 52 | 53 | class SubscriptionAdmin(admin.ModelAdmin): 54 | fields = ('email', 'services',) 55 | 56 | admin.site.register(Service, ServiceAdmin) 57 | admin.site.register(Event, EventAdmin) 58 | admin.site.register(EventUpdate, EventUpdateAdmin) 59 | admin.site.register(Subscription, SubscriptionAdmin) 60 | -------------------------------------------------------------------------------- /overseer/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.utils 3 | ~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | import httplib 10 | import oauth2 11 | 12 | # example client using httplib with headers 13 | class SimpleTwitterClient(oauth2.Client): 14 | 15 | def __init__(self, server='api.twitter.com', port=httplib.HTTP_PORT, request_token_url='', 16 | access_token_url='', authorization_url='', consumer=None, token=None): 17 | self.server = server 18 | self.port = port 19 | self.request_token_url = request_token_url 20 | self.access_token_url = access_token_url 21 | self.authorization_url = authorization_url 22 | self.connection = httplib.HTTPConnection("%s:%d" % (self.server, self.port)) 23 | self.consumer = consumer 24 | self.token = token 25 | 26 | def fetch_request_token(self, oauth_request): 27 | # via headers 28 | # -> OAuthToken 29 | self.connection.request(oauth_request.http_method, self.request_token_url, headers=oauth_request.to_header()) 30 | response = self.connection.getresponse() 31 | return oauth2.Token.from_string(response.read()) 32 | 33 | def fetch_access_token(self, oauth_request): 34 | # via headers 35 | # -> OAuthToken 36 | self.connection.request(oauth_request.http_method, self.access_token_url, headers=oauth_request.to_header()) 37 | response = self.connection.getresponse() 38 | return oauth2.Token.from_string(response.read()) 39 | 40 | def authorize_token(self, oauth_request): 41 | # via url 42 | # -> typically just some okay response 43 | self.connection.request(oauth_request.http_method, oauth_request.to_url()) 44 | response = self.connection.getresponse() 45 | return response.read() 46 | 47 | def update_status(self, status): 48 | # via post body 49 | # -> some protected resources 50 | headers = {'Content-Type' :'application/x-www-form-urlencoded'} 51 | params = { 52 | 'status': status, 53 | } 54 | oauth_request = oauth2.Request.from_consumer_and_token( 55 | consumer=self.consumer, token=self.token, http_method='POST', 56 | http_url='http://%s/1/statuses/update.json' % self.server, parameters=params) 57 | oauth_request.sign_request(oauth2.SignatureMethod_HMAC_SHA1(), self.consumer, self.token) 58 | 59 | self.connection.request('POST', '/1/statuses/update.json', 60 | body=oauth_request.to_postdata(), headers=headers) 61 | response = self.connection.getresponse() 62 | return response, response.read() -------------------------------------------------------------------------------- /overseer/tests/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.tests.tests 3 | ~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | from django.core import mail 10 | from django.test import TestCase 11 | 12 | from overseer import conf 13 | from overseer.models import Service, Event, EventUpdate, Subscription 14 | 15 | class OverseerTestCase(TestCase): 16 | urls = 'overseer.tests.urls' 17 | 18 | def setUp(self): 19 | self.service = Service.objects.create( 20 | name='Test', 21 | slug='test', 22 | ) 23 | conf.FROM_EMAIL = 'foo@example.com' 24 | conf.BASE_URL = 'http://example.com' 25 | 26 | def refresh(self, inst): 27 | return inst.__class__.objects.get(pk=inst.pk) 28 | 29 | def test_subscriptions(self): 30 | conf.ALLOW_SUBSCRIPTIONS = True 31 | 32 | sub = Subscription.objects.create( 33 | email='foo@example.com', 34 | ) 35 | sub.services = [self.service] 36 | 37 | event = Event.objects.create() 38 | event.services.add(self.service) 39 | 40 | EventUpdate.objects.create( 41 | event=event, 42 | status=2, 43 | ) 44 | 45 | self.assertEquals(len(mail.outbox), 1) 46 | 47 | msg = mail.outbox[0] 48 | 49 | self.assertEquals(len(msg.to), 1) 50 | self.assertEquals(msg.to[0], 'foo@example.com') 51 | 52 | body = msg.body 53 | 54 | self.assertTrue('Test may be unavailable' in body) 55 | self.assertTrue('http://example.com/subscription/%s/' % sub.ident in body) 56 | 57 | conf.ALLOW_SUBSCRIPTIONS = False 58 | 59 | def test_cascading_saves(self): 60 | event = Event.objects.create() 61 | event.services.add(self.service) 62 | 63 | service = self.refresh(self.service) 64 | 65 | self.assertEquals(service.status, 0) 66 | self.assertEquals(event.status, 0) 67 | self.assertEquals(service.date_updated, event.date_updated) 68 | 69 | update = EventUpdate.objects.create( 70 | event=event, 71 | status=2, 72 | message='holy omg wtf', 73 | ) 74 | 75 | service = self.refresh(self.service) 76 | event = self.refresh(event) 77 | 78 | self.assertEquals(service.status, update.status) 79 | self.assertEquals(event.status, update.status) 80 | self.assertEquals(event.description, update.message) 81 | self.assertEquals(event.message, update.message) 82 | self.assertEquals(event.date_updated, update.date_created) 83 | self.assertEquals(service.date_updated, update.date_created) 84 | -------------------------------------------------------------------------------- /overseer/templates/overseer/index.html: -------------------------------------------------------------------------------- 1 | {% extends "overseer/base.html" %} 2 | 3 | {% load overseer_helpers %} 4 | 5 | {% block content %} 6 | {% if latest_event %} 7 |
8 |

Latest Event

9 |

{{ latest_event.get_message }}

10 | 11 | affects {% for slug, name in latest_event.get_services %}{{ name }}{% if not forloop.last %}, {% endif %}{% endfor %} 12 |
13 | {% endif %} 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {% for service in service_list %} 26 | 27 | 28 | 34 | 41 | 42 | {% endfor %} 43 | 44 |
StatusServiceLast Event
{{ service.get_status_display }} 29 |

{{ service.name }}

30 | {% if service.description %} 31 |

{{ service.description }}

32 | {% endif %} 33 |
35 | {% if service.date_updated != service.date_created %} 36 | {{ service.date_updated|timesince }} 37 | {% else %} 38 | n/a 39 | {% endif %} 40 |
45 |
46 | 47 | {% if event_list %} 48 |
49 |

Recent Events

50 |
    51 | {% for event in event_list %} 52 |
  • 53 |

    {{ event.get_message }}

    54 | 55 | affects {% for slug, name in event.get_services %}{{ name }}{% if not forloop.last %}, {% endif %}{% endfor %} 56 |
  • 57 | {% endfor %} 58 |
59 |
60 | {% endif %} 61 | {% endblock %} -------------------------------------------------------------------------------- /docs/config.rst: -------------------------------------------------------------------------------- 1 | Config 2 | ====== 3 | 4 | Several configuration variables are available within Overseer. All of these are handled with a single dictionary configuration object:: 5 | 6 | OVERSEER_CONFIG = { 7 | # the title for your page 8 | 'TITLE': 'DISQUS Service Status', 9 | 10 | # the heading text for your page 11 | 'NAME': 'status.disqus.com', 12 | 13 | # the prefix for overseer's media -- by default this is handled using Django's static media server (pre-1.3) 14 | 'MEDIA_PREFIX': '/media/', 15 | 16 | # the base url to overseer -- highly recommended 17 | 'BASE_URL' : 'http://status.disqus.com', 18 | } 19 | 20 | Email Subscriptions 21 | ------------------- 22 | 23 | Allow users to subscribe to email notifications on event updates by configuring the following settings in ``OVERSEER_CONFIG``:: 24 | 25 | OVERSEER_CONFIG = { 26 | # Enable subscriptions 27 | 'ALLOW_SUBSCRIPTIONS': True, 28 | 29 | # Specify an email from address 30 | 'FROM_EMAIL': 'overseer@domain.com', 31 | 32 | # Ensure base url is set 33 | 'BASE_URL': 'http://status.disqus.com', 34 | } 35 | 36 | Twitter Integration 37 | ------------------- 38 | 39 | Enabling Twitter integration a few things, first a foremost, you must register an application via `Twitter `_. You only need to fill out the basics. Don't worry about callback URL, *ensure your Application Type is set to Client*. 40 | 41 | Once you've obtained an API KEY and API SECRET, you'll need to fill in these values in your configuration:: 42 | 43 | OVERSEER_CONFIG = { 44 | 'TWITTER_CONSUMER_KEY': '...', 45 | 'TWITTER_CONSUMER_SECRET': '...', 46 | } 47 | 48 | Now you need to obtain an authenticated access token. To do this you're going to need to obtain a PIN number using OAuth. Overseer includes a manage.py command to obtain these values:: 49 | 50 | python manage.py overseer_twitter_auth 51 | 52 | This will open a new webpage which will ask for authentication to your Twitter account. Click Allow and paste the PIN code given when Overseer asks for it (via the CLI). When finished, you should see something like this:: 53 | 54 | (overseer)[~/Development/overseer/example_project] ./manage.py overseer_twitter_auth 55 | Request Token: 56 | - oauth_token = tSsXTKV8NjKDNU364umDp9OYqv2dTFLWrsVML3T3vU 57 | - oauth_token_secret = uawnL5iieRI7GFOHuBuOACJhGT2WEbvJLMlJhF2E 58 | 59 | We are opening a new browser window to authorize your account 60 | 61 | Enter your PIN number once authorized: 5541487 62 | 63 | Configuration changes: 64 | 65 | 'TWITTER_ACCESS_TOKEN': '241259795-wVnWHaoaI08hbucKhJF1y2CaI4wKvbGyEoF0Ange', 66 | 'TWITTER_ACCESS_SECRET': 'lNkf6CKYPmh3szc1f3ueQdg076facQM3qGKFYzwT2A', 67 | 68 | Add the above values to your OVERSEER_CONFIG setting 69 | 70 | Simply take the values given, and add them to your configuration:: 71 | 72 | OVERSEER_CONFIG = { 73 | 'TWITTER_CONSUMER_KEY': '...', 74 | 'TWITTER_CONSUMER_SECRET': '...', 75 | 'TWITTER_ACCESS_TOKEN': '241259795-wVnWHaoaI08hbucKhJF1y2CaI4wKvbGyEoF0Ange', 76 | 'TWITTER_ACCESS_SECRET': 'lNkf6CKYPmh3szc1f3ueQdg076facQM3qGKFYzwT2A', 77 | } 78 | 79 | You'll see now an option to "Post to Twitter" within the Event administration. While this is selected (it's value does not persist) any changes made to event updates (additions, for example) will also create a Twitter posting with a permalink (assuming BASE_URL is configured) and the #status hash tag. 80 | 81 | -------------------------------------------------------------------------------- /overseer/management/commands/overseer_twitter_auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.management.commands.overseer_twitter_auth 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | from cStringIO import StringIO 10 | 11 | import sys 12 | import webbrowser 13 | 14 | from django.core.management.base import BaseCommand 15 | 16 | from overseer import conf 17 | 18 | class Command(BaseCommand): 19 | def handle(self, **options): 20 | import urlparse 21 | import oauth2 as oauth 22 | 23 | consumer_key = conf.TWITTER_CONSUMER_KEY 24 | consumer_secret = conf.TWITTER_CONSUMER_SECRET 25 | 26 | request_token_url = 'http://twitter.com/oauth/request_token' 27 | access_token_url = 'http://twitter.com/oauth/access_token' 28 | authorize_url = 'http://twitter.com/oauth/authorize' 29 | 30 | consumer = oauth.Consumer(consumer_key, consumer_secret) 31 | client = oauth.Client(consumer) 32 | 33 | # Step 1: Get a request token. This is a temporary token that is used for 34 | # having the user authorize an access token and to sign the request to obtain 35 | # said access token. 36 | 37 | resp, content = client.request(request_token_url, "GET") 38 | if resp['status'] != '200': 39 | raise Exception("Invalid response %s." % resp['status']) 40 | 41 | request_token = dict(urlparse.parse_qsl(content)) 42 | 43 | print "Request Token:" 44 | print " - oauth_token = %s" % request_token['oauth_token'] 45 | print " - oauth_token_secret = %s" % request_token['oauth_token_secret'] 46 | print 47 | 48 | # Step 2: Redirect to the provider. Since this is a CLI script we do not 49 | # redirect. In a web application you would redirect the user to the URL 50 | # below. 51 | 52 | print "We are opening a new browser window to authorize your account" 53 | print 54 | webbrowser.open("%s?oauth_token=%s" % (authorize_url, request_token['oauth_token'])) 55 | print 56 | 57 | # After the user has granted access to you, the consumer, the provider will 58 | # redirect you to whatever URL you have told them to redirect to. You can 59 | # usually define this in the oauth_callback argument as well. 60 | oauth_verifier = raw_input('Enter your PIN number once authorized: ') 61 | 62 | # Step 3: Once the consumer has redirected the user back to the oauth_callback 63 | # URL you can request the access token the user has approved. You use the 64 | # request token to sign this request. After this is done you throw away the 65 | # request token and use the access token returned. You should store this 66 | # access token somewhere safe, like a database, for future use. 67 | token = oauth.Token(request_token['oauth_token'], 68 | request_token['oauth_token_secret']) 69 | token.set_verifier(oauth_verifier) 70 | client = oauth.Client(consumer, token) 71 | 72 | resp, content = client.request(access_token_url, "POST") 73 | access_token = dict(urlparse.parse_qsl(content)) 74 | 75 | print 76 | print "Configuration changes:" 77 | print 78 | print " 'TWITTER_ACCESS_TOKEN': '%s'," % access_token['oauth_token'] 79 | print " 'TWITTER_ACCESS_SECRET': '%s'," % access_token['oauth_token_secret'] 80 | print 81 | print "Add the above values to your OVERSEER_CONFIG setting" 82 | print 83 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Overseer.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Overseer.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Overseer" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Overseer" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 98 | "(use \`make latexpdf' here to do that automatically)." 99 | 100 | latexpdf: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | make -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | if NOT "%PAPER%" == "" ( 11 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 12 | ) 13 | 14 | if "%1" == "" goto help 15 | 16 | if "%1" == "help" ( 17 | :help 18 | echo.Please use `make ^` where ^ is one of 19 | echo. html to make standalone HTML files 20 | echo. dirhtml to make HTML files named index.html in directories 21 | echo. singlehtml to make a single large HTML file 22 | echo. pickle to make pickle files 23 | echo. json to make JSON files 24 | echo. htmlhelp to make HTML files and a HTML help project 25 | echo. qthelp to make HTML files and a qthelp project 26 | echo. devhelp to make HTML files and a Devhelp project 27 | echo. epub to make an epub 28 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 29 | echo. text to make text files 30 | echo. man to make manual pages 31 | echo. changes to make an overview over all changed/added/deprecated items 32 | echo. linkcheck to check all external links for integrity 33 | echo. doctest to run all doctests embedded in the documentation if enabled 34 | goto end 35 | ) 36 | 37 | if "%1" == "clean" ( 38 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 39 | del /q /s %BUILDDIR%\* 40 | goto end 41 | ) 42 | 43 | if "%1" == "html" ( 44 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 45 | if errorlevel 1 exit /b 1 46 | echo. 47 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 48 | goto end 49 | ) 50 | 51 | if "%1" == "dirhtml" ( 52 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 53 | if errorlevel 1 exit /b 1 54 | echo. 55 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 56 | goto end 57 | ) 58 | 59 | if "%1" == "singlehtml" ( 60 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 61 | if errorlevel 1 exit /b 1 62 | echo. 63 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 64 | goto end 65 | ) 66 | 67 | if "%1" == "pickle" ( 68 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 69 | if errorlevel 1 exit /b 1 70 | echo. 71 | echo.Build finished; now you can process the pickle files. 72 | goto end 73 | ) 74 | 75 | if "%1" == "json" ( 76 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished; now you can process the JSON files. 80 | goto end 81 | ) 82 | 83 | if "%1" == "htmlhelp" ( 84 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished; now you can run HTML Help Workshop with the ^ 88 | .hhp project file in %BUILDDIR%/htmlhelp. 89 | goto end 90 | ) 91 | 92 | if "%1" == "qthelp" ( 93 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 94 | if errorlevel 1 exit /b 1 95 | echo. 96 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 97 | .qhcp project file in %BUILDDIR%/qthelp, like this: 98 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Overseer.qhcp 99 | echo.To view the help file: 100 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Overseer.ghc 101 | goto end 102 | ) 103 | 104 | if "%1" == "devhelp" ( 105 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 106 | if errorlevel 1 exit /b 1 107 | echo. 108 | echo.Build finished. 109 | goto end 110 | ) 111 | 112 | if "%1" == "epub" ( 113 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 117 | goto end 118 | ) 119 | 120 | if "%1" == "latex" ( 121 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 122 | if errorlevel 1 exit /b 1 123 | echo. 124 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 125 | goto end 126 | ) 127 | 128 | if "%1" == "text" ( 129 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 130 | if errorlevel 1 exit /b 1 131 | echo. 132 | echo.Build finished. The text files are in %BUILDDIR%/text. 133 | goto end 134 | ) 135 | 136 | if "%1" == "man" ( 137 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 141 | goto end 142 | ) 143 | 144 | if "%1" == "changes" ( 145 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.The overview file is in %BUILDDIR%/changes. 149 | goto end 150 | ) 151 | 152 | if "%1" == "linkcheck" ( 153 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Link check complete; look for any errors in the above output ^ 157 | or in %BUILDDIR%/linkcheck/output.txt. 158 | goto end 159 | ) 160 | 161 | if "%1" == "doctest" ( 162 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 163 | if errorlevel 1 exit /b 1 164 | echo. 165 | echo.Testing of doctests in the sources finished, look at the ^ 166 | results in %BUILDDIR%/doctest/output.txt. 167 | goto end 168 | ) 169 | 170 | :end 171 | -------------------------------------------------------------------------------- /overseer/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | class Migration(SchemaMigration): 8 | 9 | def forwards(self, orm): 10 | 11 | # Adding model 'Service' 12 | db.create_table('overseer_service', ( 13 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 14 | ('name', self.gf('django.db.models.fields.CharField')(max_length=128)), 15 | ('slug', self.gf('django.db.models.fields.SlugField')(unique=True, max_length=128, db_index=True)), 16 | ('description', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), 17 | ('status', self.gf('django.db.models.fields.SmallIntegerField')(default=0)), 18 | ('order', self.gf('django.db.models.fields.IntegerField')(default=0)), 19 | ('date_created', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now)), 20 | ('date_updated', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now)), 21 | )) 22 | db.send_create_signal('overseer', ['Service']) 23 | 24 | # Adding model 'Event' 25 | db.create_table('overseer_event', ( 26 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 27 | ('status', self.gf('django.db.models.fields.SmallIntegerField')(default=0)), 28 | ('peak_status', self.gf('django.db.models.fields.SmallIntegerField')(default=0)), 29 | ('description', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), 30 | ('message', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), 31 | ('date_created', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now)), 32 | ('date_updated', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now)), 33 | )) 34 | db.send_create_signal('overseer', ['Event']) 35 | 36 | # Adding M2M table for field services on 'Event' 37 | db.create_table('overseer_event_services', ( 38 | ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), 39 | ('event', models.ForeignKey(orm['overseer.event'], null=False)), 40 | ('service', models.ForeignKey(orm['overseer.service'], null=False)) 41 | )) 42 | db.create_unique('overseer_event_services', ['event_id', 'service_id']) 43 | 44 | # Adding model 'EventUpdate' 45 | db.create_table('overseer_eventupdate', ( 46 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 47 | ('event', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['overseer.Event'])), 48 | ('status', self.gf('django.db.models.fields.SmallIntegerField')()), 49 | ('message', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), 50 | ('date_created', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now)), 51 | )) 52 | db.send_create_signal('overseer', ['EventUpdate']) 53 | 54 | 55 | def backwards(self, orm): 56 | 57 | # Deleting model 'Service' 58 | db.delete_table('overseer_service') 59 | 60 | # Deleting model 'Event' 61 | db.delete_table('overseer_event') 62 | 63 | # Removing M2M table for field services on 'Event' 64 | db.delete_table('overseer_event_services') 65 | 66 | # Deleting model 'EventUpdate' 67 | db.delete_table('overseer_eventupdate') 68 | 69 | 70 | models = { 71 | 'overseer.event': { 72 | 'Meta': {'object_name': 'Event'}, 73 | 'date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 74 | 'date_updated': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 75 | 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 76 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 77 | 'message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 78 | 'peak_status': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), 79 | 'services': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['overseer.Service']", 'symmetrical': 'False'}), 80 | 'status': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}) 81 | }, 82 | 'overseer.eventupdate': { 83 | 'Meta': {'object_name': 'EventUpdate'}, 84 | 'date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 85 | 'event': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['overseer.Event']"}), 86 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 87 | 'message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 88 | 'status': ('django.db.models.fields.SmallIntegerField', [], {}) 89 | }, 90 | 'overseer.service': { 91 | 'Meta': {'ordering': "('order', 'name')", 'object_name': 'Service'}, 92 | 'date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 93 | 'date_updated': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 94 | 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 95 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 96 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 97 | 'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}), 98 | 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), 99 | 'status': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}) 100 | } 101 | } 102 | 103 | complete_apps = ['overseer'] 104 | -------------------------------------------------------------------------------- /overseer/migrations/0002_auto__add_subscription__add_unverifiedsubscription.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | class Migration(SchemaMigration): 8 | 9 | def forwards(self, orm): 10 | 11 | # Adding model 'Subscription' 12 | db.create_table('overseer_subscription', ( 13 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 14 | ('ident', self.gf('django.db.models.fields.CharField')(unique=True, max_length=32)), 15 | ('date_created', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now)), 16 | ('email', self.gf('django.db.models.fields.EmailField')(unique=True, max_length=75)), 17 | )) 18 | db.send_create_signal('overseer', ['Subscription']) 19 | 20 | # Adding M2M table for field services on 'Subscription' 21 | db.create_table('overseer_subscription_services', ( 22 | ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), 23 | ('subscription', models.ForeignKey(orm['overseer.subscription'], null=False)), 24 | ('service', models.ForeignKey(orm['overseer.service'], null=False)) 25 | )) 26 | db.create_unique('overseer_subscription_services', ['subscription_id', 'service_id']) 27 | 28 | # Adding model 'UnverifiedSubscription' 29 | db.create_table('overseer_unverifiedsubscription', ( 30 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 31 | ('ident', self.gf('django.db.models.fields.CharField')(unique=True, max_length=32)), 32 | ('date_created', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now)), 33 | ('email', self.gf('django.db.models.fields.EmailField')(max_length=75)), 34 | )) 35 | db.send_create_signal('overseer', ['UnverifiedSubscription']) 36 | 37 | # Adding M2M table for field services on 'UnverifiedSubscription' 38 | db.create_table('overseer_unverifiedsubscription_services', ( 39 | ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), 40 | ('unverifiedsubscription', models.ForeignKey(orm['overseer.unverifiedsubscription'], null=False)), 41 | ('service', models.ForeignKey(orm['overseer.service'], null=False)) 42 | )) 43 | db.create_unique('overseer_unverifiedsubscription_services', ['unverifiedsubscription_id', 'service_id']) 44 | 45 | 46 | def backwards(self, orm): 47 | 48 | # Deleting model 'Subscription' 49 | db.delete_table('overseer_subscription') 50 | 51 | # Removing M2M table for field services on 'Subscription' 52 | db.delete_table('overseer_subscription_services') 53 | 54 | # Deleting model 'UnverifiedSubscription' 55 | db.delete_table('overseer_unverifiedsubscription') 56 | 57 | # Removing M2M table for field services on 'UnverifiedSubscription' 58 | db.delete_table('overseer_unverifiedsubscription_services') 59 | 60 | 61 | models = { 62 | 'overseer.event': { 63 | 'Meta': {'object_name': 'Event'}, 64 | 'date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 65 | 'date_updated': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 66 | 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 67 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 68 | 'message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 69 | 'peak_status': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), 70 | 'services': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['overseer.Service']", 'symmetrical': 'False'}), 71 | 'status': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}) 72 | }, 73 | 'overseer.eventupdate': { 74 | 'Meta': {'object_name': 'EventUpdate'}, 75 | 'date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 76 | 'event': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['overseer.Event']"}), 77 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 78 | 'message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 79 | 'status': ('django.db.models.fields.SmallIntegerField', [], {}) 80 | }, 81 | 'overseer.service': { 82 | 'Meta': {'ordering': "('order', 'name')", 'object_name': 'Service'}, 83 | 'date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 84 | 'date_updated': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 85 | 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), 86 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 87 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 88 | 'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}), 89 | 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), 90 | 'status': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}) 91 | }, 92 | 'overseer.subscription': { 93 | 'Meta': {'object_name': 'Subscription'}, 94 | 'date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 95 | 'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75'}), 96 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 97 | 'ident': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}), 98 | 'services': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['overseer.Service']", 'symmetrical': 'False'}) 99 | }, 100 | 'overseer.unverifiedsubscription': { 101 | 'Meta': {'object_name': 'UnverifiedSubscription'}, 102 | 'date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 103 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75'}), 104 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 105 | 'ident': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}), 106 | 'services': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['overseer.Service']", 'symmetrical': 'False'}) 107 | } 108 | } 109 | 110 | complete_apps = ['overseer'] 111 | -------------------------------------------------------------------------------- /overseer/views.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.views 3 | ~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2011 DISQUS. 6 | :license: Apache License 2.0, see LICENSE for more details. 7 | """ 8 | 9 | import datetime 10 | import urlparse 11 | 12 | from django.core.context_processors import csrf 13 | from django.core.mail import send_mail 14 | from django.core.urlresolvers import reverse 15 | from django.db.models.query import Q 16 | from django.http import HttpResponseRedirect 17 | from django.views.decorators.csrf import csrf_protect 18 | 19 | from overseer import context_processors, conf 20 | from overseer.forms import NewSubscriptionForm, UpdateSubscriptionForm 21 | from overseer.models import Service, Event, Subscription, UnverifiedSubscription 22 | 23 | def requires(value_or_callable): 24 | def wrapped(func): 25 | def call(request, *args, **kwargs): 26 | if callable(value_or_callable): 27 | result = value_or_callable(request) 28 | else: 29 | result = value_or_callable 30 | 31 | if not result: 32 | return HttpResponseRedirect(reverse('overseer:index')) 33 | 34 | return func(request, *args, **kwargs) 35 | return call 36 | return wrapped 37 | 38 | def respond(template, context={}, request=None, **kwargs): 39 | "Calls render_to_response with a RequestConext" 40 | from django.http import HttpResponse 41 | from django.template import RequestContext 42 | from django.template.loader import render_to_string 43 | 44 | if request: 45 | default = context_processors.default(request) 46 | default.update(context) 47 | else: 48 | default = context.copy() 49 | 50 | rendered = render_to_string(template, default, context_instance=request and RequestContext(request) or None) 51 | return HttpResponse(rendered, **kwargs) 52 | 53 | def index(request): 54 | "Displays a list of all services and their current status." 55 | 56 | service_list = Service.objects.all() 57 | 58 | event_list = list(Event.objects\ 59 | .filter(Q(status__gt=0) | Q(date_updated__gte=datetime.datetime.now()-datetime.timedelta(days=1)))\ 60 | .order_by('-date_created')[0:6]) 61 | 62 | if event_list: 63 | latest_event, event_list = event_list[0], event_list[1:] 64 | else: 65 | latest_event = None 66 | 67 | return respond('overseer/index.html', { 68 | 'service_list': service_list, 69 | 'event_list': event_list, 70 | 'latest_event': latest_event, 71 | }, request) 72 | 73 | def service(request, slug): 74 | "Displays a list of all services and their current status." 75 | 76 | try: 77 | service = Service.objects.get(slug=slug) 78 | except Service.DoesNotExist: 79 | return HttpResponseRedirect(reverse('overseer:index')) 80 | 81 | event_list = service.event_set.order_by('-date_created') 82 | 83 | return respond('overseer/service.html', { 84 | 'service': service, 85 | 'event_list': event_list, 86 | }, request) 87 | 88 | def event(request, id): 89 | "Displays a list of all services and their current status." 90 | 91 | try: 92 | evt = Event.objects.get(pk=id) 93 | except Event.DoesNotExist: 94 | return HttpResponseRedirect(reverse('overseer:index')) 95 | 96 | update_list = list(evt.eventupdate_set.order_by('-date_created')) 97 | 98 | return respond('overseer/event.html', { 99 | 'event': evt, 100 | 'update_list': update_list, 101 | }, request) 102 | 103 | def last_event(request, slug): 104 | "Displays a list of all services and their current status." 105 | 106 | try: 107 | service = Service.objects.get(slug=slug) 108 | except Service.DoesNotExist: 109 | return HttpResponseRedirect(reverse('overseer:index')) 110 | 111 | try: 112 | evt = service.event_set.order_by('-date_created')[0] 113 | except IndexError: 114 | return HttpResponseRedirect(service.get_absolute_url()) 115 | 116 | return event(request, evt.pk) 117 | 118 | @requires(conf.ALLOW_SUBSCRIPTIONS) 119 | @csrf_protect 120 | def update_subscription(request, ident): 121 | "Shows subscriptions options for a verified subscriber." 122 | 123 | try: 124 | subscription = Subscription.objects.get(ident=ident) 125 | except Subscription.DoesNotExist: 126 | return respond('overseer/invalid_subscription_token.html', {}, request) 127 | 128 | if request.POST: 129 | form = UpdateSubscriptionForm(request.POST, instance=subscription) 130 | if form.is_valid(): 131 | if form.cleaned_data['unsubscribe']: 132 | subscription.delete() 133 | 134 | return respond('overseer/unsubscribe_confirmed.html', { 135 | 'email': subscription.email, 136 | }) 137 | else: 138 | form.save() 139 | 140 | return HttpResponseRedirect(request.get_full_path()) 141 | else: 142 | form = UpdateSubscriptionForm(instance=subscription) 143 | 144 | context = csrf(request) 145 | context.update({ 146 | 'form': form, 147 | 'subscription': subscription, 148 | 'service_list': Service.objects.all(), 149 | }) 150 | 151 | return respond('overseer/update_subscription.html', context, request) 152 | 153 | @requires(conf.ALLOW_SUBSCRIPTIONS) 154 | @csrf_protect 155 | def create_subscription(request): 156 | "Shows subscriptions options for a new subscriber." 157 | 158 | if request.POST: 159 | form = NewSubscriptionForm(request.POST) 160 | if form.is_valid(): 161 | unverified = form.save() 162 | 163 | body = """Please confirm your email address to subscribe to status updates from %(name)s:\n\n%(link)s""" % dict( 164 | name=conf.NAME, 165 | link=urlparse.urljoin(conf.BASE_URL, reverse('overseer:verify_subscription', args=[unverified.ident])) 166 | ) 167 | 168 | # Send verification email 169 | from_mail = conf.FROM_EMAIL 170 | if not from_mail: 171 | from_mail = 'overseer@%s' % request.get_host().split(':', 1)[0] 172 | 173 | send_mail('Confirm Subscription', body, from_mail, [unverified.email], 174 | fail_silently=True) 175 | 176 | # Show success page 177 | return respond('overseer/create_subscription_complete.html', { 178 | 'subscription': unverified, 179 | }, request) 180 | else: 181 | form = NewSubscriptionForm() 182 | 183 | context = csrf(request) 184 | context.update({ 185 | 'form': form, 186 | 'service_list': Service.objects.all(), 187 | }) 188 | 189 | return respond('overseer/create_subscription.html', context, request) 190 | 191 | @requires(conf.ALLOW_SUBSCRIPTIONS) 192 | def verify_subscription(request, ident): 193 | """ 194 | Verifies an unverified subscription and create or appends 195 | to an existing subscription. 196 | """ 197 | 198 | try: 199 | unverified = UnverifiedSubscription.objects.get(ident=ident) 200 | except UnverifiedSubscription.DoesNotExist: 201 | return respond('overseer/invalid_subscription_token.html', {}, request) 202 | 203 | subscription = Subscription.objects.get_or_create(email=unverified.email, defaults={ 204 | 'ident': unverified.ident, 205 | })[0] 206 | 207 | subscription.services = unverified.services.all() 208 | 209 | unverified.delete() 210 | 211 | return respond('overseer/subscription_confirmed.html', { 212 | 'subscription': subscription, 213 | }, request) -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Overseer documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Feb 2 17:28:01 2011. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = [] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'Overseer' 44 | copyright = u'2011, DISQUS' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The short X.Y version. 51 | version = '0.2.0' 52 | # The full version, including alpha/beta/rc tags. 53 | release = version 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | #language = None 58 | 59 | # There are two options for replacing |today|: either, you set today to some 60 | # non-false value, then it is used: 61 | #today = '' 62 | # Else, today_fmt is used as the format for a strftime call. 63 | #today_fmt = '%B %d, %Y' 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | exclude_patterns = ['_build'] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | html_theme = 'default' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | #html_theme_options = {} 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | #html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | #html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | #html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | #html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | #html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | #html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | #html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | #html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | #html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | #html_domain_indices = True 142 | 143 | # If false, no index is generated. 144 | #html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | #html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | #html_show_sourcelink = True 151 | 152 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 153 | #html_show_sphinx = True 154 | 155 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 156 | #html_show_copyright = True 157 | 158 | # If true, an OpenSearch description file will be output, and all pages will 159 | # contain a tag referring to it. The value of this option must be the 160 | # base URL from which the finished HTML is served. 161 | #html_use_opensearch = '' 162 | 163 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 164 | #html_file_suffix = None 165 | 166 | # Output file base name for HTML help builder. 167 | htmlhelp_basename = 'Overseerdoc' 168 | 169 | 170 | # -- Options for LaTeX output -------------------------------------------------- 171 | 172 | # The paper size ('letter' or 'a4'). 173 | #latex_paper_size = 'letter' 174 | 175 | # The font size ('10pt', '11pt' or '12pt'). 176 | #latex_font_size = '10pt' 177 | 178 | # Grouping the document tree into LaTeX files. List of tuples 179 | # (source start file, target name, title, author, documentclass [howto/manual]). 180 | latex_documents = [ 181 | ('index', 'Overseer.tex', u'Overseer Documentation', 182 | u'DISQUS', 'manual'), 183 | ] 184 | 185 | # The name of an image file (relative to this directory) to place at the top of 186 | # the title page. 187 | #latex_logo = None 188 | 189 | # For "manual" documents, if this is true, then toplevel headings are parts, 190 | # not chapters. 191 | #latex_use_parts = False 192 | 193 | # If true, show page references after internal links. 194 | #latex_show_pagerefs = False 195 | 196 | # If true, show URL addresses after external links. 197 | #latex_show_urls = False 198 | 199 | # Additional stuff for the LaTeX preamble. 200 | #latex_preamble = '' 201 | 202 | # Documents to append as an appendix to all manuals. 203 | #latex_appendices = [] 204 | 205 | # If false, no module index is generated. 206 | #latex_domain_indices = True 207 | 208 | 209 | # -- Options for manual page output -------------------------------------------- 210 | 211 | # One entry per manual page. List of tuples 212 | # (source start file, name, description, authors, manual section). 213 | man_pages = [ 214 | ('index', 'overseer', u'Overseer Documentation', 215 | [u'DISQUS'], 1) 216 | ] 217 | -------------------------------------------------------------------------------- /overseer/media/css/base.css: -------------------------------------------------------------------------------- 1 | /* HTML5 ✰ Boilerplate */ 2 | 3 | html, body, div, span, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, 4 | abbr, address, cite, code, del, dfn, em, img, ins, kbd, q, samp, 5 | small, strong, sub, sup, var, b, i, dl, dt, dd, ol, ul, li, 6 | fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, 7 | article, aside, canvas, details, figcaption, figure, footer, header, hgroup, 8 | menu, nav, section, summary, time, mark, audio, video { 9 | margin:0; 10 | padding:0; 11 | border:0; 12 | outline:0; 13 | font-size:100%; 14 | vertical-align:baseline; 15 | background:transparent; 16 | } 17 | article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section { 18 | display:block; 19 | } 20 | nav ul { list-style:none; } 21 | blockquote, q { quotes:none; } 22 | blockquote:before, blockquote:after, 23 | q:before, q:after { content:''; content:none; } 24 | a { margin:0; padding:0; font-size:100%; vertical-align:baseline; background:transparent; } 25 | ins { background-color:#ff9; color:#000; text-decoration:none; } 26 | mark { background-color:#ff9; color:#000; font-style:italic; font-weight:bold; } 27 | del { text-decoration: line-through; } 28 | abbr[title], dfn[title] { border-bottom:1px dotted; cursor:help; } 29 | table { border-collapse:collapse; border-spacing:0; } 30 | hr { display:block; height:1px; border:0; border-top:1px solid #ccc; margin:1em 0; padding:0; } 31 | input, select { vertical-align:middle; } 32 | 33 | 34 | body { font:13px/1.231 sans-serif; *font-size:small; } 35 | select, input, textarea, button { font:99% sans-serif; } 36 | pre, code, kbd, samp { font-family: monospace, sans-serif; } 37 | pre { 38 | white-space: pre-wrap; /* CSS-3 */ 39 | white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ 40 | white-space: -pre-wrap; /* Opera 4-6 */ 41 | white-space: -o-pre-wrap; /* Opera 7 */ 42 | word-wrap: break-word; /* Internet Explorer 5.5+ */ 43 | } 44 | body, select, input, textarea { color: #444; } 45 | h1,h2,h3,h4,h5,h6 { font-weight: bold; } 46 | html { overflow-y: scroll; } 47 | 48 | a:hover, a:active { outline: none; } 49 | 50 | ul, ol { margin-left: 1.8em; } 51 | ol { list-style-type: decimal; } 52 | 53 | nav ul, nav li { margin: 0; } 54 | small { font-size: 85%; } 55 | strong, th { font-weight: bold; } 56 | td, td img { vertical-align: top; } 57 | sub { vertical-align: sub; font-size: smaller; } 58 | sup { vertical-align: super; font-size: smaller; } 59 | textarea { overflow: auto; } 60 | .ie6 legend, .ie7 legend { margin-left: -7px; } 61 | input[type="radio"] { vertical-align: text-bottom; } 62 | input[type="checkbox"] { vertical-align: bottom; } 63 | .ie7 input[type="checkbox"] { vertical-align: baseline; } 64 | .ie6 input { vertical-align: text-bottom; } 65 | label, input[type=button], input[type=submit], button { cursor: pointer; } 66 | button, input, select, textarea { margin: 0; } 67 | input:valid, textarea:valid { } 68 | input:invalid, textarea:invalid { border-radius: 1px; -moz-box-shadow: 0px 0px 5px red; -webkit-box-shadow: 0px 0px 5px red; box-shadow: 0px 0px 5px red; } 69 | .no-boxshadow input:invalid, 70 | .no-boxshadow textarea:invalid { background-color: #f0dddd; } 71 | 72 | ::-moz-selection{ background: #FF5E99; color:#fff; text-shadow: none; } 73 | ::selection { background:#FF5E99; color:#fff; text-shadow: none; } 74 | a:link { -webkit-tap-highlight-color: #FF5E99; } 75 | 76 | button { width: auto; overflow: visible; } 77 | .ie7 img { -ms-interpolation-mode: bicubic; } 78 | 79 | .ir { display: block; text-indent: -999em; overflow: hidden; background-repeat: no-repeat; text-align: left; direction: ltr; } 80 | .hidden { display: none; visibility: hidden; } 81 | .visuallyhidden { position: absolute !important; clip: rect(1px 1px 1px 1px); clip: rect(1px, 1px, 1px, 1px); } 82 | .invisible { visibility: hidden; } 83 | .clearfix:before, .clearfix:after { content: "\0020"; display: block; height: 0; visibility: hidden; } 84 | .clearfix:after { clear: both; } 85 | .clearfix { zoom: 1; } 86 | 87 | 88 | /** 89 | * Overseer Design 90 | */ 91 | 92 | a { color: #333; } 93 | a:visited { color: inherit; } 94 | a:hover { color: #436586; } 95 | 96 | body { background-color: #e5e5e5; } 97 | 98 | table, p, h1, h2, h3, h4, h5, h6, ul, ol, form, dl { 99 | margin-bottom: 20px; 100 | } 101 | 102 | h1 { 103 | font-size: 1.8em; 104 | font-weight: normal; 105 | } 106 | h3 { 107 | font-size: 1.4em; 108 | font-weight: normal; 109 | } 110 | 111 | .wrapper { 112 | width: 800px; 113 | margin: 0 auto; 114 | } 115 | 116 | header { 117 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#437ba3', endColorstr='#234b6f'); /* for IE */ 118 | background: -webkit-gradient(linear, left top, left bottom, from(#437ba3), to(#234b6f)); /* for webkit browsers */ 119 | background: -moz-linear-gradient(top, #555, #437ba3); /* for firefox 3.6+ */ 120 | padding: 20px; 121 | margin: 0 0 20px; 122 | } 123 | 124 | header h1 { 125 | border: 0; 126 | margin: 0; 127 | color: #fff; 128 | display: inline; 129 | font-size: 20pt; 130 | font-weight: normal; 131 | text-shadow: 0 -1px 1px rgba(0,0,0,.4); 132 | } 133 | header h1 a { 134 | text-decoration: none; 135 | } 136 | header h1 a:hover { 137 | color: inherit; 138 | } 139 | 140 | header h1 { 141 | font-size: 2em; 142 | font-weight: normal; 143 | text-decoration: none; 144 | } 145 | 146 | footer { 147 | border-top: solid 1px #ddd; 148 | padding: 20px; 149 | background-color: #e5e5e5; 150 | } 151 | footer a { 152 | font-weight: bold; 153 | } 154 | 155 | #container { background-color: #eee; } 156 | #main { padding: 0 10px; } 157 | #main .wrapper { 158 | position: relative; 159 | overflow: hidden; 160 | margin: 0 auto 20px; 161 | padding: 20px 20px 0; 162 | background-color: #fff; 163 | 164 | -webkit-border-radius: 6px; 165 | -moz-border-radius: 6px; 166 | border-radius: 6px; 167 | 168 | -webkit-box-shadow: 0 1px 2px rgba(0,0,0,.2); 169 | -moz-box-shadow: 0 1px 2px rgba(0,0,0,.2); 170 | box-shadow: 0 1px 2px rgba(0,0,0,.2); 171 | } 172 | 173 | 174 | table { 175 | width: 100%; 176 | } 177 | table thead th { 178 | text-align: left; 179 | font-weight: bold; 180 | border-bottom: 1px solid #ddd; 181 | } 182 | table td, table th { 183 | padding: 6px 6px; 184 | } 185 | table tbody tr:nth-child(even) { 186 | background: #f3f3f3; 187 | } 188 | table thead tr { 189 | background: #eee; 190 | } 191 | table th.status { 192 | text-align: center; 193 | padding: 0; 194 | } 195 | table td.status { 196 | vertical-align: top; 197 | width: 32px; 198 | padding: 0; 199 | text-align: center; 200 | background-position: center 6px; 201 | background-repeat: no-repeat; 202 | text-indent: -10000em; 203 | } 204 | table td.status-0 { 205 | background-image: url('../images/green_light_32x32.png'); 206 | } 207 | table td.status-1 { 208 | background-image: url('../images/yellow_light_32x32.png'); 209 | } 210 | table td.status-2 { 211 | background-image: url('../images/red_light_32x32.png'); 212 | } 213 | table td.service h2 { 214 | font-size: 24px; 215 | line-height: 32px; 216 | font-weight: normal; 217 | margin-bottom: 0; 218 | } 219 | table td.service h2 a { 220 | text-decoration: none; 221 | } 222 | table td.service p { 223 | color: #999; 224 | margin-bottom: 0; 225 | } 226 | table td.service p.status { 227 | margin-top: 10px; 228 | color: #000; 229 | padding: 3px 6px; 230 | } 231 | table .status-0 td.service p.status { 232 | background: green; 233 | color: #fff; 234 | } 235 | table .status-1 td.service p.status { 236 | background: orange; 237 | color: #fff; 238 | } 239 | table .status-2 td.service p.status { 240 | background: red; 241 | color: #fff; 242 | } 243 | table td.last-event { 244 | width: 130px; 245 | line-height: 32px; 246 | } 247 | 248 | .latest-event { 249 | margin-bottom: 20px; 250 | font-size: 1.2em; 251 | } 252 | .latest-event time, 253 | .latest-event .affects { 254 | font-size: 0.8em; 255 | } 256 | .event-list, .update-list { 257 | border-top: 1px solid #ddd; 258 | padding-top: 20px; 259 | } 260 | .event-list ul, .update-list ul { 261 | list-style: none; 262 | margin-left: 0; 263 | } 264 | .event-list li, .update-list li { 265 | margin-bottom: 5px; 266 | } 267 | .event p a { 268 | text-decoration: none; 269 | } 270 | .event p, .update p { 271 | background-repeat: no-repeat; 272 | background-position: top left; 273 | padding-left: 22px; 274 | margin-bottom: 0; 275 | font-weight: bold; 276 | line-height: 18px; 277 | } 278 | .event.status-0 p, .update.status-0 p { 279 | background-image: url('../images/green_light_16x16.png'); 280 | } 281 | .event.status-1 p, .update.status-1 p { 282 | background-image: url('../images/yellow_light_16x16.png'); 283 | } 284 | .event.status-2 p, .update.status-2 p { 285 | background-image: url('../images/red_light_16x16.png'); 286 | } 287 | 288 | .event time, .update time, 289 | .event .affects { 290 | font-size: 0.9em; 291 | padding: 2px 6px; 292 | display: inline-block; 293 | } 294 | .event time, .update time { 295 | margin-left: 22px; 296 | color: #999; 297 | } 298 | .event .affects { 299 | background: #ff9; 300 | } 301 | 302 | .subscription-service-list ul { 303 | list-style: none; 304 | margin-left: 0; 305 | } 306 | .subscription-service-list li label { 307 | display: block; 308 | margin-bottom: 5px; 309 | font-weight: bold; 310 | } 311 | 312 | input[type=text], input[type=password], 313 | textarea, select { 314 | border: 1px solid #999; 315 | padding: 2px 6px; 316 | } 317 | 318 | .subscription-error-list p { 319 | color: red; 320 | } 321 | .has-errors input { 322 | border-color: red; 323 | border-style: solid; 324 | } 325 | .errorlist { 326 | color: red; 327 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2011 DISQUS 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /overseer/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | overseer.models 3 | ~~~~~~~~~~~~~~~ 4 | 5 | A service's status should be: 6 | 7 | - red if any(updates affecting service) are red 8 | - yellow if any(updates affecting service) are yellow 9 | - green if all(updates affecting service) are green 10 | 11 | :copyright: (c) 2011 DISQUS. 12 | :license: Apache License 2.0, see LICENSE for more details. 13 | """ 14 | 15 | import datetime 16 | import oauth2 17 | import urlparse 18 | import uuid 19 | import warnings 20 | 21 | from django.core.mail import send_mail 22 | from django.core.urlresolvers import reverse 23 | from django.db import models 24 | from django.db.models.signals import post_save, m2m_changed 25 | 26 | from overseer import conf 27 | from overseer.utils import SimpleTwitterClient 28 | 29 | STATUS_CHOICES = ( 30 | (0, 'No Problems'), 31 | (1, 'Some Issues'), 32 | (2, 'Unavailable'), 33 | ) 34 | 35 | SUBSCRIPTION_EMAIL_TEMPLATE = """ 36 | A service's status has changed on %(name)s: 37 | 38 | %(message)s 39 | 40 | This update affects the following: 41 | 42 | %(affects)s 43 | 44 | ---- 45 | 46 | To change your subscription settings, please visit %(sub_url)s 47 | 48 | """.strip() 49 | 50 | class Service(models.Model): 51 | """ 52 | A ``Service`` can describe any part of your architecture. Each 53 | service can have many events, in which the last event should be shown 54 | (unless the status is 'No Problems'). 55 | """ 56 | name = models.CharField(max_length=128) 57 | slug = models.SlugField(max_length=128, unique=True) 58 | description = models.TextField(blank=True, null=True) 59 | status = models.SmallIntegerField(choices=STATUS_CHOICES, editable=False, default=0) 60 | order = models.IntegerField(default=0) 61 | date_created = models.DateTimeField(default=datetime.datetime.now, editable=False) 62 | date_updated = models.DateTimeField(default=datetime.datetime.now, editable=False) 63 | 64 | class Meta: 65 | ordering = ('order', 'name') 66 | 67 | def __unicode__(self): 68 | return self.name 69 | 70 | @models.permalink 71 | def get_absolute_url(self): 72 | return ('overseer:service', [self.slug], {}) 73 | 74 | @classmethod 75 | def handle_event_m2m_save(cls, sender, instance, action, reverse, model, pk_set, **kwargs): 76 | if not action.startswith('post_'): 77 | return 78 | if not pk_set: 79 | return 80 | 81 | if model is Service: 82 | for service in Service.objects.filter(pk__in=pk_set): 83 | service.update_from_event(instance) 84 | else: 85 | for event in Event.objects.filter(pk__in=pk_set): 86 | instance.update_from_event(event) 87 | 88 | @classmethod 89 | def handle_event_save(cls, instance, **kwargs): 90 | for service in instance.services.all(): 91 | service.update_from_event(instance) 92 | 93 | def update_from_event(self, event): 94 | update_qs = Service.objects.filter(pk=self.pk) 95 | if event.date_updated > self.date_updated: 96 | # If the update is newer than the last update to the self 97 | update_qs.filter(date_updated__lt=event.date_updated)\ 98 | .update(date_updated=event.date_updated) 99 | self.date_updated = event.date_updated 100 | 101 | if event.status > self.status: 102 | # If our status more critical (higher) than the current 103 | # self status, update to match the current 104 | update_qs.filter(status__lt=event.status)\ 105 | .update(status=event.status) 106 | self.status = event.status 107 | 108 | elif event.status < self.status: 109 | # If no more events match the current self status, let's update 110 | # it to the current status 111 | if not Event.objects.filter(services=self, status=self.status)\ 112 | .exclude(pk=event.pk).exists(): 113 | update_qs.filter(status__gt=event.status)\ 114 | .update(status=event.status) 115 | self.status = event.status 116 | 117 | def get_message(self): 118 | if self.status == 0: 119 | return 'This service is operating as expected.' 120 | elif self.status == 1: 121 | return 'This service is experiencing some issues.' 122 | elif self.status == 2: 123 | return 'This service may be unavailable.' 124 | return '' 125 | 126 | def join_with_and(values): 127 | values = list(values) 128 | if len(values) == 2: 129 | return ' and '.join(values) 130 | elif len(values) > 2: 131 | return '%s, and %s' % (', '.join(values[:-1]), values[-1]) 132 | return values[0] 133 | 134 | class EventBase(models.Model): 135 | class Meta: 136 | abstract = True 137 | 138 | def get_message(self): 139 | if self.message: 140 | return self.message 141 | elif self.status == 0: 142 | return '%s operating as expected.' % join_with_and(a[1] for a in self.get_services()) 143 | elif self.status == 1: 144 | return 'Experiencing some issues with %s.' % join_with_and(a[1] for a in self.get_services()) 145 | elif self.status == 2: 146 | return '%s may be unavailable.' % join_with_and(a[1] for a in self.get_services()) 147 | return '' 148 | 149 | class Event(EventBase): 150 | """ 151 | An ``Event`` is a collection of updates related to one event. 152 | 153 | - ``message`` stores the last message from ``StatusUpdate`` for this event. 154 | """ 155 | services = models.ManyToManyField(Service) 156 | status = models.SmallIntegerField(choices=STATUS_CHOICES, editable=False, default=0) 157 | peak_status = models.SmallIntegerField(choices=STATUS_CHOICES, editable=False, default=0) 158 | description = models.TextField(null=True, blank=True, help_text='We will auto fill the description from the first event message if not set') 159 | message = models.TextField(null=True, blank=True, editable=False) 160 | date_created = models.DateTimeField(default=datetime.datetime.now, editable=False) 161 | date_updated = models.DateTimeField(default=datetime.datetime.now, editable=False) 162 | 163 | def __unicode__(self): 164 | return u"%s on %s" % (self.date_created, '; '.join(self.services.values_list('name', flat=True))) 165 | 166 | @models.permalink 167 | def get_absolute_url(self): 168 | return ('overseer:event', [self.pk], {}) 169 | 170 | def get_services(self): 171 | return self.services.values_list('slug', 'name') 172 | 173 | def get_duration(self): 174 | return self.date_updated - self.date_created 175 | 176 | def post_to_twitter(self, message=None): 177 | """Update twitter status, i.e., post a tweet""" 178 | 179 | consumer = oauth2.Consumer(key=conf.TWITTER_CONSUMER_KEY, 180 | secret=conf.TWITTER_CONSUMER_SECRET) 181 | token = oauth2.Token(key=conf.TWITTER_ACCESS_TOKEN, secret=conf.TWITTER_ACCESS_SECRET) 182 | client = SimpleTwitterClient(consumer=consumer, token=token) 183 | 184 | if not message: 185 | message = self.get_message() 186 | 187 | hash_tag = '#status' 188 | 189 | if conf.BASE_URL: 190 | permalink = urlparse.urljoin(conf.BASE_URL, reverse('overseer:event_short', args=[self.pk])) 191 | if len(message) + len(permalink) + len(hash_tag) > 138: 192 | message = '%s.. %s %s' % (message[:140-4-len(hash_tag)-len(permalink)], permalink, hash_tag) 193 | else: 194 | message = '%s %s %s' % (message, permalink, hash_tag) 195 | else: 196 | if len(message) + len(hash_tag) > 139: 197 | message = '%s.. %s' % (message[:140-3-len(hash_tag)], hash_tag) 198 | else: 199 | message = '%s %s' % (message, hash_tag) 200 | 201 | return client.update_status(message) 202 | 203 | @classmethod 204 | def handle_update_save(cls, instance, created, **kwargs): 205 | event = instance.event 206 | 207 | if created: 208 | is_latest = True 209 | elif EventUpdate.objects.filter(event=event).order_by('-date_created')\ 210 | .values_list('event', flat=True)[0] == event.pk: 211 | is_latest = True 212 | else: 213 | is_latest = False 214 | 215 | if is_latest: 216 | update_kwargs = dict( 217 | status=instance.status, 218 | date_updated=instance.date_created, 219 | message=instance.message 220 | ) 221 | 222 | if not event.description: 223 | update_kwargs['description'] = instance.message 224 | 225 | if not event.peak_status or event.peak_status < instance.status: 226 | update_kwargs['peak_status'] = instance.status 227 | 228 | Event.objects.filter(pk=event.pk).update(**update_kwargs) 229 | 230 | for k, v in update_kwargs.iteritems(): 231 | setattr(event, k, v) 232 | 233 | # Without sending the signal Service will fail to update 234 | post_save.send(sender=Event, instance=event, created=False) 235 | 236 | class EventUpdate(EventBase): 237 | """ 238 | An ``EventUpdate`` contains a single update to an ``Event``. The latest update 239 | will always be reflected within the event, carrying over it's ``status`` and ``message``. 240 | """ 241 | event = models.ForeignKey(Event) 242 | status = models.SmallIntegerField(choices=STATUS_CHOICES) 243 | message = models.TextField(null=True, blank=True) 244 | date_created = models.DateTimeField(default=datetime.datetime.now, editable=False) 245 | 246 | def __unicode__(self): 247 | return unicode(self.date_created) 248 | 249 | def get_services(self): 250 | return self.event.services.values_list('slug', 'name') 251 | 252 | class BaseSubscription(models.Model): 253 | ident = models.CharField(max_length=32, unique=True) 254 | date_created = models.DateTimeField(default=datetime.datetime.now, editable=False) 255 | services = models.ManyToManyField(Service) 256 | 257 | class Meta: 258 | abstract = True 259 | 260 | def __unicode__(self): 261 | return self.email 262 | 263 | def save(self, *args, **kwargs): 264 | if not self.ident: 265 | self.ident = uuid.uuid4().hex 266 | super(BaseSubscription, self).save(*args, **kwargs) 267 | 268 | class Subscription(BaseSubscription): 269 | """ 270 | Represents an email subscription. 271 | """ 272 | email = models.EmailField(unique=True) 273 | 274 | @classmethod 275 | def handle_update_save(cls, instance, created, **kwargs): 276 | if not created: 277 | return 278 | 279 | if not conf.ALLOW_SUBSCRIPTIONS: 280 | return 281 | 282 | if not conf.FROM_EMAIL: 283 | # TODO: grab system default 284 | warnings.warn('Configuration error with Oveerseer: FROM_EMAIL is not set') 285 | return 286 | 287 | if not conf.BASE_URL: 288 | warnings.warn('Configuration error with Oveerseer: BASE_URL is not set') 289 | return 290 | 291 | services = list(instance.event.services.all()) 292 | affects = '\n'.join('- %s' % s.name for s in services) 293 | message = instance.get_message() 294 | 295 | for email, ident in cls.objects.filter(services__in=services)\ 296 | .values_list('email', 'ident')\ 297 | .distinct(): 298 | # send email 299 | body = SUBSCRIPTION_EMAIL_TEMPLATE % dict( 300 | sub_url = urlparse.urljoin(conf.BASE_URL, reverse('overseer:update_subscription', args=[ident])), 301 | message = message, 302 | affects = affects, 303 | name = conf.NAME, 304 | ) 305 | send_mail('Status Change on %s' % conf.NAME, body, conf.FROM_EMAIL, [email], 306 | fail_silently=True) 307 | 308 | class UnverifiedSubscription(BaseSubscription): 309 | """ 310 | A temporary store for unverified subscriptions. 311 | """ 312 | email = models.EmailField() 313 | 314 | post_save.connect(Service.handle_event_save, sender=Event) 315 | post_save.connect(Event.handle_update_save, sender=EventUpdate) 316 | m2m_changed.connect(Service.handle_event_m2m_save, sender=Event.services.through) 317 | post_save.connect(Subscription.handle_update_save, sender=EventUpdate) --------------------------------------------------------------------------------