├── 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 |
11 | {% for service in subscription.services.all %}
12 | {{ service.name }}
13 | {% endfor %}
14 |
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 |
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 |
{{ event.date_updated|timesince }}{% if not event.status %} (lasted {{ event.get_duration|duration }}){% endif %}
11 |
affects {% for slug, name in event.get_services %}{{ name }} {% if not forloop.last %}, {% endif %}{% endfor %}
12 |
13 |
14 |
15 |
Updates (newest first)
16 |
17 | {% for update in update_list %}
18 |
19 | {{ update.get_message }}
20 | {{ update.date_created|timesince }}
21 |
22 | {% endfor %}
23 |
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 |
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 | {{ service.get_status_display }}
10 |
11 |
12 | {% if service.description %}
13 | {{ service.description }}
14 | {% endif %}
15 | {{ service.get_message }}
16 |
17 |
18 |
19 |
20 |
21 |
22 |
Recent Events
23 | {% if event_list %}
24 |
25 | {% for event in event_list %}
26 |
27 | {{ event.description }}
28 | {{ event.date_updated|timesince }}{% if not event.status %} (lasted {{ event.get_duration|duration }}){% endif %}
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 |
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 |
{{ latest_event.date_updated|timesince }}{% if not latest_event.status %} (lasted {{ latest_event.get_duration|duration }}){% endif %}
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 | Status
20 | Service
21 | Last Event
22 |
23 |
24 |
25 | {% for service in service_list %}
26 |
27 | {{ service.get_status_display }}
28 |
29 |
30 | {% if service.description %}
31 | {{ service.description }}
32 | {% endif %}
33 |
34 |
35 | {% if service.date_updated != service.date_created %}
36 | {{ service.date_updated|timesince }}
37 | {% else %}
38 | n/a
39 | {% endif %}
40 |
41 |
42 | {% endfor %}
43 |
44 |
45 |
46 |
47 | {% if event_list %}
48 |
49 |
Recent Events
50 |
51 | {% for event in event_list %}
52 |
53 | {{ event.get_message }}
54 | {{ event.date_updated|timesince }}{% if not event.status %} (lasted {{ event.get_duration|duration }}){% endif %}
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)
--------------------------------------------------------------------------------