Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
35f805a
Add custom 404 page.
tekd Mar 16, 2016
6e04e83
Register error_handlers blueprint for custom 404 page.
toolness Mar 16, 2016
c34f2eb
Add slack integration.
toolness Mar 17, 2016
0bad790
Add unit tests for slack integration.
toolness Mar 17, 2016
b25046c
Changed tutorial box text for Discourse. Fixes #296.
toolness Mar 22, 2016
e3a7295
reload dev server when yaml files change.
toolness Mar 22, 2016
f5aa309
link to discourse category in activity feed.
toolness Mar 22, 2016
75ea140
Merge pull request #297 from GovLab/show-discourse-category
toolness Mar 22, 2016
c3eb32a
Only show discourse category if it's non-null.
toolness Mar 22, 2016
7564e34
Open discourse links in same tab. Fixes #298.
toolness Mar 22, 2016
2e66e12
swap discourse and view network buttons on toolbar (#299).
toolness Mar 22, 2016
3dcebb5
Refer to 'Discourse' as 'Discuss'. Fixes #299.
toolness Mar 22, 2016
65d07e6
Show every Discourse post w/ excerpt. Fixes #301.
toolness Mar 22, 2016
426fc5f
rebase hrefs from discourse snippets.
toolness Mar 23, 2016
cada612
Add migrations for discourse_topics changes.
toolness Mar 23, 2016
c2d52af
Merge pull request #302 from GovLab/show-all-discourse-post-excerpts
toolness Mar 23, 2016
4ffb783
Make AdminTestCase easier to reuse.
toolness Mar 23, 2016
5498bb2
Add discourse admin page.
toolness Mar 23, 2016
dfe852c
Link discourse topic name, change 'read more' to 'reply'. Fixes #304.
toolness Apr 6, 2016
fa38182
Do not escape <p> tags in blog posts
toolness Apr 19, 2016
2d495f0
Add custom 404 page.
tekd Mar 16, 2016
ec0468b
Register error_handlers blueprint for custom 404 page.
toolness Mar 16, 2016
a8457a3
Add error_handlers
tekd Apr 27, 2016
0b8d943
Edit copy
tekd Apr 27, 2016
370c75e
Fix merge conflicts
tekd Apr 27, 2016
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
4 changes: 3 additions & 1 deletion app/blog_posts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
Expand Down
10 changes: 10 additions & 0 deletions app/config/local_config.sample.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion app/discourse/__init__.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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')
Expand Down
16 changes: 16 additions & 0 deletions app/discourse/admin.py
Original file line number Diff line number Diff line change
@@ -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'))
82 changes: 63 additions & 19 deletions app/discourse/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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'<a href="/blarg">hi</a>', 'http://bop')
u'<a href="http://bop/blarg">hi</a>'
'''

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)

Expand All @@ -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')
Expand Down
8 changes: 8 additions & 0 deletions app/error_handlers.py
Original file line number Diff line number Diff line change
@@ -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
26 changes: 21 additions & 5 deletions app/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -71,24 +83,25 @@ 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")
configure_from_os_environment(app.config)
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)
Expand All @@ -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)
Expand Down Expand Up @@ -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'])
Expand Down
42 changes: 42 additions & 0 deletions app/slack.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions app/static/sass/_activity.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down
20 changes: 20 additions & 0 deletions app/static/sass/_error-page.scss
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions app/static/sass/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading