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_outline +

Error 404

+

We couldn't find what you were looking for

+

Try using the navigation above or go back to the homepage

+
+
+{% endblock %} diff --git a/app/templates/__base_ui__.html b/app/templates/__base_ui__.html index c815d80..5bb925c 100644 --- a/app/templates/__base_ui__.html +++ b/app/templates/__base_ui__.html @@ -31,14 +31,10 @@

{{ gettext('Network of Innovators') }}

search {{ gettext('Find Innovators') }} - - language - {{ gettext('View Network') }} - {% if DISCOURSE_ENABLED %} - + feedback - {{ gettext('Discourse') }} + {{ gettext('Discuss') }} {% else %} @@ -46,6 +42,10 @@

{{ gettext('Network of Innovators') }}

{{ gettext('Share Feedback') }}
{% endif %} + + language + {{ gettext('View Network') }} + diff --git a/app/templates/_activity_events.html b/app/templates/_activity_events.html index d8e296a..2990417 100644 --- a/app/templates/_activity_events.html +++ b/app/templates/_activity_events.html @@ -31,20 +31,19 @@ {% endif %}
{% if event.user %} -

{{ gettext('%(user)s posted in Discourse.', user=user_link(event.user)) }}

+

{{ gettext('%(user)s posted in Discuss.', user=user_link(event.user)) }}

{% endif %} -

{{ event.title }}

+

+ {{ event.title }} + {% if event.category_name %}{{ event.category_name }}{% endif %} +

{% if event.excerpt %} -

{{ event.excerpt|safe }}

+

{{ event.cleaned_excerpt|safe }}

{% endif %}
- open_in_new + open_in_new - {% if event.posts_count > 1 %} - {{ gettext("Read %(posts_count)s replies on Discourse", posts_count=event.posts_count) }} - {% else %} - {{ gettext("Read more on Discourse") }} - {% endif %} + {{ gettext("Reply on Discuss") }}
diff --git a/app/templates/activity.html b/app/templates/activity.html index fcdde1a..12f46f9 100644 --- a/app/templates/activity.html +++ b/app/templates/activity.html @@ -151,8 +151,8 @@ {% endcall %} {% if DISCOURSE_ENABLED %} -{% call render_tutorial_step(4, max_step, extra_classes="on-right-side") %} -

{{ 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. +

+
+
+{% endblock %} diff --git a/app/tests/test_admin.py b/app/tests/test_admin.py index ad47232..4ac4993 100644 --- a/app/tests/test_admin.py +++ b/app/tests/test_admin.py @@ -11,6 +11,12 @@ class AdminTestCase(ViewTestCase): BASE_APP_CONFIG['ADMIN_UI_USERS'] = ['admin@example.org'] + def login_as_normal_user(self): + self.login('normal@example.org', 'password') + + def login_as_admin_user(self): + self.login('admin@example.org', 'password') + def setUp(self): super(AdminTestCase, self).setUp() self.admin_user = self.create_user(u'admin@example.org', 'password') @@ -23,11 +29,11 @@ def test_anonymous_users_are_redirected_to_login(self): '/login?next=%2Fadmin%2Fuser%2F') def test_non_admin_users_receive_403(self): - self.login('normal@example.org', 'password') + self.login_as_normal_user() self.assert403(self.client.get('/admin/user/')) def test_admin_users_are_not_redirected_to_login(self): - self.login('admin@example.org', 'password') + self.login_as_admin_user() self.assert200(self.client.get('/admin/user/')) @@ -37,11 +43,11 @@ def test_anonymous_users_are_redirected_to_login(self): '/login?next=%2Fadmin%2Fstats%2F') def test_non_admin_users_receive_403(self): - self.login('normal@example.org', 'password') + self.login_as_normal_user() self.assert403(self.client.get('/admin/stats/')) def test_admin_users_are_not_redirected_to_login(self): - self.login('admin@example.org', 'password') + self.login_as_admin_user() self.assert200(self.client.get('/admin/stats/')) diff --git a/app/tests/test_discourse.py b/app/tests/test_discourse.py index 5f2ecc1..22d68b6 100644 --- a/app/tests/test_discourse.py +++ b/app/tests/test_discourse.py @@ -9,6 +9,7 @@ from .test_views import ViewTestCase from .test_models import DbTestCase +from .test_admin import AdminTestCase from ..models import User, Event, db from ..signals import user_changed_profile from ..discourse import sso, api @@ -193,8 +194,22 @@ class DiscourseTopicEventTests(DbTestCase): BASE_APP_CONFIG.update(DISCOURSE=FAKE_DISCOURSE_CONFIG) def test_url_works(self): + evt = DiscourseTopicEvent(discourse_id=5, post_number=1, + slug='beep-boop') + self.assertEqual(evt.url, 'http://discourse/t/beep-boop/5/1') + + def test_url_works_with_no_post_number(self): evt = DiscourseTopicEvent(discourse_id=5, slug='beep-boop') - self.assertEqual(evt.url, 'http://discourse/t/beep-boop/5') + self.assertEqual(evt.url, 'http://discourse/t/beep-boop/5/0') + + def test_category_url_works(self): + evt = DiscourseTopicEvent(category_slug='beep-boop') + self.assertEqual(evt.category_url, 'http://discourse/c/beep-boop') + + def test_cleaned_excerpt_works(self): + evt = DiscourseTopicEvent(excerpt='hi') + self.assertEqual(evt.cleaned_excerpt, + 'hi') @mock.patch('app.discourse.api.get') def test_update_works(self, get): @@ -206,12 +221,7 @@ def test_update_works(self, get): { 'visible': True, 'id': 14, - 'bumped_at': '2016-02-18T14:27:48.103Z', - 'created_at': '2016-02-15T14:27:48.062Z', 'posts_count': 6, - 'last_poster': { - 'username': 'system' - }, 'title': 'Hello There', 'slug': 'hello-there', } @@ -225,19 +235,46 @@ def test_update_works(self, get): }, { 'read_restricted': False, + 'name': 'Funky Things', + 'slug': 'funky-things', 'topics': fake_topics } ] } } + fake_post = { + 'hidden': False, + 'cooked': '

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' is cool' % user.id + ) diff --git a/manage.py b/manage.py index 5aca757..29d484c 100644 --- a/manage.py +++ b/manage.py @@ -14,8 +14,8 @@ ] + sys.argv) from app import (mail, models, sass, email_errors, LEVELS, ORG_TYPES, stats, - questionnaires, blog_posts, linkedin) -from app.factory import create_app + questionnaires, blog_posts, linkedin, slack) +from app.factory import create_app, YML_FILENAMES from app.models import db, User from app.utils import csv_reader from app.tests.factories import UserFactory @@ -28,7 +28,7 @@ from flask_mail import Message from flask_security.recoverable import send_reset_password_instructions from flask.ext.script import Command -from flask.ext.script.commands import InvalidCommand +from flask.ext.script.commands import InvalidCommand, Server, Shell from random import choice from sqlalchemy.exc import IntegrityError @@ -45,11 +45,15 @@ app = create_app() #pylint: disable=invalid-name migrate = Migrate(app, db) #pylint: disable=invalid-name -manager = Manager(app) #pylint: disable=invalid-name +manager = Manager(app, with_default_commands=False) + +manager.add_command('shell', Shell()) +manager.add_command('runserver', Server(extra_files=YML_FILENAMES)) manager.add_command('discourse', DiscourseCommand) manager.add_command('noi1', Noi1Command) manager.add_command('db', MigrateCommand) +manager.add_command('slack', slack.SlackCommand) #manager.add_command("assets", ManageAssets) alchemydumps = AlchemyDumps(app, db) diff --git a/migrations/versions/28ff85fcc188_.py b/migrations/versions/28ff85fcc188_.py new file mode 100644 index 0000000..4605969 --- /dev/null +++ b/migrations/versions/28ff85fcc188_.py @@ -0,0 +1,26 @@ +"""add post_number column to discourse_topics + +Revision ID: 28ff85fcc188 +Revises: 312a48268855 +Create Date: 2016-03-22 22:00:07.520355 + +""" + +# revision identifiers, used by Alembic. +revision = '28ff85fcc188' +down_revision = '312a48268855' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('discourse_topics', sa.Column('post_number', sa.Integer(), nullable=True)) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('discourse_topics', 'post_number') + ### end Alembic commands ### diff --git a/migrations/versions/312a48268855_.py b/migrations/versions/312a48268855_.py new file mode 100644 index 0000000..3132ccf --- /dev/null +++ b/migrations/versions/312a48268855_.py @@ -0,0 +1,28 @@ +"""Add category_name, category_slug to discourse_topics + +Revision ID: 312a48268855 +Revises: 4309a04aaea9 +Create Date: 2016-03-22 12:03:26.330530 + +""" + +# revision identifiers, used by Alembic. +revision = '312a48268855' +down_revision = '4309a04aaea9' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('discourse_topics', sa.Column('category_name', sa.Text(), nullable=True)) + op.add_column('discourse_topics', sa.Column('category_slug', sa.Text(), nullable=True)) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('discourse_topics', 'category_slug') + op.drop_column('discourse_topics', 'category_name') + ### end Alembic commands ### diff --git a/migrations/versions/51bdb7c7928a_.py b/migrations/versions/51bdb7c7928a_.py new file mode 100644 index 0000000..2c15093 --- /dev/null +++ b/migrations/versions/51bdb7c7928a_.py @@ -0,0 +1,28 @@ +"""enforce uniqueness based on discourse_id and post_number + +Revision ID: 51bdb7c7928a +Revises: 28ff85fcc188 +Create Date: 2016-03-22 22:02:14.557660 + +""" + +# revision identifiers, used by Alembic. +revision = '51bdb7c7928a' +down_revision = '28ff85fcc188' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(u'discourse_topics_discourse_id_key', 'discourse_topics', type_='unique') + op.create_unique_constraint(None, 'discourse_topics', ['discourse_id', 'post_number']) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'discourse_topics', type_='unique') + op.create_unique_constraint(u'discourse_topics_discourse_id_key', 'discourse_topics', ['discourse_id']) + ### end Alembic commands ###