diff --git a/app/admin.py b/app/admin.py
index 28f6315..0597a0f 100644
--- a/app/admin.py
+++ b/app/admin.py
@@ -119,6 +119,14 @@ def scaffold_form(self):
return form_class
+def add_admin_view(app, view):
+ if not (hasattr(app, 'extensions') and
+ len(app.extensions.get('admin', [])) == 1):
+ return
+
+ app.extensions['admin'][0].add_view(view)
+
+
def init_app(app):
admin = Admin(app, name=NAME, template_mode='bootstrap3')
admin.add_view(UserModelView(User, db.session))
diff --git a/app/blog_posts.py b/app/blog_posts.py
index bb63432..e7d572d 100644
--- a/app/blog_posts.py
+++ b/app/blog_posts.py
@@ -3,6 +3,8 @@
from flask import Markup
import feedparser
+ALLOWED_TAGS = ['p']
+
def truncate(text, max_words):
'''
Truncates the given text with an ellipsis if it's more than the given
@@ -28,7 +30,7 @@ def summarize(feed, max_entries=3, max_words_per_entry=20):
desc = truncate(entry.description, max_words_per_entry)
post = dict(
title=entry.title,
- description=Markup(bleach.clean(desc)),
+ description=Markup(bleach.clean(desc, tags=ALLOWED_TAGS)),
link=entry.link,
domain=urlparse.urlparse(entry.link).netloc
)
diff --git a/app/config/local_config.sample.yml b/app/config/local_config.sample.yml
index 85973b8..5ae1792 100755
--- a/app/config/local_config.sample.yml
+++ b/app/config/local_config.sample.yml
@@ -127,3 +127,13 @@ SECRET_KEY: i_am_insecure
# api_key: PUT_YOUR_DISCOURSE_API_KEY_HERE
# origin: http://discourse-dev.networkofinnovators.org
# sso_secret: PUT_YOUR_SSO_SECRET_HERE
+
+# Slack Integration
+# -----------------
+
+# Events that occur in NoI can automatically be posted to Slack via a
+# Slack Incoming Webhook. For more information, see:
+#
+# https://api.slack.com/incoming-webhooks
+#
+# SLACK_WEBHOOK_URL: https://hooks.slack.com/services/T00000000/B00000000/XXXX
diff --git a/app/discourse/__init__.py b/app/discourse/__init__.py
index ef0c512..d8b552b 100644
--- a/app/discourse/__init__.py
+++ b/app/discourse/__init__.py
@@ -1,8 +1,9 @@
from flask_security.signals import user_registered, user_confirmed
from flask.ext.login import user_logged_out
+from ..admin import add_admin_view
from ..signals import user_changed_profile
-from . import views, sso
+from . import views, sso, admin
from .config import DiscourseConfig
def init_app(app):
@@ -12,6 +13,9 @@ def init_app(app):
app.jinja_env.globals['discourse_url'] = config.url
app.register_blueprint(views.views)
+ add_admin_view(app, admin.DiscourseView(name='Discourse',
+ endpoint='discourse_admin'))
+
if app.config['NOI_CAN_UNCONFIRMED_USERS_FULLY_REGISTER']:
raise Exception('Discourse support is incompatible with '
'NOI_CAN_UNCONFIRMED_USERS_FULLY_REGISTER')
diff --git a/app/discourse/admin.py b/app/discourse/admin.py
new file mode 100644
index 0000000..d8a5747
--- /dev/null
+++ b/app/discourse/admin.py
@@ -0,0 +1,16 @@
+from flask import flash, redirect, url_for
+from flask_admin import Admin, BaseView, expose
+
+from ..admin import AdminPermissionRequiredMixin
+from . import models
+
+class DiscourseView(AdminPermissionRequiredMixin, BaseView):
+ @expose('/')
+ def index(self):
+ return self.render('admin/discourse_index.html')
+
+ @expose('/topics/recache', methods=['POST'])
+ def recache_topics(self):
+ models.DiscourseTopicEvent.update()
+ flash('Discourse topics recached.')
+ return redirect(url_for('discourse_admin.index'))
diff --git a/app/discourse/models.py b/app/discourse/models.py
index c0e5f28..f36192b 100644
--- a/app/discourse/models.py
+++ b/app/discourse/models.py
@@ -1,5 +1,5 @@
import datetime
-from sqlalchemy import types, Column, ForeignKey
+from sqlalchemy import types, Column, ForeignKey, UniqueConstraint
from ..models import db, User
from .. import models
@@ -15,12 +15,25 @@ def parse_iso_datetime(text):
return datetime.datetime.strptime(text, "%Y-%m-%dT%H:%M:%S.%fZ")
+def rebase_hrefs(html, origin=None):
+ '''
+ >>> rebase_hrefs(u'hi', 'http://bop')
+ u'hi'
+ '''
+
+ if origin is None:
+ origin = config.origin
+
+ return html.replace(u'href="/', u'href="%s/' % unicode(origin))
+
class DiscourseTopicEvent(models.UserEvent):
__tablename__ = 'discourse_topics'
id = Column(types.Integer, ForeignKey('user_events.id'), primary_key=True)
- discourse_id = Column(types.Integer, unique=True)
+ discourse_id = Column(types.Integer)
+
+ post_number = Column(types.Integer)
slug = Column(types.Text)
@@ -30,45 +43,76 @@ class DiscourseTopicEvent(models.UserEvent):
posts_count = Column(types.Integer)
+ category_name = Column(types.Text)
+
+ category_slug = Column(types.Text)
+
+ __table_args__ = (UniqueConstraint('discourse_id', 'post_number'),)
+
__mapper_args__ = {
'polymorphic_identity': 'discourse_topic_event'
}
+ @property
+ def cleaned_excerpt(self):
+ return rebase_hrefs(self.excerpt or '')
+
@property
def url(self):
- return config.url('/t/%s/%d' % (self.slug, self.discourse_id))
+ return config.url('/t/%s/%d/%d' % (self.slug, self.discourse_id,
+ self.post_number or 0))
+
+ @property
+ def category_url(self):
+ return config.url('/c/%s' % self.category_slug)
@classmethod
- def _get_or_create(cls, discourse_id):
+ def _get_or_create(cls, discourse_id, post_number=None):
msg = db.session.query(cls).\
- filter_by(discourse_id=discourse_id).first()
+ filter_by(discourse_id=discourse_id,
+ post_number=post_number).first()
if msg is None:
- msg = cls(discourse_id=discourse_id)
+ msg = cls(discourse_id=discourse_id, post_number=post_number)
return msg
@classmethod
- def _update_category(cls, category):
- topics = category.get('topics', [])
- for topic in topics:
- if not topic['visible']: continue
- msg = cls._get_or_create(discourse_id=topic['id'])
- msg.created_at = parse_iso_datetime(topic['created_at'])
- msg.updated_at = parse_iso_datetime(topic['bumped_at'])
+ def _update_topic(cls, category, topic):
+ req = api.get('/t/%d/last.json' % topic['id'])
+
+ if req.status_code != 200:
+ return req.raise_for_status()
+
+ topic_detail = req.json()
+
+ for post in topic_detail['post_stream']['posts']:
+ if post['hidden'] or not post['cooked']: continue
+
+ msg = cls._get_or_create(discourse_id=topic['id'],
+ post_number=post['post_number'])
+ msg.created_at = parse_iso_datetime(post['created_at'])
+ msg.updated_at = parse_iso_datetime(post['updated_at'])
msg.slug = topic['slug']
- user = User.find_by_username(topic['last_poster']['username'])
+ msg.category_name = category['name']
+ msg.category_slug = category['slug']
+
+ user = User.find_by_username(post['username'])
msg.user = user
- # Argh, it looks like only pinned topics have excerpts
- # for now:
- #
- # https://meta.discourse.org/t/get-excerpt-for-regular-topics/33482
- msg.excerpt = topic.get('excerpt')
+ msg.excerpt = post['cooked']
msg.title = topic['title']
msg.posts_count = topic['posts_count']
db.session.add(msg)
+ @classmethod
+ def _update_category(cls, category):
+ topics = category.get('topics', [])
+ for topic in topics:
+ if not topic['visible']: continue
+
+ cls._update_topic(category, topic)
+
@classmethod
def update(cls):
req = api.get('/categories.json')
diff --git a/app/error_handlers.py b/app/error_handlers.py
new file mode 100644
index 0000000..65b1515
--- /dev/null
+++ b/app/error_handlers.py
@@ -0,0 +1,8 @@
+import flask
+from flask import Blueprint, render_template
+
+blueprint = flask.Blueprint('error_handlers', __name__)
+
+@blueprint.app_errorhandler(404)
+def page_not_found(e):
+ return render_template('404.html'), 404
diff --git a/app/factory.py b/app/factory.py
index 93896ed..610e930 100644
--- a/app/factory.py
+++ b/app/factory.py
@@ -12,7 +12,7 @@
from app import (csrf, cache, mail, bcrypt, s3, assets, security, admin,
babel, alchemydumps, sass, email_errors, csp, oauth,
- linkedin, discourse,
+ linkedin, discourse, slack, error_handlers,
QUESTIONNAIRES, NOI_COLORS, LEVELS, ORG_TYPES,
QUESTIONS_BY_ID, LEVELS_BY_SCORE, QUESTIONNAIRES_BY_ID)
from app.forms import (NOIForgotPasswordForm, NOILoginForm,
@@ -41,6 +41,18 @@
'GA_TRACKING_CODE'
]
+CONFIG_YML_FILENAME = '/noi/app/config/config.yml'
+
+LOCAL_CONFIG_YML_FILENAME = '/noi/app/config/local_config.yml'
+
+DEPLOYMENTS_YML_FILENAME = '/noi/app/data/deployments.yaml'
+
+YML_FILENAMES = [
+ CONFIG_YML_FILENAME,
+ LOCAL_CONFIG_YML_FILENAME,
+ DEPLOYMENTS_YML_FILENAME
+]
+
class DeploySQLAlchemyUserDatastore(SQLAlchemyUserDatastore):
'''
Subclass of SQLAlchemyUserDatastore that overrides `get_user` to take app
@@ -71,12 +83,12 @@ def create_app(config=None): #pylint: disable=too-many-statements
'''
app = Flask(__name__)
- with open('/noi/app/config/config.yml', 'r') as config_file:
+ with open(CONFIG_YML_FILENAME, 'r') as config_file:
app.config.update(yaml.load(config_file))
if config is None:
try:
- with open('/noi/app/config/local_config.yml', 'r') as config_file:
+ with open(LOCAL_CONFIG_YML_FILENAME, 'r') as config_file:
app.config.update(yaml.load(config_file))
except IOError:
app.logger.warn("No local_config.yml file")
@@ -84,11 +96,12 @@ def create_app(config=None): #pylint: disable=too-many-statements
else:
app.config.update(config)
- with open('/noi/app/data/deployments.yaml') as deployments_yaml:
+ with open(DEPLOYMENTS_YML_FILENAME) as deployments_yaml:
deployments = yaml.load(deployments_yaml)
l10n.configure_app(app)
+ app.register_blueprint(error_handlers.blueprint)
app.register_blueprint(views)
if app.config['DEBUG']:
app.register_blueprint(style_guide.views)
@@ -103,12 +116,16 @@ def create_app(config=None): #pylint: disable=too-many-statements
if not app.config['DEBUG'] and app.config.get('ADMINS'):
email_errors.init_app(app)
+ admin.init_app(app)
+
oauth.init_app(app)
if 'LINKEDIN' in app.config:
app.jinja_env.globals['LINKEDIN_ENABLED'] = True
app.register_blueprint(linkedin.views)
if 'DISCOURSE' in app.config:
discourse.init_app(app)
+ if 'SLACK_WEBHOOK_URL' in app.config:
+ slack.init_app(app)
cache.init_app(app)
csrf.init_app(app)
@@ -146,7 +163,6 @@ def create_app(config=None): #pylint: disable=too-many-statements
app.config['SEARCH_DEPLOYMENTS'].append(noi_deploy)
babel.init_app(app)
l10n.init_app(app)
- admin.init_app(app)
app.config['DOMAINS'] = this_deployment.get('domains',
default_deployment['domains'])
diff --git a/app/slack.py b/app/slack.py
new file mode 100644
index 0000000..df20bab
--- /dev/null
+++ b/app/slack.py
@@ -0,0 +1,42 @@
+import requests
+from flask import current_app, url_for
+from flask_script import Manager
+from flask_security.signals import user_registered
+
+from signals import user_changed_profile
+
+def init_app(app):
+ assert 'SLACK_WEBHOOK_URL' in app.config
+
+ @user_registered.connect_via(app)
+ def when_user_registered(sender, user, confirm_token, **extra):
+ post_user_message(user, 'just registered.')
+
+ @user_changed_profile.connect_via(app)
+ def when_user_changed_profile(sender, user, avatar_changed=False, **extra):
+ text = 'changed their profile.'
+ if avatar_changed:
+ text = 'changed their avatar.'
+ post_user_message(user, text)
+
+def post_user_message(user, text):
+ url = url_for('views.get_user', userid=user.id, _external=True)
+ post_message(u'<%s|%s> %s' % (url, user.full_name, text))
+
+def post_message(text):
+ res = requests.post(
+ current_app.config['SLACK_WEBHOOK_URL'],
+ json={'text': text}
+ )
+ if res.status_code != 200:
+ return res.raise_for_status()
+
+SlackCommand = manager = Manager(usage='Manage Slack integration.')
+
+@manager.command
+def post(text='Hello! This is just a message to test slack integration.'):
+ '''
+ Post a message with the given optional text.
+ '''
+
+ post_message(text)
diff --git a/app/static/sass/_activity.scss b/app/static/sass/_activity.scss
index 902e89c..606901a 100644
--- a/app/static/sass/_activity.scss
+++ b/app/static/sass/_activity.scss
@@ -142,6 +142,14 @@
}
}
+ .e-discourse-category {
+ font-size: 10px;
+ padding: 2px;
+ color: gray;
+ border: 1px dotted lightgray;
+ white-space: pre;
+ }
+
.e-feed-message {
flex: 1; /* NEW, Spec - Firefox, Chrome, Opera */
diff --git a/app/static/sass/_error-page.scss b/app/static/sass/_error-page.scss
new file mode 100644
index 0000000..3382fcb
--- /dev/null
+++ b/app/static/sass/_error-page.scss
@@ -0,0 +1,20 @@
+.e-error-display {
+ background-color: $blue;
+ text-align: center;
+ text-transform: uppercase;
+ padding-top: 4em;
+ h2,h3,h4 {
+ color: $white-80;;
+ padding: .5em;
+ }
+}
+
+.error-page-icon {
+ color: $white-80;;
+ font-size: 4em;
+ }
+
+.e-error-display a {
+ color: $white-90;
+ font-weight: bold;
+}
diff --git a/app/static/sass/styles.scss b/app/static/sass/styles.scss
index a37f45d..bef6bbb 100644
--- a/app/static/sass/styles.scss
+++ b/app/static/sass/styles.scss
@@ -96,6 +96,8 @@ body {
@import 'desktop-hack';
+@import 'error-page';
+
/* Useful for making blocks of content that we don't have time to style
* not look utterly unstyled and horrible. */
.b-temporary-styling {
diff --git a/app/templates/404.html b/app/templates/404.html
new file mode 100644
index 0000000..e83fc63
--- /dev/null
+++ b/app/templates/404.html
@@ -0,0 +1,15 @@
+{% extends '__base_ui__.html' %}
+
+{% block title %}Page Not Found | Network of Innovators{% endblock %}
+{% block body_class %}b-landing-page{% endblock %}
+
+{% block content %}
+ Error 404
+ We couldn't find what you were looking for
+ Try using the navigation above or go back to the homepage
+
{{ gettext("You can now participate in our Discourse forum! Check it out.") }}
+{% call render_tutorial_step(4, max_step) %} +{{ gettext("Have a question or something to share with the network? Discuss it with like-minded innovators in one of our channels!") }}
{% endcall %} {% endif %} diff --git a/app/templates/admin/discourse_index.html b/app/templates/admin/discourse_index.html new file mode 100644 index 0000000..e4d495e --- /dev/null +++ b/app/templates/admin/discourse_index.html @@ -0,0 +1,19 @@ +{% extends "admin/master.html" %} + +{% block body %} + ++ This will recache Discourse topics in the activity feed. Use this + if the activity feed isn't displaying the latest Discourse topics. +
+Hello
', + 'post_number': 1, + 'created_at': '2016-02-15T14:27:48.062Z', + 'updated_at': '2016-02-18T14:27:48.103Z', + 'username': 'system', + } + fake_topic_detail = { + 'post_stream': { + 'posts': [fake_post] + } + } - get.return_value.status_code = 200 - get.return_value.json.return_value = fake_categories + def get_url(url): + retval = mock.MagicMock() + retval.raise_for_status.side_effect = Exception('kaboom') + if url == '/categories.json': + retval.status_code = 200 + retval.json.return_value = fake_categories + else: + retval.status_code = 200 + retval.json.return_value = fake_topic_detail + return retval + + get.side_effect = get_url DiscourseTopicEvent.update() - get.assert_called_once_with('/categories.json') - get.return_value.raise_for_status.assert_not_called() + get.assert_has_calls([ + mock.call('/categories.json'), + mock.call('/t/14/last.json') + ]) events = db.session.query(Event).all() self.assertEqual(len(events), 1) @@ -251,21 +288,23 @@ def test_update_works(self, get): datetime.datetime(2016, 2, 18, 14, 27, 48, 103000)) self.assertEqual(event.slug, 'hello-there') self.assertIsNone(event.user) - self.assertIsNone(event.excerpt) + self.assertEqual(event.excerpt, 'Hello
') self.assertEqual(event.title, 'Hello There') + self.assertEqual(event.category_name, 'Funky Things') + self.assertEqual(event.category_slug, 'funky-things') self.assertEqual(event.posts_count, 6) - # Now simulate a new reply. + # Now simulate an edited post. - fake_topics[1]['bumped_at'] = '2016-02-20T14:27:48.103Z' - fake_topics[1]['posts_count'] += 1 + fake_post['updated_at'] = '2016-02-20T14:27:48.103Z' + fake_post['cooked'] = 'Blah
' DiscourseTopicEvent.update() self.assertEqual(db.session.query(Event).all(), [event]) self.assertEqual(event.updated_at, datetime.datetime(2016, 2, 20, 14, 27, 48, 103000)) - self.assertEqual(event.posts_count, 7) + self.assertEqual(event.excerpt, 'Blah
') def test_get_or_create_can_get_existing(self): evt = DiscourseTopicEvent(discourse_id=15) @@ -301,6 +340,25 @@ def test_parent_event_is_deleted(self): self.assertEqual(db.session.query(Event).count(), 0) + +class AdminViewTests(AdminTestCase): + BASE_APP_CONFIG = AdminTestCase.BASE_APP_CONFIG.copy() + + BASE_APP_CONFIG.update(DISCOURSE=FAKE_DISCOURSE_CONFIG) + + def test_index_works(self): + self.login_as_admin_user() + self.assert200(self.client.get('/admin/discourse_admin/')) + + @mock.patch('app.discourse.models.DiscourseTopicEvent.update') + def test_recache_topics_works(self, update): + self.login_as_admin_user() + self.assertRedirects( + self.client.post('/admin/discourse_admin/topics/recache'), + '/admin/discourse_admin/' + ) + update.assert_called_once_with() + class ViewTests(ViewTestCase): BASE_APP_CONFIG = ViewTestCase.BASE_APP_CONFIG.copy() diff --git a/app/tests/test_slack.py b/app/tests/test_slack.py new file mode 100644 index 0000000..944e082 --- /dev/null +++ b/app/tests/test_slack.py @@ -0,0 +1,57 @@ +from flask import Flask +import mock +import flask_testing +from flask_security.signals import user_registered + +from ..signals import user_changed_profile +from .test_views import ViewTestCase +from .. import slack + +class SlackTestCase(flask_testing.TestCase): + def create_app(self): + app = Flask('slack_test') + app.config['SLACK_WEBHOOK_URL'] = 'http://slack/123' + slack.init_app(app) + return app + +@mock.patch('app.slack.post_user_message') +class SignalTests(SlackTestCase): + def test_user_changed_avatar(self, post_user_message): + user_changed_profile.send(self.app, user='user', avatar_changed=True) + post_user_message.assert_called_with('user', 'changed their avatar.') + + def test_user_changed_profile(self, post_user_message): + user_changed_profile.send(self.app, user='user', avatar_changed=False) + post_user_message.assert_called_with('user', 'changed their profile.') + + def test_user_registered(self, post_user_message): + user_registered.send(self.app, user='user', confirm_token='blah') + post_user_message.assert_called_with('user', 'just registered.') + +@mock.patch('requests.post') +class PostMessageTests(SlackTestCase): + def test_it_works(self, post): + post.return_value.status_code = 200 + + slack.post_message('hi') + + post.assert_called_once_with('http://slack/123', json={'text': 'hi'}) + post.return_value.raise_for_status.assert_not_called() + + def test_it_raises_exception_when_not_ok(self, post): + post.return_value.status_code = 500 + + slack.post_message('hi') + + post.assert_called_once_with('http://slack/123', json={'text': 'hi'}) + post.return_value.raise_for_status.assert_called_once_with() + +@mock.patch('app.slack.post_message') +class PostUserMessageTests(ViewTestCase): + def test_it_works(self, post_message): + self.login(first_name='Boop', last_name='Jones') + user = self.last_created_user + slack.post_user_message(user, 'is cool') + post_message.assert_called_once_with( + u'