From 9c65d225a895abfcaa1129ec3d87068d9d38560c Mon Sep 17 00:00:00 2001 From: James Polley Date: Thu, 31 May 2012 20:26:55 +1000 Subject: [PATCH 1/2] Add XMPP handling. Can pull the list of services, or details of a single service, over XMPP. Can subscribe to a service, and receive notifications when that service's status changes. Can unsubscribe as well. --- stashboard/app.yaml | 4 + stashboard/handlers/api.py | 20 ++++- stashboard/handlers/xmpp.py | 163 ++++++++++++++++++++++++++++++++++++ stashboard/main.py | 8 +- stashboard/models.py | 31 +++++-- stashboard/settings.py | 6 +- 6 files changed, 219 insertions(+), 13 deletions(-) create mode 100644 stashboard/handlers/xmpp.py diff --git a/stashboard/app.yaml b/stashboard/app.yaml index 78bc1177..ecc1f7ae 100755 --- a/stashboard/app.yaml +++ b/stashboard/app.yaml @@ -10,6 +10,10 @@ builtins: - appstats: on - remote_api: on +inbound_services: +- xmpp_message +- xmpp_presence + handlers: - url: /console/.* script: $PYTHON_LIB/google/appengine/ext/admin diff --git a/stashboard/handlers/api.py b/stashboard/handlers/api.py index dc75f248..45244fa1 100644 --- a/stashboard/handlers/api.py +++ b/stashboard/handlers/api.py @@ -377,10 +377,13 @@ def post(self, version, service_slug): self.error(404, "Service %s not found" % service_slug) return + last_event = service.current_event() + if last_event: + old_status = last_event.status + if not status_slug: - event = service.current_event() - if event: - status = event.status + if last_event: + status = old_status else: status = Status.get_default() else: @@ -398,6 +401,17 @@ def post(self, version, service_slug): if self.request.get('tweet'): logging.info('Attempting to post a tweet for the latest event via async GAE task queue.') taskqueue.add(url='/admin/tweet', params={'service_name': service.name, 'status_name': status.name, 'message': message}) + # Queue up tasks for notifing subscribers via XMPP + if service.subscriptions: + logging.info("Handling subscriptions") + for subscription in service.subscriptions: + params={'address': subscription.address, 'service': + service.key(), 'oldstatus': old_status.key()} + logging.info("Adding deferred task: %s %s" % ( + subscription.type, params)) + taskqueue.add(url='/notify/' + subscription.type, params=params) + else: + logging.info("No subscriptions for %s" % service.name) invalidate_cache() self.json(e.rest(self.base_url(version))) diff --git a/stashboard/handlers/xmpp.py b/stashboard/handlers/xmpp.py new file mode 100644 index 00000000..8a0421fc --- /dev/null +++ b/stashboard/handlers/xmpp.py @@ -0,0 +1,163 @@ +# The MIT License +# +# Copyright (c) 2008 William T. Katz +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + + +__author__ = 'James Polley' + +import os +import sys +import logging +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'contrib')) + +import appengine_config # Make sure this happens + +from google.appengine.api import memcache + +from google.appengine.api import xmpp +from google.appengine.ext import db +from google.appengine.ext import webapp +from google.appengine.ext.webapp import xmpp_handlers + +from models import List, Service, Status, Event, Image, Profile, Subscription +from utils import authorized + +class FirstWordHandlerMixin(xmpp_handlers.CommandHandlerMixin): + """Just like CommandHandlerMixin but assumes first word is command.""" + + def message_received(self, message): + if message.command: + super(FirstWordHandlerMixin, self).message_received(message) + else: + command = message.body.split(' ')[0] + handler_name = '%s_command' % (command,) + handler = getattr(self, handler_name, None) + if handler: + handler(message) + else: + self.unhandled_command(message) + +class FirstWordHandler(FirstWordHandlerMixin, xmpp_handlers.BaseHandler): + """A webapp implementation of FirstWordHandlerMixin.""" + pass + +class XmppNotificationHandler(webapp.RequestHandler): + """Handle notifications via XMPP""" + + def post(self): + """Notify subscribers that a service changed status.""" + + address = self.request.get('address') + service = Service.get(self.request.get('service')) + oldstatus = Status.get(self.request.get('oldstatus')) + + logging.info("Service: %s" % service) + logging.info("Service name: %s" % service.name) + + msg = "%s changed state from %s to %s" % ( + service.name, oldstatus.name, + service.current_event().status.name) + xmpp.send_message(address, msg) + logging.info("Notified: %s\nmessage: %s" % (address, msg)) + +class XmppHandler(FirstWordHandler): + """Handler class for all XMPP activity.""" + + def service_command(self, message=None): + """Change status of a service""" + _, service_name = message.body.split(' ', 1) + service = Service.all().filter('name = ', service_name).get() + + if service: + return_msg =["Name: %s" % service.name] + return_msg.append("Description: %s" % service.description) + return_msg.append("Recent events:") + events = service.events.order('-start').run(limit=3) + for event in events: + return_msg.append("%s: %s: %s" % ( + event.start, event.status.name, event.message)) + else: + return_msg = ["Cannot find service with name: %s" % service_name] + + return_msg = "\n".join(return_msg) + message.reply(return_msg) + + def services_command(self, message=None): + """List all services""" + return_msg = [] + + for service in Service.all(): + event = service.current_event() + if event: + return_msg.append("%s: %s: %s" % ( + service.name, event.status.name, event.message)) + else: + return_msg.append("%s has no events" % service.name) + + return_msg = '\n'.join(return_msg) + + message.reply(return_msg) + + def addservice_command(self, message=None): + """Create a new service""" + + service_name = message.body.split(' ')[1] + service = Service(key_name=service_name, name=service_name) + service.put() + + message.reply("Added service %s" % service_name) + + def sub_command(self, message=None): + """Subscribe the user to a service""" + user = message.sender.split('/')[0] + + _, service_name = message.body.split(' ', 1) + service = Service.all().filter('name = ', service_name).get() + + if service: + subscription = Subscription.all().filter('address =', user).filter('service = ', service).get() + if subscription: + message.reply("user %s is already subscribed to service %s" % (user, service.name)) + else: + subscription = Subscription(type='xmpp', address=user, service=service) + subscription.put() + message.reply("Subscribed %s to service %s" % (user, service.name)) + else: + message.reply("Sorry, I couldn't find a service called " + "%s" % service_name) + + def unsub_command(self, message=None): + """Unsubscribe the user from a service""" + user = message.sender.split('/')[0] + + _, service_name = message.body.split(' ', 1) + service = Service.all().filter('name = ', service_name).get() + + if service: + subscription = Subscription.all().filter('address =', user).filter('service = ', service).get() + if subscription: + subscription.delete() + message.reply("Unsubscribed %s from service %s" % (user, service.name)) + else: + message.reply("user %s is not subscribed to service %s" % (user, service.name)) + else: + message.reply("Sorry, I couldn't find a service called " + "%s" % service_name) diff --git a/stashboard/main.py b/stashboard/main.py index 5be937d8..9a6d35c3 100755 --- a/stashboard/main.py +++ b/stashboard/main.py @@ -34,7 +34,7 @@ from google.appengine.api import users from google.appengine.ext import webapp from google.appengine.ext.webapp.util import run_wsgi_app -from handlers import site, api, admin +from handlers import site, api, admin, xmpp API = [ (r'/api/(.+)/levels', api.LevelListHandler), #DEPRECATED @@ -98,9 +98,15 @@ (r'/admin', admin.RootHandler), ] +XMPP = [ + ('/_ah/xmpp/message/chat/', xmpp.XmppHandler), + ('/notify/xmpp', xmpp.XmppNotificationHandler), + ] + ROUTES = [] ROUTES.extend(SITE) ROUTES.extend(ADMIN) +ROUTES.extend(XMPP) ROUTES.extend([ ("/admin" + a[0], a[1]) for a in API ]) ROUTES.extend([ (a[0], a[1].readonly()) for a in API ]) ROUTES.append((r'/.*$', site.NotFoundHandler)) diff --git a/stashboard/models.py b/stashboard/models.py index 5ac7ee7c..bf74b93e 100644 --- a/stashboard/models.py +++ b/stashboard/models.py @@ -41,12 +41,12 @@ class InternalEvent(db.Model): class Image(db.Model): - """A service to track + """A graphical representation of a service Properties: - slug -- stirng: URL friendly version of the name + slug -- string: URL friendly version of the name name -- string: The name of this service - path -- stirng: The path to the image + path -- string: The path to the image """ slug = db.StringProperty(required=True) @@ -68,6 +68,7 @@ def load_defaults(cls): def absolute_url(self): return "/images/" + self.path + class List(db.Model): """A list to group service @@ -109,7 +110,6 @@ def rest(self, base_url): return m - class Service(db.Model): """A service to track @@ -129,6 +129,7 @@ def get_by_slug(service_slug): list = db.ReferenceProperty(List) def current_event(self): + event = self.events.order('-start').get() return event @@ -209,6 +210,7 @@ def rest(self, base_url): return m + class Status(db.Model): """A possible system status @@ -230,7 +232,7 @@ def get_default(cls): @classmethod def load_defaults(cls): """ - Install the default statuses. xI am not sure where these should live just yet + Install the default statuses. """ if not cls.get_by_slug("down"): d = cls(name="Down", slug="down", @@ -247,7 +249,9 @@ def load_defaults(cls): if not cls.get_by_slug("warning"): w = cls(name="Warning", slug="warning", image="icons/fugue/exclamation.png", - description="The service is experiencing intermittent problems") + description=("The service is experiencing intermittent ", + "problems") + ) w.put() name = db.StringProperty(required=True) @@ -332,8 +336,23 @@ def rest(self, base_url): return m + class Profile(db.Model): owner = db.UserProperty(required=True) token = db.StringProperty(required=True) secret = db.StringProperty(required=True) + +class Subscription(db.Model): + """A subscription to a service. + + Properties: + type -- string: The type of notifcation to send + address -- string: contract address to send notification to + service -- reference: the service to notify about + """ + + type = db.StringProperty(required=True) + address = db.StringProperty(required=True) + service = db.ReferenceProperty(Service, collection_name="subscriptions") + diff --git a/stashboard/settings.py b/stashboard/settings.py index b34c7007..49b209d4 100644 --- a/stashboard/settings.py +++ b/stashboard/settings.py @@ -2,10 +2,10 @@ DEBUG = False -SITE_NAME = "Stashboard" +SITE_NAME = "AtlasBoard" SITE_AUTHOR = "Colonel Mustache" -SITE_URL = "http://stashbooard.appspot.com" -REPORT_URL = "mailto:help@stashboard.org" +SITE_URL = "http://atlasboard.appspot.com" +REPORT_URL = "mailto:jpolley@atlassian.com" # Twitter update settings TWITTER_CONSUMER_KEY = '' From 3f9714d40e2e7d6e8bb035a94d5fb221f43ee5b1 Mon Sep 17 00:00:00 2001 From: James Polley Date: Sat, 24 Nov 2012 14:01:47 +1000 Subject: [PATCH 2/2] Revert unintended changes to settings.py --- stashboard/settings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stashboard/settings.py b/stashboard/settings.py index 49b209d4..b34c7007 100644 --- a/stashboard/settings.py +++ b/stashboard/settings.py @@ -2,10 +2,10 @@ DEBUG = False -SITE_NAME = "AtlasBoard" +SITE_NAME = "Stashboard" SITE_AUTHOR = "Colonel Mustache" -SITE_URL = "http://atlasboard.appspot.com" -REPORT_URL = "mailto:jpolley@atlassian.com" +SITE_URL = "http://stashbooard.appspot.com" +REPORT_URL = "mailto:help@stashboard.org" # Twitter update settings TWITTER_CONSUMER_KEY = ''