├── .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 |
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 |
14 |
--------------------------------------------------------------------------------
/app/templates/talks/_talk_header.html:
--------------------------------------------------------------------------------
1 |
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 |
18 | {% endif %}
19 | {% endblock %}
20 |
--------------------------------------------------------------------------------
/app/templates/talks/moderate.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block page_content %}
4 | Comment moderation
5 |
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 |
22 | {% include "talks/_comments.html" %}
23 |
35 | {% endif %}
36 |
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 |
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
\nsection
\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 |
--------------------------------------------------------------------------------