├── .gitignore ├── LICENSE ├── README.md ├── app ├── __init__.py ├── api_1_0 │ ├── __init__.py │ ├── comments.py │ └── errors.py ├── auth │ ├── __init__.py │ ├── forms.py │ └── routes.py ├── emails.py ├── models.py ├── static │ └── styles.css ├── talks │ ├── __init__.py │ ├── forms.py │ └── routes.py └── templates │ ├── _api_client.html │ ├── auth │ └── login.html │ ├── base.html │ ├── email │ ├── notify.html │ └── notify.txt │ └── talks │ ├── _comment.html │ ├── _comments.html │ ├── _talk_header.html │ ├── _talks.html │ ├── edit_talk.html │ ├── index.html │ ├── moderate.html │ ├── profile.html │ ├── talk.html │ └── user.html ├── config.py ├── manage.py ├── requirements.txt └── tests ├── __init__.py ├── test_api.py ├── test_comment_model.py ├── test_pending_email_model.py ├── test_talk_model.py └── test_user_model.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | 38 | # SQLite databases 39 | *.sqlite 40 | 41 | # Virtual environment 42 | venv 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Miguel Grinberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Flask By Example 2 | ================ 3 | 4 | Code for my PyCon 2014 tutorial "Flask By Example". 5 | 6 | Pre-requisites 7 | -------------- 8 | 9 | - Some previous Python coding experience 10 | - Basic knowledge of HTML and CSS 11 | - A bit of JavaScript will definitely not hurt 12 | 13 | Requirements 14 | ------------ 15 | 16 | - Python 2.7 or 3.3+ on any supported OS (even Windows!) 17 | - virtualenv (or pyvenv if you are using Python 3.4) 18 | - git 19 | - Network connection (only to install the application) 20 | 21 | Setup 22 | ----- 23 | 24 | The tutorial does not have a hands-on portion. For that reason it is not required that you come with your laptop with everything installed; you can come unplugged and just listen and learn if you like. 25 | 26 | However, you will want to work with this application once you learn all about it in class, so I recommend that you try to install it ahead of time and have a chance to talk to me if you run into problems. 27 | 28 | Please make sure your computer meets all the requirements listed above before you begin. Below are step-by-step installation instructions: 29 | 30 | **Step 1**: Clone the git repository 31 | 32 | $ git clone https://github.com/miguelgrinberg/flask-pycon2014.git 33 | $ cd flask-pycon2014 34 | 35 | **Step 2**: Create a virtual environment. 36 | 37 | For Linux, OSX or any other platform that uses *bash* as command prompt (including Cygwin on Windows): 38 | 39 | $ virtualenv venv 40 | $ source venv/bin/activate 41 | (venv) $ pip install -r requirements.txt 42 | 43 | For Windows users working on the standard command prompt: 44 | 45 | > virtualenv venv 46 | > venv\scripts\activate 47 | (venv) > pip install -r requirements.txt 48 | 49 | **Step 3**: Create an administrator user 50 | 51 | (venv) $ python manage.py adduser --admin 52 | Password: 53 | Confirm: 54 | User was registered successfully. 55 | 56 | **Step 4**: Configure a gmail account for the application to send emails from. 57 | 58 | For Linux, OSX or any other platform that uses *bash* as command prompt: 59 | 60 | (venv) $ export MAIL_USERNAME= 61 | (venv) $ export MAIL_PASSWORD= 62 | 63 | For Windows users working on the standard command prompt: 64 | 65 | (venv) > set MAIL_USERNAME= 66 | (venv) > set MAIL_PASSWORD= 67 | 68 | **Step 5**: Start the application: 69 | 70 | (venv) $ python manage.py runserver 71 | * Running on http://127.0.0.1:5000/ 72 | * Restarting with reloader 73 | 74 | Now open your web browser and type [http://localhost:5000](http://localhost:5000) in the address bar to see the application running. If you feel adventurous click on the "Presenter Login" link on the far right of the navigation bar and ensure the account credentials you picked above work. 75 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_bootstrap import Bootstrap 3 | from flask_sqlalchemy import SQLAlchemy 4 | from flask_login import LoginManager 5 | from flask_moment import Moment 6 | from flask_pagedown import PageDown 7 | from flask_mail import Mail 8 | from config import config 9 | 10 | bootstrap = Bootstrap() 11 | db = SQLAlchemy() 12 | moment = Moment() 13 | pagedown = PageDown() 14 | mail = Mail() 15 | 16 | login_manager = LoginManager() 17 | login_manager.login_view = 'auth.login' 18 | 19 | 20 | def create_app(config_name): 21 | app = Flask(__name__) 22 | app.config.from_object(config[config_name]) 23 | 24 | if not app.config['DEBUG'] and not app.config['TESTING']: 25 | # configure logging for production 26 | 27 | # email errors to the administrators 28 | if app.config.get('MAIL_ERROR_RECIPIENT') is not None: 29 | import logging 30 | from logging.handlers import SMTPHandler 31 | credentials = None 32 | secure = None 33 | if app.config.get('MAIL_USERNAME') is not None: 34 | credentials = (app.config['MAIL_USERNAME'], app.config['MAIL_PASSWORD']) 35 | if app.config['MAIL_USE_TLS'] is not None: 36 | secure = () 37 | mail_handler = SMTPHandler( 38 | mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']), 39 | fromaddr=app.config['MAIL_DEFAULT_SENDER'], 40 | toaddrs=[app.config['MAIL_ERROR_RECIPIENT']], 41 | subject='[Talks] Application Error', 42 | credentials=credentials, 43 | secure=secure) 44 | mail_handler.setLevel(logging.ERROR) 45 | app.logger.addHandler(mail_handler) 46 | 47 | # send standard logs to syslog 48 | import logging 49 | from logging.handlers import SysLogHandler 50 | syslog_handler = SysLogHandler() 51 | syslog_handler.setLevel(logging.WARNING) 52 | app.logger.addHandler(syslog_handler) 53 | 54 | bootstrap.init_app(app) 55 | db.init_app(app) 56 | moment.init_app(app) 57 | pagedown.init_app(app) 58 | mail.init_app(app) 59 | login_manager.init_app(app) 60 | 61 | from .talks import talks as talks_blueprint 62 | app.register_blueprint(talks_blueprint) 63 | 64 | from .auth import auth as auth_blueprint 65 | app.register_blueprint(auth_blueprint, url_prefix='/auth') 66 | 67 | from .api_1_0 import api as api_blueprint 68 | app.register_blueprint(api_blueprint, url_prefix='/api/1.0') 69 | 70 | from app.emails import start_email_thread 71 | @app.before_first_request 72 | def before_first_request(): 73 | start_email_thread() 74 | 75 | return app 76 | -------------------------------------------------------------------------------- /app/api_1_0/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request, g 2 | 3 | api = Blueprint('api', __name__) 4 | 5 | from ..models import User 6 | from . import comments, errors 7 | 8 | 9 | @api.before_request 10 | def before_api_request(): 11 | if request.json is None: 12 | return errors.bad_request('Invalid JSON in body.') 13 | token = request.json.get('token') 14 | if not token: 15 | return errors.unauthorized('Authentication token not provided.') 16 | user = User.validate_api_token(token) 17 | if not user: 18 | return errors.unauthorized('Invalid authentication token.') 19 | g.current_user = user 20 | -------------------------------------------------------------------------------- /app/api_1_0/comments.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify, g 2 | from .. import db 3 | from ..models import Comment 4 | from ..emails import send_comment_notification 5 | 6 | from . import api 7 | from .errors import forbidden, bad_request 8 | 9 | 10 | @api.route('/comments/', methods=['PUT']) 11 | def approve_comment(id): 12 | comment = Comment.query.get_or_404(id) 13 | if comment.talk.author != g.current_user and \ 14 | not g.current_user.is_admin: 15 | return forbidden('You cannot modify this comment.') 16 | if comment.approved: 17 | return bad_request('Comment is already approved.') 18 | comment.approved = True 19 | db.session.add(comment) 20 | db.session.commit() 21 | send_comment_notification(comment) 22 | return jsonify({'status': 'ok'}) 23 | 24 | 25 | @api.route('/comments/', methods=['DELETE']) 26 | def delete_comment(id): 27 | comment = Comment.query.get_or_404(id) 28 | if comment.talk.author != g.current_user and \ 29 | not g.current_user.is_admin: 30 | return forbidden('You cannot modify this comment.') 31 | if comment.approved: 32 | return bad_request('Comment is already approved.') 33 | db.session.delete(comment) 34 | db.session.commit() 35 | return jsonify({'status': 'ok'}) 36 | -------------------------------------------------------------------------------- /app/api_1_0/errors.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | from . import api 3 | 4 | 5 | def bad_request(message): 6 | response = jsonify({'status': 'bad request', 'message': message}) 7 | response.status_code = 400 8 | return response 9 | 10 | 11 | def unauthorized(message): 12 | response = jsonify({'status': 'unauthorized', 'message': message}) 13 | response.status_code = 401 14 | return response 15 | 16 | 17 | def forbidden(message): 18 | response = jsonify({'status': 'forbidden', 'message': message}) 19 | response.status_code = 403 20 | return response 21 | 22 | 23 | def not_found(message): 24 | response = jsonify({'status': 'not found', 'message': message}) 25 | response.status_code = 404 26 | return response 27 | 28 | 29 | @api.errorhandler(404) 30 | def not_found_handler(e): 31 | return not_found('resource not found') 32 | -------------------------------------------------------------------------------- /app/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | auth = Blueprint('auth', __name__) 4 | 5 | from . import routes 6 | -------------------------------------------------------------------------------- /app/auth/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import Form 2 | from wtforms import StringField, PasswordField, BooleanField, SubmitField 3 | from wtforms.validators import Required, Length, Email 4 | 5 | 6 | class LoginForm(Form): 7 | email = StringField('Email', validators=[Required(), Length(1, 64), 8 | Email()]) 9 | password = PasswordField('Password', validators=[Required()]) 10 | remember_me = BooleanField('Keep me logged in') 11 | submit = SubmitField('Log In') 12 | -------------------------------------------------------------------------------- /app/auth/routes.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, current_app, request, redirect, url_for, \ 2 | flash 3 | from flask_login import login_user, logout_user, login_required 4 | from ..models import User 5 | from . import auth 6 | from .forms import LoginForm 7 | 8 | 9 | @auth.route('/login', methods=['GET', 'POST']) 10 | def login(): 11 | if not current_app.config['DEBUG'] and not current_app.config['TESTING'] \ 12 | and not request.is_secure: 13 | return redirect(url_for('.login', _external=True, _scheme='https')) 14 | form = LoginForm() 15 | if form.validate_on_submit(): 16 | user = User.query.filter_by(email=form.email.data).first() 17 | if user is None or not user.verify_password(form.password.data): 18 | flash('Invalid email or password.') 19 | return redirect(url_for('.login')) 20 | login_user(user, form.remember_me.data) 21 | return redirect(request.args.get('next') or url_for('talks.index')) 22 | return render_template('auth/login.html', form=form) 23 | 24 | 25 | @auth.route('/logout') 26 | @login_required 27 | def logout(): 28 | logout_user() 29 | flash('You have been logged out.') 30 | return redirect(url_for('talks.index')) 31 | -------------------------------------------------------------------------------- /app/emails.py: -------------------------------------------------------------------------------- 1 | from threading import Thread 2 | import time 3 | from datetime import datetime 4 | from flask import current_app, render_template, url_for 5 | from flask_mail import Message 6 | from . import db 7 | from .models import PendingEmail 8 | from . import mail 9 | 10 | _email_thread = None 11 | 12 | 13 | def get_notification_email(name, email, subject, body_text, body_html): 14 | msg = Message(subject, recipients=['{0} <{1}>'.format(name, email)]) 15 | msg.body = body_text 16 | msg.html = body_html 17 | return msg 18 | 19 | 20 | def flush_pending(app): 21 | while True: 22 | time.sleep(app.config['MAIL_FLUSH_INTERVAL']) 23 | now = datetime.utcnow() 24 | with app.app_context(): 25 | emails = PendingEmail.query.filter(PendingEmail.timestamp < now) 26 | if emails.count() > 0: 27 | with mail.connect() as conn: 28 | for email in emails.all(): 29 | conn.send( 30 | get_notification_email(email.name, email.email, 31 | email.subject, 32 | email.body_text, 33 | email.body_html)) 34 | db.session.delete(email) 35 | db.session.commit() 36 | 37 | 38 | def start_email_thread(): 39 | if not current_app.config['TESTING']: 40 | global _email_thread 41 | if _email_thread is None: 42 | print("Starting email thread...") 43 | _email_thread = Thread(target=flush_pending, 44 | args=[current_app._get_current_object()]) 45 | _email_thread.start() 46 | 47 | 48 | def send_author_notification(talk): 49 | if not PendingEmail.already_in_queue(talk.author.email, talk): 50 | pending_email = PendingEmail( 51 | name=talk.author.username, 52 | email=talk.author.email, 53 | subject='[talks] New comment', 54 | body_text=render_template('email/notify.txt', 55 | name=talk.author.username, 56 | email=talk.author.email, talk=talk), 57 | body_html=render_template('email/notify.html', 58 | name=talk.author.username, 59 | email=talk.author.email, talk=talk), 60 | talk=talk) 61 | db.session.add(pending_email) 62 | db.session.commit() 63 | 64 | 65 | def send_comment_notification(comment): 66 | talk = comment.talk 67 | for email, name in comment.notification_list(): 68 | if not PendingEmail.already_in_queue(email, talk): 69 | unsubscribe = url_for('talks.unsubscribe', 70 | token=talk.get_unsubscribe_token(email), 71 | _external=True) 72 | pending_email = PendingEmail( 73 | name=name, email=email, subject='[talks] New comment', 74 | body_text=render_template('email/notify.txt', 75 | name=name, email=email, talk=talk, 76 | unsubscribe=unsubscribe), 77 | body_html=render_template('email/notify.html', 78 | name=name, email=email, talk=talk, 79 | unsubscribe=unsubscribe), 80 | talk=talk) 81 | db.session.add(pending_email) 82 | db.session.flush() 83 | db.session.commit() 84 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import hashlib 3 | from markdown import markdown 4 | import bleach 5 | from werkzeug.security import generate_password_hash, check_password_hash 6 | from itsdangerous import TimedJSONWebSignatureSerializer as Serializer 7 | from flask import request, current_app 8 | from flask_login import UserMixin 9 | from . import db, login_manager 10 | 11 | 12 | class User(UserMixin, db.Model): 13 | __tablename__ = 'users' 14 | id = db.Column(db.Integer, primary_key=True) 15 | email = db.Column(db.String(64), 16 | nullable=False, unique=True, index=True) 17 | username = db.Column(db.String(64), 18 | nullable=False, unique=True, index=True) 19 | is_admin = db.Column(db.Boolean) 20 | password_hash = db.Column(db.String(128)) 21 | name = db.Column(db.String(64)) 22 | location = db.Column(db.String(64)) 23 | bio = db.Column(db.Text()) 24 | member_since = db.Column(db.DateTime(), default=datetime.utcnow) 25 | avatar_hash = db.Column(db.String(32)) 26 | talks = db.relationship('Talk', lazy='dynamic', backref='author') 27 | comments = db.relationship('Comment', lazy='dynamic', backref='author') 28 | 29 | def __init__(self, **kwargs): 30 | super(User, self).__init__(**kwargs) 31 | if self.email is not None and self.avatar_hash is None: 32 | self.avatar_hash = hashlib.md5( 33 | self.email.encode('utf-8')).hexdigest() 34 | 35 | @property 36 | def password(self): 37 | raise AttributeError('password is not a readable attribute') 38 | 39 | @password.setter 40 | def password(self, password): 41 | self.password_hash = generate_password_hash(password) 42 | 43 | def verify_password(self, password): 44 | return check_password_hash(self.password_hash, password) 45 | 46 | def gravatar(self, size=100, default='identicon', rating='g'): 47 | if request.is_secure: 48 | url = 'https://secure.gravatar.com/avatar' 49 | else: 50 | url = 'http://www.gravatar.com/avatar' 51 | hash = self.avatar_hash or \ 52 | hashlib.md5(self.email.encode('utf-8')).hexdigest() 53 | return '{url}/{hash}?s={size}&d={default}&r={rating}'.format( 54 | url=url, hash=hash, size=size, default=default, rating=rating) 55 | 56 | def for_moderation(self, admin=False): 57 | if admin and self.is_admin: 58 | return Comment.for_moderation() 59 | return Comment.query.join(Talk, Comment.talk_id == Talk.id).\ 60 | filter(Talk.author == self).filter(Comment.approved == False) 61 | 62 | def get_api_token(self, expiration=300): 63 | s = Serializer(current_app.config['SECRET_KEY'], expiration) 64 | return s.dumps({'user': self.id}).decode('utf-8') 65 | 66 | @staticmethod 67 | def validate_api_token(token): 68 | s = Serializer(current_app.config['SECRET_KEY']) 69 | try: 70 | data = s.loads(token) 71 | except: 72 | return None 73 | id = data.get('user') 74 | if id: 75 | return User.query.get(id) 76 | return None 77 | 78 | 79 | @login_manager.user_loader 80 | def load_user(user_id): 81 | return User.query.get(int(user_id)) 82 | 83 | 84 | class Talk(db.Model): 85 | __tablename__ = 'talks' 86 | id = db.Column(db.Integer, primary_key=True) 87 | title = db.Column(db.String(128), nullable=False) 88 | description = db.Column(db.Text) 89 | slides = db.Column(db.Text()) 90 | video = db.Column(db.Text()) 91 | user_id = db.Column(db.Integer, db.ForeignKey('users.id')) 92 | venue = db.Column(db.String(128)) 93 | venue_url = db.Column(db.String(128)) 94 | date = db.Column(db.DateTime()) 95 | comments = db.relationship('Comment', lazy='dynamic', backref='talk') 96 | emails = db.relationship('PendingEmail', lazy='dynamic', backref='talk') 97 | 98 | def approved_comments(self): 99 | return self.comments.filter_by(approved=True) 100 | 101 | def get_unsubscribe_token(self, email, expiration=604800): 102 | s = Serializer(current_app.config['SECRET_KEY'], expiration) 103 | return s.dumps({'talk': self.id, 'email': email}).decode('utf-8') 104 | 105 | @staticmethod 106 | def unsubscribe_user(token): 107 | s = Serializer(current_app.config['SECRET_KEY']) 108 | try: 109 | data = s.loads(token) 110 | except: 111 | return None, None 112 | id = data.get('talk') 113 | email = data.get('email') 114 | if not id or not email: 115 | return None, None 116 | talk = Talk.query.get(id) 117 | if not talk: 118 | return None, None 119 | Comment.query\ 120 | .filter_by(talk=talk).filter_by(author_email=email)\ 121 | .update({'notify': False}) 122 | db.session.commit() 123 | return talk, email 124 | 125 | 126 | class Comment(db.Model): 127 | __tablename__ = 'comments' 128 | id = db.Column(db.Integer, primary_key=True) 129 | body = db.Column(db.Text) 130 | body_html = db.Column(db.Text) 131 | timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) 132 | author_id = db.Column(db.Integer, db.ForeignKey('users.id')) 133 | author_name = db.Column(db.String(64)) 134 | author_email = db.Column(db.String(64)) 135 | notify = db.Column(db.Boolean, default=True) 136 | approved = db.Column(db.Boolean, default=False) 137 | talk_id = db.Column(db.Integer, db.ForeignKey('talks.id')) 138 | 139 | @staticmethod 140 | def on_changed_body(target, value, oldvalue, initiator): 141 | allowed_tags = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 142 | 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 143 | 'h1', 'h2', 'h3', 'p'] 144 | target.body_html = bleach.linkify(bleach.clean( 145 | markdown(value, output_format='html'), 146 | tags=allowed_tags, strip=True)) 147 | 148 | @staticmethod 149 | def for_moderation(): 150 | return Comment.query.filter(Comment.approved == False) 151 | 152 | def notification_list(self): 153 | list = {} 154 | for comment in self.talk.comments: 155 | # include all commenters that have notifications enabled except 156 | # the author of the talk and the author of this comment 157 | if comment.notify and comment.author != comment.talk.author: 158 | if comment.author: 159 | # registered user 160 | if self.author != comment.author: 161 | list[comment.author.email] = comment.author.name or \ 162 | comment.author.username 163 | else: 164 | # regular user 165 | if self.author_email != comment.author_email: 166 | list[comment.author_email] = comment.author_name 167 | return list.items() 168 | 169 | 170 | db.event.listen(Comment.body, 'set', Comment.on_changed_body) 171 | 172 | 173 | class PendingEmail(db.Model): 174 | __tablename__ = 'pending_emails' 175 | id = db.Column(db.Integer, primary_key=True) 176 | name = db.Column(db.String(64)) 177 | email = db.Column(db.String(64), index=True) 178 | subject = db.Column(db.String(128)) 179 | body_text = db.Column(db.Text()) 180 | body_html = db.Column(db.Text()) 181 | talk_id = db.Column(db.Integer, db.ForeignKey('talks.id')) 182 | timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) 183 | 184 | @staticmethod 185 | def already_in_queue(email, talk): 186 | return PendingEmail.query\ 187 | .filter(PendingEmail.talk_id == talk.id)\ 188 | .filter(PendingEmail.email == email).count() > 0 189 | 190 | @staticmethod 191 | def remove(email): 192 | PendingEmail.query.filter_by(email=email).delete() 193 | -------------------------------------------------------------------------------- /app/static/styles.css: -------------------------------------------------------------------------------- 1 | .user-profile { 2 | min-height: 140px; 3 | } 4 | 5 | .user-avatar { 6 | float: left; 7 | margin-right: 16px; 8 | margin-bottom: 16px; 9 | } 10 | 11 | ul.talk-list { 12 | list-style-type: none; 13 | padding: 0px; 14 | } 15 | 16 | li.talk { 17 | padding-bottom: 10px; 18 | border-bottom: 1px solid #eee; 19 | padding: 10px; 20 | } 21 | 22 | li.talk:hover { 23 | background-color: #f8f8f8; 24 | } 25 | 26 | div.talk-header h2 { 27 | font-size: 200%; 28 | margin: 5px 0px 15px 0px; 29 | } 30 | 31 | div.talk-header h3 { 32 | font-size: 115%; 33 | margin: 0px 0px 10px 0px; 34 | } 35 | 36 | div.talk-header p { 37 | font-size: 90%; 38 | font-style: italic; 39 | margin: 20px 0px 0px 0px; 40 | } 41 | 42 | div.talk-video { 43 | width: 450px; 44 | max-width: 450px; 45 | display: inline-block; 46 | margin: 5px; 47 | vertical-align: middle; 48 | } 49 | 50 | div.talk-slides { 51 | width: 450px; 52 | max-width: 450px; 53 | display: inline-block; 54 | margin: 5px; 55 | vertical-align: middle; 56 | } 57 | 58 | .flask-pagedown-preview { 59 | margin-top: 10px; 60 | padding: 10px; 61 | border: 1px solid #ccc; 62 | } 63 | 64 | .flask-pagedown-preview h1, .comment-body h1 { 65 | font-size: 160%; 66 | } 67 | 68 | .flask-pagedown-preview h2, .comment-body h2 { 69 | font-size: 140%; 70 | } 71 | 72 | .flask-pagedown-preview h3, .comment-body h3 { 73 | font-size: 120%; 74 | } 75 | 76 | ul.comment-list { 77 | list-style-type: none; 78 | padding: 0px; 79 | } 80 | 81 | li.comment { 82 | padding: 10px; 83 | } 84 | 85 | ul.comment-list li.comment:nth-child(odd) 86 | { 87 | background-color: #eee; 88 | } 89 | 90 | ul.comment-list li.comment:nth-child(even) 91 | { 92 | background-color: #fff; 93 | } 94 | 95 | .badge-red { 96 | background-color:#d44; 97 | } 98 | -------------------------------------------------------------------------------- /app/talks/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | talks = Blueprint('talks', __name__) 4 | 5 | from . import routes 6 | 7 | -------------------------------------------------------------------------------- /app/talks/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import Form 2 | from wtforms import StringField, TextAreaField, BooleanField, SubmitField 3 | from wtforms.fields.html5 import DateField 4 | from wtforms.validators import Optional, Length, Required, URL, Email 5 | from flask_pagedown.fields import PageDownField 6 | 7 | 8 | class ProfileForm(Form): 9 | name = StringField('Name', validators=[Optional(), Length(1, 64)]) 10 | location = StringField('Location', validators=[Optional(), Length(1, 64)]) 11 | bio = TextAreaField('Bio') 12 | submit = SubmitField('Submit') 13 | 14 | 15 | class TalkForm(Form): 16 | title = StringField('Title', validators=[Required(), Length(1, 128)]) 17 | description = TextAreaField('Description') 18 | slides = StringField('Slides Embed Code (450 pixels wide)') 19 | video = StringField('Video Embed Code (450 pixels wide)') 20 | venue = StringField('Venue', 21 | validators=[Required(), Length(1, 128)]) 22 | venue_url = StringField('Venue URL', 23 | validators=[Optional(), Length(1, 128), URL()]) 24 | date = DateField('Date') 25 | submit = SubmitField('Submit') 26 | 27 | def from_model(self, talk): 28 | self.title.data = talk.title 29 | self.description.data = talk.description 30 | self.slides.data = talk.slides 31 | self.video.data = talk.video 32 | self.venue.data = talk.venue 33 | self.venue_url.data = talk.venue_url 34 | self.date.data = talk.date 35 | 36 | def to_model(self, talk): 37 | talk.title = self.title.data 38 | talk.description = self.description.data 39 | talk.slides = self.slides.data 40 | talk.video = self.video.data 41 | talk.venue = self.venue.data 42 | talk.venue_url = self.venue_url.data 43 | talk.date = self.date.data 44 | 45 | 46 | class PresenterCommentForm(Form): 47 | body = PageDownField('Comment', validators=[Required()]) 48 | submit = SubmitField('Submit') 49 | 50 | 51 | class CommentForm(Form): 52 | name = StringField('Name', validators=[Required(), Length(1, 64)]) 53 | email = StringField('Email', validators=[Required(), Length(1, 64), 54 | Email()]) 55 | body = PageDownField('Comment', validators=[Required()]) 56 | notify = BooleanField('Notify when new comments are posted', default=True) 57 | submit = SubmitField('Submit') 58 | -------------------------------------------------------------------------------- /app/talks/routes.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, flash, redirect, url_for, abort,\ 2 | request, current_app 3 | from flask_login import login_required, current_user 4 | from .. import db 5 | from ..models import User, Talk, Comment, PendingEmail 6 | from ..emails import send_author_notification, send_comment_notification 7 | from . import talks 8 | from .forms import ProfileForm, TalkForm, CommentForm, PresenterCommentForm 9 | 10 | 11 | @talks.route('/') 12 | def index(): 13 | page = request.args.get('page', 1, type=int) 14 | pagination = Talk.query.order_by(Talk.date.desc()).paginate( 15 | page, per_page=current_app.config['TALKS_PER_PAGE'], 16 | error_out=False) 17 | talk_list = pagination.items 18 | return render_template('talks/index.html', talks=talk_list, 19 | pagination=pagination) 20 | 21 | 22 | @talks.route('/user/') 23 | def user(username): 24 | user = User.query.filter_by(username=username).first_or_404() 25 | page = request.args.get('page', 1, type=int) 26 | pagination = user.talks.order_by(Talk.date.desc()).paginate( 27 | page, per_page=current_app.config['TALKS_PER_PAGE'], 28 | error_out=False) 29 | talk_list = pagination.items 30 | return render_template('talks/user.html', user=user, talks=talk_list, 31 | pagination=pagination) 32 | 33 | 34 | @talks.route('/profile', methods=['GET', 'POST']) 35 | @login_required 36 | def profile(): 37 | form = ProfileForm() 38 | if form.validate_on_submit(): 39 | current_user.name = form.name.data 40 | current_user.location = form.location.data 41 | current_user.bio = form.bio.data 42 | db.session.add(current_user._get_current_object()) 43 | db.session.commit() 44 | flash('Your profile has been updated.') 45 | return redirect(url_for('talks.user', username=current_user.username)) 46 | form.name.data = current_user.name 47 | form.location.data = current_user.location 48 | form.bio.data = current_user.bio 49 | return render_template('talks/profile.html', form=form) 50 | 51 | 52 | @talks.route('/new', methods=['GET', 'POST']) 53 | @login_required 54 | def new_talk(): 55 | form = TalkForm() 56 | if form.validate_on_submit(): 57 | talk = Talk(author=current_user) 58 | form.to_model(talk) 59 | db.session.add(talk) 60 | db.session.commit() 61 | flash('The talk was added successfully.') 62 | return redirect(url_for('.index')) 63 | return render_template('talks/edit_talk.html', form=form) 64 | 65 | 66 | @talks.route('/talk/', methods=['GET', 'POST']) 67 | def talk(id): 68 | talk = Talk.query.get_or_404(id) 69 | comment = None 70 | if current_user.is_authenticated: 71 | form = PresenterCommentForm() 72 | if form.validate_on_submit(): 73 | comment = Comment(body=form.body.data, 74 | talk=talk, 75 | author=current_user, 76 | notify=False, approved=True) 77 | else: 78 | form = CommentForm() 79 | if form.validate_on_submit(): 80 | comment = Comment(body=form.body.data, 81 | talk=talk, 82 | author_name=form.name.data, 83 | author_email=form.email.data, 84 | notify=form.notify.data, approved=False) 85 | if comment: 86 | db.session.add(comment) 87 | db.session.commit() 88 | if comment.approved: 89 | send_comment_notification(comment) 90 | flash('Your comment has been published.') 91 | else: 92 | send_author_notification(talk) 93 | flash('Your comment will be published after it is reviewed by ' 94 | 'the presenter.') 95 | return redirect(url_for('.talk', id=talk.id) + '#top') 96 | if talk.author == current_user or \ 97 | (current_user.is_authenticated and current_user.is_admin): 98 | comments_query = talk.comments 99 | else: 100 | comments_query = talk.approved_comments() 101 | page = request.args.get('page', 1, type=int) 102 | pagination = comments_query.order_by(Comment.timestamp.asc()).paginate( 103 | page, per_page=current_app.config['COMMENTS_PER_PAGE'], 104 | error_out=False) 105 | comments = pagination.items 106 | headers = {} 107 | if current_user.is_authenticated: 108 | headers['X-XSS-Protection'] = '0' 109 | return render_template('talks/talk.html', talk=talk, form=form, 110 | comments=comments, pagination=pagination),\ 111 | 200, headers 112 | 113 | 114 | @talks.route('/edit/', methods=['GET', 'POST']) 115 | @login_required 116 | def edit_talk(id): 117 | talk = Talk.query.get_or_404(id) 118 | if not current_user.is_admin and talk.author != current_user: 119 | abort(403) 120 | form = TalkForm() 121 | if form.validate_on_submit(): 122 | form.to_model(talk) 123 | db.session.add(talk) 124 | db.session.commit() 125 | flash('The talk was updated successfully.') 126 | return redirect(url_for('.talk', id=talk.id)) 127 | form.from_model(talk) 128 | return render_template('talks/edit_talk.html', form=form) 129 | 130 | 131 | @talks.route('/moderate') 132 | @login_required 133 | def moderate(): 134 | comments = current_user.for_moderation().order_by(Comment.timestamp.asc()) 135 | return render_template('talks/moderate.html', comments=comments) 136 | 137 | 138 | @talks.route('/moderate-admin') 139 | @login_required 140 | def moderate_admin(): 141 | if not current_user.is_admin: 142 | abort(403) 143 | comments = Comment.for_moderation().order_by(Comment.timestamp.asc()) 144 | return render_template('talks/moderate.html', comments=comments) 145 | 146 | 147 | @talks.route('/unsubscribe/') 148 | def unsubscribe(token): 149 | talk, email = Talk.unsubscribe_user(token) 150 | if not talk or not email: 151 | flash('Invalid unsubscribe token.') 152 | return redirect(url_for('talks.index')) 153 | PendingEmail.remove(email) 154 | flash('You will not receive any more email notifications about this talk.') 155 | return redirect(url_for('talks.talk', id=talk.id)) 156 | -------------------------------------------------------------------------------- /app/templates/_api_client.html: -------------------------------------------------------------------------------- 1 | {% if current_user.is_authenticated %} 2 | 43 | {% endif %} 44 | -------------------------------------------------------------------------------- /app/templates/auth/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block page_content %} 5 | 8 | {{ wtf.quick_form(form) }} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /app/templates/base.html: -------------------------------------------------------------------------------- 1 | {% extends "bootstrap/base.html" %} 2 | 3 | {% block title %}Talks{% endblock %} 4 | 5 | {% block head %} 6 | {{ super() }} 7 | 8 | {% endblock %} 9 | 10 | {% block navbar %} 11 | 63 | {% endblock %} 64 | 65 | {% block content %} 66 |
67 | {% for message in get_flashed_messages() %} 68 |
69 | 70 | {{ message }} 71 |
72 | {% endfor %} 73 | 74 | {% block page_content %}{% endblock %} 75 |
76 | {% endblock %} 77 | 78 | {% block scripts %} 79 | {{ super() }} 80 | {{ moment.include_moment() }} 81 | {% endblock %} 82 | -------------------------------------------------------------------------------- /app/templates/email/notify.html: -------------------------------------------------------------------------------- 1 |

Dear {{ name }},

2 |

New comments have been posted to talk {{ talk.title }}. 3 |

Sincerely,

4 |

The talks admin

5 | {% if unsubscribe %} 6 |

PS: This email was sent to {{ email }}. To stop receiving notifications for this talk click here.

7 | {% endif %} 8 | 9 | -------------------------------------------------------------------------------- /app/templates/email/notify.txt: -------------------------------------------------------------------------------- 1 | Dear {{ name }}, 2 | 3 | New comments have been posted to talk "{{ talk.title }}". Please visit {{ url_for('talks.talk', id=talk.id, page=-1, _external=True) }}#comments to read them. 4 | 5 | Sincerely, 6 | 7 | The talks admin 8 | {% if unsubscribe %} 9 | PS: This email was sent to {{ email }}. To stop receiving notifications for this talk click here: {{ unsubscribe }}. 10 | {% endif %} 11 | -------------------------------------------------------------------------------- /app/templates/talks/_comment.html: -------------------------------------------------------------------------------- 1 |

2 | {% if comment.author %} 3 | {% if index %} 4 | #{{ index }} 5 | {% endif %} 6 | {{ comment.author.username }} 7 | {% else %} 8 | {% if talk.author == current_user or current_user.is_admin %} 9 | {% if not comment.approved %} 10 |

11 | Approve 12 | Delete 13 |
14 | 17 | 20 | {% endif %} 21 | {% endif %} 22 | {% if index %} 23 | #{{ index }} 24 | {% endif %} 25 | {{ comment.author_name }} 26 | {% if current_user.is_authenticated and (talk.author == current_user or current_user.is_admin) %} 27 | ({{ comment.author_email }}) 28 | {% endif %} 29 | {% endif %} 30 | commented {{ moment(comment.timestamp).fromNow() }}: 31 |

32 |
33 | {{ comment.body_html | safe }} 34 |
35 | -------------------------------------------------------------------------------- /app/templates/talks/_comments.html: -------------------------------------------------------------------------------- 1 |
    2 | {% if pagination %} 3 | {% set base_index = (pagination.page - 1) * pagination.per_page %} 4 | {% else %} 5 | {% set base_index = 0 %} 6 | {% endif %} 7 | {% for comment in comments %} 8 | {% set index = loop.index + base_index %} 9 |
  • 10 | {% include "talks/_comment.html" %} 11 |
  • 12 | {% endfor %} 13 |
14 | -------------------------------------------------------------------------------- /app/templates/talks/_talk_header.html: -------------------------------------------------------------------------------- 1 |
2 | {% if talk.author == current_user %} 3 | Edit 4 | {% elif current_user.is_admin %} 5 | Edit 6 | {% endif %} 7 |

{{ talk.title }}

8 |

{{ talk.description }}

9 | 10 |

11 | Presented by {{ talk.author.username }} at 12 | {% if talk.venue_url %} 13 | {{ talk.venue }} 14 | {% else %} 15 | {{ talk.venue }} 16 | {% endif %} 17 | on {{ moment(talk.date, local=True).format('LL') }}. 18 |

19 |
20 | -------------------------------------------------------------------------------- /app/templates/talks/_talks.html: -------------------------------------------------------------------------------- 1 |
    2 | {% for talk in talks %} 3 |
  • 4 | {% include "talks/_talk_header.html" %} 5 |
  • 6 | {% endfor %} 7 |
8 | -------------------------------------------------------------------------------- /app/templates/talks/edit_talk.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block page_content %} 5 | 8 | {{ wtf.quick_form(form) }} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /app/templates/talks/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block page_content %} 4 | {% include "talks/_talks.html" %} 5 | {% if talks %} 6 |
    7 | {% if pagination.has_prev %} 8 | 9 | {% else %} 10 | 11 | {% endif %} 12 | {% if pagination.has_next %} 13 | 14 | {% else %} 15 | 16 | {% endif %} 17 |
18 | {% endif %} 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /app/templates/talks/moderate.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block page_content %} 4 |

Comment moderation

5 |
    6 | {% for comment in comments %} 7 | {% set talk = comment.talk %} 8 |
  • 9 |

    In {{ talk.title }}

    10 | {% include "talks/_comment.html" %} 11 |
  • 12 | {% endfor %} 13 |
14 | {% endblock %} 15 | 16 | {% block scripts %} 17 | {{ super() }} 18 | {% include "_api_client.html" %} 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /app/templates/talks/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block page_content %} 5 | 8 | {{ wtf.quick_form(form) }} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /app/templates/talks/talk.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block page_content %} 5 | 8 |
9 | {% if talk.video %} 10 |
11 | {{ talk.video | safe }} 12 |
13 | {% endif %} 14 | {% if talk.slides %} 15 |
16 | {{ talk.slides | safe }} 17 |
18 | {% endif %} 19 |
20 | {% if comments %} 21 |

Comments

22 | {% include "talks/_comments.html" %} 23 |
    24 | {% if pagination.has_prev %} 25 | 26 | {% else %} 27 | 28 | {% endif %} 29 | {% if pagination.has_next %} 30 | 31 | {% else %} 32 | 33 | {% endif %} 34 |
35 | {% endif %} 36 |

Write a comment

37 | {{ wtf.quick_form(form, action='#comment-form') }} 38 | {% endblock %} 39 | 40 | {% block scripts %} 41 | {{ super() }} 42 | {{ pagedown.include_pagedown() }} 43 | 44 | 49 | {% include "_api_client.html" %} 50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /app/templates/talks/user.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block page_content %} 4 | 14 | {% include "talks/_talks.html" %} 15 | {% if talks %} 16 |
    17 | {% if pagination.has_prev %} 18 | 19 | {% else %} 20 | 21 | {% endif %} 22 | {% if pagination.has_next %} 23 | 24 | {% else %} 25 | 26 | {% endif %} 27 |
28 | {% endif %} 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | basedir = os.path.abspath(os.path.dirname(__file__)) 3 | 4 | 5 | class Config: 6 | SECRET_KEY = os.environ.get('SECRET_KEY') 7 | TALKS_PER_PAGE = 50 8 | COMMENTS_PER_PAGE = 100 9 | 10 | MAIL_SERVER = 'smtp.googlemail.com' 11 | MAIL_PORT = 587 12 | MAIL_USE_TLS = True 13 | MAIL_USERNAME = os.environ.get('MAIL_USERNAME') 14 | MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') 15 | MAIL_DEFAULT_SENDER = os.environ.get('MAIL_SENDER') or 'nobody@example.com' 16 | MAIL_FLUSH_INTERVAL = 3600 # one hour 17 | MAIL_ERROR_RECIPIENT = os.environ.get('MAIL_ERROR_RECIPIENT') 18 | 19 | 20 | class DevelopmentConfig(Config): 21 | DEBUG = True 22 | SECRET_KEY = os.environ.get('SECRET_KEY') or 't0p s3cr3t' 23 | SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \ 24 | 'sqlite:///' + os.path.join(basedir, 'data-dev.sqlite') 25 | MAIL_FLUSH_INTERVAL = 60 # one minute 26 | 27 | 28 | class TestingConfig(Config): 29 | TESTING = True 30 | SECRET_KEY = 'secret' 31 | SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \ 32 | 'sqlite:///' + os.path.join(basedir, 'data-test.sqlite') 33 | 34 | 35 | class ProductionConfig(Config): 36 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ 37 | 'sqlite:///' + os.path.join(basedir, 'data.sqlite') 38 | 39 | 40 | config = { 41 | 'development': DevelopmentConfig, 42 | 'testing': TestingConfig, 43 | 'production': ProductionConfig, 44 | 45 | 'default': DevelopmentConfig 46 | } 47 | 48 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | if os.path.exists('.env'): 4 | print('Importing environment from .env...') 5 | for line in open('.env'): 6 | var = line.strip().split('=') 7 | if len(var) == 2: 8 | os.environ[var[0]] = var[1] 9 | 10 | from app import create_app 11 | from flask_script import Manager 12 | from app import db 13 | from app.models import User 14 | 15 | app = create_app(os.getenv('FLASK_CONFIG') or 'default') 16 | manager = Manager(app) 17 | 18 | 19 | @manager.command 20 | def test(): 21 | from subprocess import call 22 | call(['nosetests', '-v', 23 | '--with-coverage', '--cover-package=app', '--cover-branches', 24 | '--cover-erase', '--cover-html', '--cover-html-dir=cover']) 25 | 26 | 27 | @manager.command 28 | def adduser(email, username, admin=False): 29 | """Register a new user.""" 30 | from getpass import getpass 31 | password = getpass() 32 | password2 = getpass(prompt='Confirm: ') 33 | if password != password2: 34 | import sys 35 | sys.exit('Error: passwords do not match.') 36 | db.create_all() 37 | user = User(email=email, username=username, password=password, 38 | is_admin=admin) 39 | db.session.add(user) 40 | db.session.commit() 41 | print('User {0} was registered successfully.'.format(username)) 42 | 43 | 44 | if __name__ == '__main__': 45 | manager.run() 46 | 47 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bleach==1.4 2 | blinker==1.3 3 | click==6.6 4 | coverage==3.7.1 5 | Flask==0.11 6 | Flask-Bootstrap==3.1.1.1 7 | Flask-Login==0.3.2 8 | Flask-Mail==0.9.0 9 | Flask-Moment==0.3.0 10 | Flask-PageDown==0.1.4 11 | Flask-Script==0.6.7 12 | Flask-SQLAlchemy==2.1 13 | Flask-WTF==0.9.4 14 | html5lib==0.999 15 | itsdangerous==0.24 16 | Jinja2==2.8 17 | Markdown==2.4 18 | MarkupSafe==0.23 19 | nose==1.3.1 20 | six==1.5.2 21 | SQLAlchemy==1.0.13 22 | Werkzeug==0.11.10 23 | WTForms==1.0.5 24 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miguelgrinberg/flask-pycon2014/bc01a357b4003b40d0cd523d91bc2f10a42417a3/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import json 3 | from app import create_app, db 4 | from app.models import User, Talk, Comment 5 | 6 | 7 | class CommentModelTestCase(unittest.TestCase): 8 | def setUp(self): 9 | self.app = create_app('testing') 10 | self.app_context = self.app.app_context() 11 | self.app_context.push() 12 | db.create_all() 13 | 14 | def tearDown(self): 15 | db.session.remove() 16 | db.drop_all() 17 | self.app_context.pop() 18 | 19 | def test_token_errors(self): 20 | u1 = User(email='john@example.com', username='john', password='cat') 21 | u2 = User(email='susan@example.com', username='susan', password='cat') 22 | t = Talk(title='t', description='d', author=u1) 23 | c = Comment(talk=t, body='c1', author_name='n', 24 | author_email='e@e.com', approved=False) 25 | db.session.add_all([u1, u2, t, c]) 26 | db.session.commit() 27 | 28 | # missing JSON --> 400 29 | with self.app.test_request_context( 30 | '/api/1.0/comments/' + str(c.id), 31 | method='PUT'): 32 | res = self.app.full_dispatch_request() 33 | self.assertTrue(res.status_code == 400) 34 | 35 | # missing token --> 401 36 | with self.app.test_request_context( 37 | '/api/1.0/comments/' + str(c.id), 38 | method='PUT', 39 | data=json.dumps({'bad': 123}), 40 | headers={'Content-Type': 'application/json'}): 41 | res = self.app.full_dispatch_request() 42 | self.assertTrue(res.status_code == 401) 43 | 44 | # bad token --> 401 45 | with self.app.test_request_context( 46 | '/api/1.0/comments/' + str(c.id), 47 | method='PUT', 48 | data=json.dumps({'token': 'a bad token'}), 49 | headers={'Content-Type': 'application/json'}): 50 | res = self.app.full_dispatch_request() 51 | self.assertTrue(res.status_code == 401) 52 | 53 | # malformed token --> 401 54 | u3 = User(email='david@example.com', username='david', password='cat') 55 | with self.app.test_request_context( 56 | '/api/1.0/comments/' + str(c.id), 57 | method='PUT', 58 | data=json.dumps({'token': u3.get_api_token()}), 59 | headers={'Content-Type': 'application/json'}): 60 | res = self.app.full_dispatch_request() 61 | self.assertTrue(res.status_code == 401) 62 | 63 | def test_approve(self): 64 | u1 = User(email='john@example.com', username='john', password='cat') 65 | u2 = User(email='susan@example.com', username='susan', password='cat') 66 | t = Talk(title='t', description='d', author=u1) 67 | c = Comment(talk=t, body='c1', author_name='n', 68 | author_email='e@e.com', approved=False) 69 | db.session.add_all([u1, u2, t, c]) 70 | db.session.commit() 71 | 72 | # wrong user --> 403 73 | token = u2.get_api_token() 74 | with self.app.test_request_context( 75 | '/api/1.0/comments/' + str(c.id), 76 | method='PUT', 77 | data=json.dumps({'token': token}), 78 | headers={'Content-Type': 'application/json'}): 79 | res = self.app.full_dispatch_request() 80 | self.assertTrue(res.status_code == 403) 81 | 82 | # correct user --> 200 83 | token = u1.get_api_token() 84 | with self.app.test_request_context( 85 | '/api/1.0/comments/' + str(c.id), 86 | method='PUT', 87 | data=json.dumps({'token': token}), 88 | headers={'Content-Type': 'application/json'}): 89 | res = self.app.full_dispatch_request() 90 | self.assertTrue(res.status_code == 200) 91 | c = Comment.query.get(c.id) 92 | self.assertTrue(c.approved) 93 | 94 | # approve an already approved comment --> 400 95 | with self.app.test_request_context( 96 | '/api/1.0/comments/' + str(c.id), 97 | method='PUT', 98 | data=json.dumps({'token': token}), 99 | headers={'Content-Type': 'application/json'}): 100 | res = self.app.full_dispatch_request() 101 | self.assertTrue(res.status_code == 400) 102 | 103 | # delete an already approved comment --> 400 104 | with self.app.test_request_context( 105 | '/api/1.0/comments/' + str(c.id), 106 | method='DELETE', 107 | data=json.dumps({'token': token}), 108 | headers={'Content-Type': 'application/json'}): 109 | res = self.app.full_dispatch_request() 110 | self.assertTrue(res.status_code == 400) 111 | 112 | def test_delete(self): 113 | u1 = User(email='john@example.com', username='john', password='cat') 114 | u2 = User(email='susan@example.com', username='susan', password='cat') 115 | t = Talk(title='t', description='d', author=u1) 116 | c = Comment(talk=t, body='c1', author_name='n', 117 | author_email='e@e.com', approved=False) 118 | db.session.add_all([u1, u2, t, c]) 119 | db.session.commit() 120 | 121 | # wrong user --> 403 122 | token = u2.get_api_token() 123 | with self.app.test_request_context( 124 | '/api/1.0/comments/' + str(c.id), 125 | method='DELETE', 126 | data=json.dumps({'token': token}), 127 | headers={'Content-Type': 'application/json'}): 128 | res = self.app.full_dispatch_request() 129 | self.assertTrue(res.status_code == 403) 130 | 131 | token = u1.get_api_token() 132 | with self.app.test_request_context( 133 | '/api/1.0/comments/' + str(c.id), 134 | method='DELETE', 135 | data=json.dumps({'token': token}), 136 | headers={'Content-Type': 'application/json'}): 137 | res = self.app.full_dispatch_request() 138 | self.assertTrue(res.status_code == 200) 139 | c = Comment.query.get(c.id) 140 | self.assertIsNone(c) 141 | -------------------------------------------------------------------------------- /tests/test_comment_model.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from app import create_app, db 3 | from app.models import User, Talk, Comment 4 | 5 | 6 | class CommentModelTestCase(unittest.TestCase): 7 | def setUp(self): 8 | self.app = create_app('testing') 9 | self.app_context = self.app.app_context() 10 | self.app_context.push() 11 | #db.create_all() 12 | 13 | def tearDown(self): 14 | db.session.remove() 15 | db.drop_all() 16 | self.app_context.pop() 17 | 18 | def test_markdown(self): 19 | c = Comment() 20 | c.body = '# title\n\n## section\n\ntext **bold** and *italic*' 21 | self.assertTrue(c.body_html == '

title

\n

section

\n' 22 | '

text bold ' 23 | 'and italic

') 24 | 25 | def test_notification_list(self): 26 | db.create_all() 27 | u1 = User(email='john@example.com', username='john', password='cat') 28 | u2 = User(email='susan@example.com', username='susan', password='cat') 29 | t = Talk(title='t', description='d', author=u1) 30 | c1 = Comment(talk=t, body='c1', author_name='n1', 31 | author_email='e@e.com', approved=True) 32 | c2 = Comment(talk=t, body='c2', author_name='n2', 33 | author_email='e2@e2.com', approved=True, notify=False) 34 | c3 = Comment(talk=t, body='c3', author=u2, approved=True) 35 | c4 = Comment(talk=t, body='c4', author_name='n4', 36 | author_email='e4@e4.com', approved=False) 37 | c5 = Comment(talk=t, body='c5', author=u2, approved=True) 38 | c6 = Comment(talk=t, body='c6', author_name='n6', 39 | author_email='e6@e6.com', approved=True, notify=False) 40 | db.session.add_all([u1, u2, t, c1, c2, c3, c4, c5]) 41 | db.session.commit() 42 | email_list = c4.notification_list() 43 | self.assertTrue(('e@e.com', 'n1') in email_list) 44 | self.assertFalse(('e2@e2.com', 'n2') in email_list) # notify=False 45 | self.assertTrue(('susan@example.com', 'susan') in email_list) 46 | self.assertFalse(('e4@e4.com', 'n4') in email_list) # comment author 47 | self.assertFalse(('e6@e6.com', 'n6') in email_list) 48 | email_list = c5.notification_list() 49 | self.assertFalse(('john@example.com', 'john') in email_list) 50 | self.assertTrue(('e4@e4.com', 'n4') in email_list) # comment author 51 | -------------------------------------------------------------------------------- /tests/test_pending_email_model.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from app import create_app, db 3 | from app.models import PendingEmail, User, Talk 4 | 5 | 6 | class CommentModelTestCase(unittest.TestCase): 7 | def setUp(self): 8 | self.app = create_app('testing') 9 | self.app_context = self.app.app_context() 10 | self.app_context.push() 11 | #db.create_all() 12 | 13 | def tearDown(self): 14 | db.session.remove() 15 | db.drop_all() 16 | self.app_context.pop() 17 | 18 | def test_queue(self): 19 | db.create_all() 20 | u = User(email='john@example.com', username='john', password='cat') 21 | t1 = Talk(title='t1', description='d', author=u) 22 | t2 = Talk(title='t2', description='d', author=u) 23 | p = PendingEmail(name='n', email='e@e.com', subject='s', 24 | body_text='t', body_html='h', talk=t1) 25 | db.session.add_all([u, t1, t2, p]) 26 | db.session.commit() 27 | self.assertTrue( 28 | PendingEmail.already_in_queue('e@e.com', t1) == True) 29 | self.assertTrue( 30 | PendingEmail.already_in_queue('e2@e2.com', t1) == False) 31 | self.assertTrue( 32 | PendingEmail.already_in_queue('e@e.com', t2) == False) 33 | -------------------------------------------------------------------------------- /tests/test_talk_model.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from app import create_app, db 3 | from app.models import User, Talk, Comment 4 | 5 | 6 | class CommentModelTestCase(unittest.TestCase): 7 | def setUp(self): 8 | self.app = create_app('testing') 9 | self.app_context = self.app.app_context() 10 | self.app_context.push() 11 | #db.create_all() 12 | 13 | def tearDown(self): 14 | db.session.remove() 15 | db.drop_all() 16 | self.app_context.pop() 17 | 18 | def test_approved(self): 19 | db.create_all() 20 | u = User(email='john@example.com', username='john', password='cat') 21 | t = Talk(title='t', description='d', author=u) 22 | c1 = Comment(talk=t, body='c1', author_name='n', 23 | author_email='e@e.com', approved=True) 24 | c2 = Comment(talk=t, body='c2', author_name='n', 25 | author_email='e@e.com', approved=False) 26 | db.session.add_all([u, t, c1, c2]) 27 | db.session.commit() 28 | approved = t.approved_comments().all() 29 | self.assertTrue(len(approved) == 1) 30 | self.assertTrue(approved[0] == c1) 31 | 32 | def test_unsubscribe(self): 33 | db.create_all() 34 | u = User(email='john@example.com', username='john', password='cat') 35 | t = Talk(title='t', description='d', author=u) 36 | c1 = Comment(talk=t, body='c1', author_name='n', 37 | author_email='e@e.com', approved=True, notify=True) 38 | c2 = Comment(talk=t, body='c2', author_name='n', 39 | author_email='e2@e2.com', approved=False, notify=True) 40 | c3 = Comment(talk=t, body='c3', author_name='n', 41 | author_email='e@e.com', approved=False, notify=True) 42 | db.session.add_all([u, t, c1, c2, c3]) 43 | db.session.commit() 44 | token = t.get_unsubscribe_token(u'e@e.com') 45 | Talk.unsubscribe_user(token) 46 | comments = t.comments.all() 47 | for comment in comments: 48 | if comment.author_email == 'e@e.com': 49 | self.assertTrue(comment.notify == False) 50 | else: 51 | self.assertTrue(comment.notify == True) 52 | 53 | def test_bad_unsubscribe_token(self): 54 | talk, email = Talk.unsubscribe_user('an invalid token') 55 | self.assertIsNone(talk) 56 | self.assertIsNone(email) 57 | u = User(email='john@example.com', username='john', password='cat') 58 | t = Talk(title='t', description='d', author=u) 59 | token = t.get_unsubscribe_token('e@e.com') 60 | talk, email = Talk.unsubscribe_user(token) 61 | self.assertIsNone(talk) 62 | self.assertIsNone(email) 63 | -------------------------------------------------------------------------------- /tests/test_user_model.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from app import create_app, db 3 | from app.models import User, Talk, Comment, load_user 4 | 5 | 6 | class UserModelTestCase(unittest.TestCase): 7 | def setUp(self): 8 | self.app = create_app('testing') 9 | self.app_context = self.app.app_context() 10 | self.app_context.push() 11 | #db.create_all() 12 | 13 | def tearDown(self): 14 | db.session.remove() 15 | db.drop_all() 16 | self.app_context.pop() 17 | 18 | def test_password_setter(self): 19 | u = User(password='cat') 20 | self.assertTrue(u.password_hash is not None) 21 | 22 | def test_no_password_getter(self): 23 | u = User(password='cat') 24 | with self.assertRaises(AttributeError): 25 | u.password 26 | 27 | def test_password_verification(self): 28 | u = User(password='cat') 29 | self.assertTrue(u.verify_password('cat')) 30 | self.assertFalse(u.verify_password('dog')) 31 | 32 | def test_password_salts_are_random(self): 33 | u = User(password='cat') 34 | u2 = User(password='cat') 35 | self.assertTrue(u.password_hash != u2.password_hash) 36 | 37 | def test_user_loader(self): 38 | db.create_all() 39 | u = User(email='john@example.com', username='john', password='cat') 40 | db.session.add(u) 41 | db.session.commit() 42 | self.assertTrue(load_user(u.id) == u) 43 | 44 | def test_gravatar(self): 45 | u = User(email='john@example.com', password='cat') 46 | with self.app.test_request_context('/'): 47 | gravatar = u.gravatar() 48 | gravatar_256 = u.gravatar(size=256) 49 | gravatar_pg = u.gravatar(rating='pg') 50 | gravatar_retro = u.gravatar(default='retro') 51 | with self.app.test_request_context('/', 52 | base_url='https://example.com'): 53 | gravatar_ssl = u.gravatar() 54 | self.assertTrue('http://www.gravatar.com/avatar/' + 55 | 'd4c74594d841139328695756648b6bd6'in gravatar) 56 | self.assertTrue('s=256' in gravatar_256) 57 | self.assertTrue('r=pg' in gravatar_pg) 58 | self.assertTrue('d=retro' in gravatar_retro) 59 | self.assertTrue('https://secure.gravatar.com/avatar/' + 60 | 'd4c74594d841139328695756648b6bd6' in gravatar_ssl) 61 | 62 | def test_moderation(self): 63 | db.create_all() 64 | u1 = User(email='john@example.com', username='john', password='cat') 65 | u2 = User(email='susan@example.com', username='susan', password='cat', 66 | is_admin=True) 67 | t = Talk(title='t', description='d', author=u1) 68 | c1 = Comment(talk=t, body='c1', author_name='n', 69 | author_email='e@e.com', approved=True) 70 | c2 = Comment(talk=t, body='c2', author_name='n', 71 | author_email='e@e.com', approved=False) 72 | db.session.add_all([u1, u2, t, c1, c2]) 73 | db.session.commit() 74 | for_mod1 = u1.for_moderation().all() 75 | for_mod1_admin = u1.for_moderation(True).all() 76 | for_mod2 = u2.for_moderation().all() 77 | for_mod2_admin = u2.for_moderation(True).all() 78 | self.assertTrue(len(for_mod1) == 1) 79 | self.assertTrue(for_mod1[0] == c2) 80 | self.assertTrue(for_mod1_admin == for_mod1) 81 | self.assertTrue(len(for_mod2) == 0) 82 | self.assertTrue(len(for_mod2_admin) == 1) 83 | self.assertTrue(for_mod2_admin[0] == c2) 84 | --------------------------------------------------------------------------------