├── tests
├── __init__.py
├── test_basics.py
├── test_client.py
├── test_selenium.py
├── test_user_model.py
└── test_api.py
├── requirements
├── prod.txt
├── heroku.txt
├── dev.txt
└── common.txt
├── migrations
├── README
├── script.py.mako
├── versions
│ ├── 288cd3dc5a8_rich_text_posts.py
│ ├── 190163627111_account_confirmation.py
│ ├── 198b0eebcf9_caching_of_avatar_hashes.py
│ ├── 56ed7d33de8d_user_roles.py
│ ├── 456a945560f6_login_support.py
│ ├── 2356a38169ea_followers.py
│ ├── 1b966e7f4b9e_post_model.py
│ ├── d66f086b258_user_information.py
│ ├── 38c4e85512a9_initial_migration.py
│ └── 51f5ccfba190_comments.py
├── alembic.ini
└── env.py
├── app
├── exceptions.py
├── templates
│ ├── mail
│ │ ├── new_user.txt
│ │ └── new_user.html
│ ├── 403.html
│ ├── 404.html
│ ├── 500.html
│ ├── error_page.html
│ ├── auth
│ │ ├── email
│ │ │ ├── change_email.txt
│ │ │ ├── confirm.txt
│ │ │ ├── reset_password.txt
│ │ │ ├── change_email.html
│ │ │ ├── confirm.html
│ │ │ └── reset_password.html
│ │ ├── register.html
│ │ ├── change_password.html
│ │ ├── reset_password.html
│ │ ├── change_email.html
│ │ ├── login.html
│ │ └── unconfirmed.html
│ ├── edit_profile.html
│ ├── edit_post.html
│ ├── moderate.html
│ ├── post.html
│ ├── followers.html
│ ├── index.html
│ ├── _macros.html
│ ├── _comments.html
│ ├── _posts.html
│ ├── user.html
│ └── base.html
├── static
│ ├── favicon.ico
│ └── styles.css
├── auth
│ ├── __init__.py
│ ├── forms.py
│ └── views.py
├── api_1_0
│ ├── __init__.py
│ ├── decorators.py
│ ├── errors.py
│ ├── authentication.py
│ ├── posts.py
│ ├── users.py
│ └── comments.py
├── main
│ ├── __init__.py
│ ├── errors.py
│ ├── forms.py
│ └── views.py
├── decorators.py
├── email.py
├── __init__.py
└── models.py
├── README.md
├── .gitignore
├── LICENSE
├── manage.py
└── config.py
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/requirements/prod.txt:
--------------------------------------------------------------------------------
1 | -r common.txt
2 |
--------------------------------------------------------------------------------
/migrations/README:
--------------------------------------------------------------------------------
1 | Generic single-database configuration.
--------------------------------------------------------------------------------
/app/exceptions.py:
--------------------------------------------------------------------------------
1 | class ValidationError(ValueError):
2 | pass
3 |
--------------------------------------------------------------------------------
/app/templates/mail/new_user.txt:
--------------------------------------------------------------------------------
1 | User {{ user.username }} has joined.
2 |
--------------------------------------------------------------------------------
/app/templates/mail/new_user.html:
--------------------------------------------------------------------------------
1 | User {{ user.username }} has joined.
2 |
--------------------------------------------------------------------------------
/requirements/heroku.txt:
--------------------------------------------------------------------------------
1 | -r prod.txt
2 | Flask-SSLify==0.1.4
3 | gunicorn==18.0
4 | psycopg2==2.5.1
5 |
--------------------------------------------------------------------------------
/app/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bjpublic/Flask_Web_Development/master/app/static/favicon.ico
--------------------------------------------------------------------------------
/app/auth/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | auth = Blueprint('auth', __name__)
4 |
5 | from . import views
6 |
--------------------------------------------------------------------------------
/app/api_1_0/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | api = Blueprint('api', __name__)
4 |
5 | from . import authentication, posts, users, comments, errors
6 |
--------------------------------------------------------------------------------
/requirements/dev.txt:
--------------------------------------------------------------------------------
1 | -r common.txt
2 | ForgeryPy==0.1
3 | Pygments==1.6
4 | colorama==0.2.7
5 | coverage==3.7.1
6 | httpie==0.7.2
7 | requests==2.1.0
8 | selenium==2.45.0
9 |
--------------------------------------------------------------------------------
/app/templates/403.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block title %}Flasky - Forbidden{% endblock %}
4 |
5 | {% block page_content %}
6 |
9 | {% endblock %}
10 |
--------------------------------------------------------------------------------
/app/templates/404.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block title %}Flasky - Page Not Found{% endblock %}
4 |
5 | {% block page_content %}
6 |
9 | {% endblock %}
10 |
--------------------------------------------------------------------------------
/app/templates/500.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block title %}Flasky - Internal Server Error{% endblock %}
4 |
5 | {% block page_content %}
6 |
9 | {% endblock %}
10 |
--------------------------------------------------------------------------------
/app/main/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | main = Blueprint('main', __name__)
4 |
5 | from . import views, errors
6 | from ..models import Permission
7 |
8 |
9 | @main.app_context_processor
10 | def inject_permissions():
11 | return dict(Permission=Permission)
12 |
--------------------------------------------------------------------------------
/app/templates/error_page.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block title %}Flasky - {{ code }}: {{ name }}{% endblock %}
4 |
5 | {% block page_content %}
6 |
10 | {% endblock %}
11 |
--------------------------------------------------------------------------------
/app/templates/auth/email/change_email.txt:
--------------------------------------------------------------------------------
1 | Dear {{ user.username }},
2 |
3 | To confirm your new email address click on the following link:
4 |
5 | {{ url_for('auth.change_email', token=token, _external=True) }}
6 |
7 | Sincerely,
8 |
9 | The Flasky Team
10 |
11 | Note: replies to this email address are not monitored.
12 |
--------------------------------------------------------------------------------
/app/templates/auth/email/confirm.txt:
--------------------------------------------------------------------------------
1 | Dear {{ user.username }},
2 |
3 | Welcome to Flasky!
4 |
5 | To confirm your account please click on the following link:
6 |
7 | {{ url_for('auth.confirm', token=token, _external=True) }}
8 |
9 | Sincerely,
10 |
11 | The Flasky Team
12 |
13 | Note: replies to this email address are not monitored.
14 |
--------------------------------------------------------------------------------
/app/templates/auth/register.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "bootstrap/wtf.html" as wtf %}
3 |
4 | {% block title %}Flasky - Register{% endblock %}
5 |
6 | {% block page_content %}
7 |
10 |
11 | {{ wtf.quick_form(form) }}
12 |
13 | {% endblock %}
14 |
--------------------------------------------------------------------------------
/app/templates/edit_profile.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "bootstrap/wtf.html" as wtf %}
3 |
4 | {% block title %}Flasky - Edit Profile{% endblock %}
5 |
6 | {% block page_content %}
7 |
10 |
11 | {{ wtf.quick_form(form) }}
12 |
13 | {% endblock %}
14 |
--------------------------------------------------------------------------------
/app/templates/auth/change_password.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "bootstrap/wtf.html" as wtf %}
3 |
4 | {% block title %}Flasky - Change Password{% endblock %}
5 |
6 | {% block page_content %}
7 |
10 |
11 | {{ wtf.quick_form(form) }}
12 |
13 | {% endblock %}
--------------------------------------------------------------------------------
/app/templates/auth/reset_password.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "bootstrap/wtf.html" as wtf %}
3 |
4 | {% block title %}Flasky - Password Reset{% endblock %}
5 |
6 | {% block page_content %}
7 |
10 |
11 | {{ wtf.quick_form(form) }}
12 |
13 | {% endblock %}
--------------------------------------------------------------------------------
/app/templates/auth/change_email.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "bootstrap/wtf.html" as wtf %}
3 |
4 | {% block title %}Flasky - Change Email Address{% endblock %}
5 |
6 | {% block page_content %}
7 |
10 |
11 | {{ wtf.quick_form(form) }}
12 |
13 | {% endblock %}
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Flasky
2 | ======
3 |
4 | This repository contains the source code examples for my O'Reilly book [Flask Web Development](http://www.flaskbook.com).
5 |
6 | The commits and tags in this repository were carefully created to match the sequence in which concepts are presented in the book. Please read the section titled "How to Work with the Example Code" in the book's preface for instructions.
7 |
8 |
--------------------------------------------------------------------------------
/app/templates/auth/email/reset_password.txt:
--------------------------------------------------------------------------------
1 | Dear {{ user.username }},
2 |
3 | To reset your password click on the following link:
4 |
5 | {{ url_for('auth.password_reset', token=token, _external=True) }}
6 |
7 | If you have not requested a password reset simply ignore this message.
8 |
9 | Sincerely,
10 |
11 | The Flasky Team
12 |
13 | Note: replies to this email address are not monitored.
14 |
--------------------------------------------------------------------------------
/app/templates/edit_post.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "bootstrap/wtf.html" as wtf %}
3 |
4 | {% block title %}Flasky - Edit Post{% endblock %}
5 |
6 | {% block page_content %}
7 |
10 |
11 | {{ wtf.quick_form(form) }}
12 |
13 | {% endblock %}
14 |
15 | {% block scripts %}
16 | {{ super() }}
17 | {{ pagedown.include_pagedown() }}
18 | {% endblock %}
19 |
--------------------------------------------------------------------------------
/app/api_1_0/decorators.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 | from flask import g
3 | from .errors import forbidden
4 |
5 |
6 | def permission_required(permission):
7 | def decorator(f):
8 | @wraps(f)
9 | def decorated_function(*args, **kwargs):
10 | if not g.current_user.can(permission):
11 | return forbidden('Insufficient permissions')
12 | return f(*args, **kwargs)
13 | return decorated_function
14 | return decorator
15 |
--------------------------------------------------------------------------------
/app/templates/auth/email/change_email.html:
--------------------------------------------------------------------------------
1 | Dear {{ user.username }},
2 | To confirm your new email address click here .
3 | Alternatively, you can paste the following link in your browser's address bar:
4 | {{ url_for('auth.change_email', token=token, _external=True) }}
5 | Sincerely,
6 | The Flasky Team
7 | Note: replies to this email address are not monitored.
8 |
--------------------------------------------------------------------------------
/app/templates/auth/email/confirm.html:
--------------------------------------------------------------------------------
1 | Dear {{ user.username }},
2 | Welcome to Flasky !
3 | To confirm your account please click here .
4 | Alternatively, you can paste the following link in your browser's address bar:
5 | {{ url_for('auth.confirm', token=token, _external=True) }}
6 | Sincerely,
7 | The Flasky Team
8 | Note: replies to this email address are not monitored.
9 |
--------------------------------------------------------------------------------
/app/templates/moderate.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "_macros.html" as macros %}
3 |
4 | {% block title %}Flasky - Comment Moderation{% endblock %}
5 |
6 | {% block page_content %}
7 |
10 | {% set moderate = True %}
11 | {% include '_comments.html' %}
12 | {% if pagination %}
13 |
16 | {% endif %}
17 | {% endblock %}
18 |
--------------------------------------------------------------------------------
/migrations/script.py.mako:
--------------------------------------------------------------------------------
1 | """${message}
2 |
3 | Revision ID: ${up_revision}
4 | Revises: ${down_revision}
5 | Create Date: ${create_date}
6 |
7 | """
8 |
9 | # revision identifiers, used by Alembic.
10 | revision = ${repr(up_revision)}
11 | down_revision = ${repr(down_revision)}
12 |
13 | from alembic import op
14 | import sqlalchemy as sa
15 | ${imports if imports else ""}
16 |
17 | def upgrade():
18 | ${upgrades if upgrades else "pass"}
19 |
20 |
21 | def downgrade():
22 | ${downgrades if downgrades else "pass"}
23 |
--------------------------------------------------------------------------------
/requirements/common.txt:
--------------------------------------------------------------------------------
1 | Flask==0.10.1
2 | Flask-Bootstrap==3.0.3.1
3 | Flask-HTTPAuth==2.7.0
4 | Flask-Login==0.3.1
5 | Flask-Mail==0.9.0
6 | Flask-Migrate==1.1.0
7 | Flask-Moment==0.2.1
8 | Flask-PageDown==0.1.4
9 | Flask-SQLAlchemy==1.0
10 | Flask-Script==0.6.6
11 | Flask-WTF==0.9.4
12 | Jinja2==2.7.1
13 | Mako==0.9.1
14 | Markdown==2.3.1
15 | MarkupSafe==0.18
16 | SQLAlchemy==0.9.9
17 | WTForms==1.0.5
18 | Werkzeug==0.10.4
19 | alembic==0.6.2
20 | bleach==1.4.0
21 | blinker==1.3
22 | html5lib==1.0b3
23 | itsdangerous==0.23
24 | six==1.4.1
25 |
--------------------------------------------------------------------------------
/app/templates/auth/email/reset_password.html:
--------------------------------------------------------------------------------
1 | Dear {{ user.username }},
2 | To reset your password click here .
3 | Alternatively, you can paste the following link in your browser's address bar:
4 | {{ url_for('auth.password_reset', token=token, _external=True) }}
5 | If you have not requested a password reset simply ignore this message.
6 | Sincerely,
7 | The Flasky Team
8 | Note: replies to this email address are not monitored.
9 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/app/templates/auth/login.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "bootstrap/wtf.html" as wtf %}
3 |
4 | {% block title %}Flasky - Login{% endblock %}
5 |
6 | {% block page_content %}
7 |
10 |
16 | {% endblock %}
17 |
--------------------------------------------------------------------------------
/app/decorators.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 | from flask import abort
3 | from flask_login import current_user
4 | from .models import Permission
5 |
6 |
7 | def permission_required(permission):
8 | def decorator(f):
9 | @wraps(f)
10 | def decorated_function(*args, **kwargs):
11 | if not current_user.can(permission):
12 | abort(403)
13 | return f(*args, **kwargs)
14 | return decorated_function
15 | return decorator
16 |
17 |
18 | def admin_required(f):
19 | return permission_required(Permission.ADMINISTER)(f)
20 |
--------------------------------------------------------------------------------
/app/templates/post.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "bootstrap/wtf.html" as wtf %}
3 | {% import "_macros.html" as macros %}
4 |
5 | {% block title %}Flasky - Post{% endblock %}
6 |
7 | {% block page_content %}
8 | {% include '_posts.html' %}
9 |
10 | {% if current_user.can(Permission.COMMENT) %}
11 |
14 | {% endif %}
15 | {% include '_comments.html' %}
16 | {% if pagination %}
17 |
20 | {% endif %}
21 | {% endblock %}
22 |
--------------------------------------------------------------------------------
/tests/test_basics.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from flask import current_app
3 | from app import create_app, db
4 |
5 |
6 | class BasicsTestCase(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_app_exists(self):
19 | self.assertFalse(current_app is None)
20 |
21 | def test_app_is_testing(self):
22 | self.assertTrue(current_app.config['TESTING'])
23 |
--------------------------------------------------------------------------------
/app/templates/auth/unconfirmed.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block title %}Flasky - Confirm your account{% endblock %}
4 |
5 | {% block page_content %}
6 |
20 | {% endblock %}
21 |
--------------------------------------------------------------------------------
/app/api_1_0/errors.py:
--------------------------------------------------------------------------------
1 | from flask import jsonify
2 | from app.exceptions import ValidationError
3 | from . import api
4 |
5 |
6 | def bad_request(message):
7 | response = jsonify({'error': 'bad request', 'message': message})
8 | response.status_code = 400
9 | return response
10 |
11 |
12 | def unauthorized(message):
13 | response = jsonify({'error': 'unauthorized', 'message': message})
14 | response.status_code = 401
15 | return response
16 |
17 |
18 | def forbidden(message):
19 | response = jsonify({'error': 'forbidden', 'message': message})
20 | response.status_code = 403
21 | return response
22 |
23 |
24 | @api.errorhandler(ValidationError)
25 | def validation_error(e):
26 | return bad_request(e.args[0])
27 |
--------------------------------------------------------------------------------
/app/email.py:
--------------------------------------------------------------------------------
1 | from threading import Thread
2 | from flask import current_app, render_template
3 | from flask_mail import Message
4 | from . import mail
5 |
6 |
7 | def send_async_email(app, msg):
8 | with app.app_context():
9 | mail.send(msg)
10 |
11 |
12 | def send_email(to, subject, template, **kwargs):
13 | app = current_app._get_current_object()
14 | msg = Message(app.config['FLASKY_MAIL_SUBJECT_PREFIX'] + ' ' + subject,
15 | sender=app.config['FLASKY_MAIL_SENDER'], recipients=[to])
16 | msg.body = render_template(template + '.txt', **kwargs)
17 | msg.html = render_template(template + '.html', **kwargs)
18 | thr = Thread(target=send_async_email, args=[app, msg])
19 | thr.start()
20 | return thr
21 |
--------------------------------------------------------------------------------
/migrations/versions/288cd3dc5a8_rich_text_posts.py:
--------------------------------------------------------------------------------
1 | """rich text posts
2 |
3 | Revision ID: 288cd3dc5a8
4 | Revises: 1b966e7f4b9e
5 | Create Date: 2013-12-31 03:25:13.286503
6 |
7 | """
8 |
9 | # revision identifiers, used by Alembic.
10 | revision = '288cd3dc5a8'
11 | down_revision = '1b966e7f4b9e'
12 |
13 | from alembic import op
14 | import sqlalchemy as sa
15 |
16 |
17 | def upgrade():
18 | ### commands auto generated by Alembic - please adjust! ###
19 | op.add_column('posts', sa.Column('body_html', sa.Text(), nullable=True))
20 | ### end Alembic commands ###
21 |
22 |
23 | def downgrade():
24 | ### commands auto generated by Alembic - please adjust! ###
25 | op.drop_column('posts', 'body_html')
26 | ### end Alembic commands ###
27 |
--------------------------------------------------------------------------------
/migrations/versions/190163627111_account_confirmation.py:
--------------------------------------------------------------------------------
1 | """account confirmation
2 |
3 | Revision ID: 190163627111
4 | Revises: 456a945560f6
5 | Create Date: 2013-12-29 02:58:45.577428
6 |
7 | """
8 |
9 | # revision identifiers, used by Alembic.
10 | revision = '190163627111'
11 | down_revision = '456a945560f6'
12 |
13 | from alembic import op
14 | import sqlalchemy as sa
15 |
16 |
17 | def upgrade():
18 | ### commands auto generated by Alembic - please adjust! ###
19 | op.add_column('users', sa.Column('confirmed', sa.Boolean(), nullable=True))
20 | ### end Alembic commands ###
21 |
22 |
23 | def downgrade():
24 | ### commands auto generated by Alembic - please adjust! ###
25 | op.drop_column('users', 'confirmed')
26 | ### end Alembic commands ###
27 |
--------------------------------------------------------------------------------
/migrations/versions/198b0eebcf9_caching_of_avatar_hashes.py:
--------------------------------------------------------------------------------
1 | """caching of avatar hashes
2 |
3 | Revision ID: 198b0eebcf9
4 | Revises: d66f086b258
5 | Create Date: 2014-02-04 09:10:02.245503
6 |
7 | """
8 |
9 | # revision identifiers, used by Alembic.
10 | revision = '198b0eebcf9'
11 | down_revision = 'd66f086b258'
12 |
13 | from alembic import op
14 | import sqlalchemy as sa
15 |
16 |
17 | def upgrade():
18 | ### commands auto generated by Alembic - please adjust! ###
19 | op.add_column('users', sa.Column('avatar_hash', sa.String(length=32), nullable=True))
20 | ### end Alembic commands ###
21 |
22 |
23 | def downgrade():
24 | ### commands auto generated by Alembic - please adjust! ###
25 | op.drop_column('users', 'avatar_hash')
26 | ### end Alembic commands ###
27 |
--------------------------------------------------------------------------------
/migrations/alembic.ini:
--------------------------------------------------------------------------------
1 | # A generic, single database configuration.
2 |
3 | [alembic]
4 | # template used to generate migration files
5 | # file_template = %%(rev)s_%%(slug)s
6 |
7 | # set to 'true' to run the environment during
8 | # the 'revision' command, regardless of autogenerate
9 | # revision_environment = false
10 |
11 |
12 | # Logging configuration
13 | [loggers]
14 | keys = root,sqlalchemy,alembic
15 |
16 | [handlers]
17 | keys = console
18 |
19 | [formatters]
20 | keys = generic
21 |
22 | [logger_root]
23 | level = WARN
24 | handlers = console
25 | qualname =
26 |
27 | [logger_sqlalchemy]
28 | level = WARN
29 | handlers =
30 | qualname = sqlalchemy.engine
31 |
32 | [logger_alembic]
33 | level = INFO
34 | handlers =
35 | qualname = alembic
36 |
37 | [handler_console]
38 | class = StreamHandler
39 | args = (sys.stderr,)
40 | level = NOTSET
41 | formatter = generic
42 |
43 | [formatter_generic]
44 | format = %(levelname)-5.5s [%(name)s] %(message)s
45 | datefmt = %H:%M:%S
46 |
--------------------------------------------------------------------------------
/migrations/versions/56ed7d33de8d_user_roles.py:
--------------------------------------------------------------------------------
1 | """user roles
2 |
3 | Revision ID: 56ed7d33de8d
4 | Revises: 190163627111
5 | Create Date: 2013-12-29 22:19:54.212604
6 |
7 | """
8 |
9 | # revision identifiers, used by Alembic.
10 | revision = '56ed7d33de8d'
11 | down_revision = '190163627111'
12 |
13 | from alembic import op
14 | import sqlalchemy as sa
15 |
16 |
17 | def upgrade():
18 | ### commands auto generated by Alembic - please adjust! ###
19 | op.add_column('roles', sa.Column('default', sa.Boolean(), nullable=True))
20 | op.add_column('roles', sa.Column('permissions', sa.Integer(), nullable=True))
21 | op.create_index('ix_roles_default', 'roles', ['default'], unique=False)
22 | ### end Alembic commands ###
23 |
24 |
25 | def downgrade():
26 | ### commands auto generated by Alembic - please adjust! ###
27 | op.drop_index('ix_roles_default', 'roles')
28 | op.drop_column('roles', 'permissions')
29 | op.drop_column('roles', 'default')
30 | ### end Alembic commands ###
31 |
--------------------------------------------------------------------------------
/migrations/versions/456a945560f6_login_support.py:
--------------------------------------------------------------------------------
1 | """login support
2 |
3 | Revision ID: 456a945560f6
4 | Revises: 38c4e85512a9
5 | Create Date: 2013-12-29 00:18:35.795259
6 |
7 | """
8 |
9 | # revision identifiers, used by Alembic.
10 | revision = '456a945560f6'
11 | down_revision = '38c4e85512a9'
12 |
13 | from alembic import op
14 | import sqlalchemy as sa
15 |
16 |
17 | def upgrade():
18 | ### commands auto generated by Alembic - please adjust! ###
19 | op.add_column('users', sa.Column('email', sa.String(length=64), nullable=True))
20 | op.add_column('users', sa.Column('password_hash', sa.String(length=128), nullable=True))
21 | op.create_index('ix_users_email', 'users', ['email'], unique=True)
22 | ### end Alembic commands ###
23 |
24 |
25 | def downgrade():
26 | ### commands auto generated by Alembic - please adjust! ###
27 | op.drop_index('ix_users_email', 'users')
28 | op.drop_column('users', 'password_hash')
29 | op.drop_column('users', 'email')
30 | ### end Alembic commands ###
31 |
--------------------------------------------------------------------------------
/app/templates/followers.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "_macros.html" as macros %}
3 |
4 | {% block title %}Flasky - {{ title }} {{ user.username }}{% endblock %}
5 |
6 | {% block page_content %}
7 |
10 |
11 | User Since
12 | {% for follow in follows %}
13 | {% if follow.user != user %}
14 |
15 |
16 |
17 |
18 | {{ follow.user.username }}
19 |
20 |
21 | {{ moment(follow.timestamp).format('L') }}
22 |
23 | {% endif %}
24 | {% endfor %}
25 |
26 |
29 | {% endblock %}
30 |
--------------------------------------------------------------------------------
/migrations/versions/2356a38169ea_followers.py:
--------------------------------------------------------------------------------
1 | """followers
2 |
3 | Revision ID: 2356a38169ea
4 | Revises: 288cd3dc5a8
5 | Create Date: 2013-12-31 16:10:34.500006
6 |
7 | """
8 |
9 | # revision identifiers, used by Alembic.
10 | revision = '2356a38169ea'
11 | down_revision = '288cd3dc5a8'
12 |
13 | from alembic import op
14 | import sqlalchemy as sa
15 |
16 |
17 | def upgrade():
18 | ### commands auto generated by Alembic - please adjust! ###
19 | op.create_table('follows',
20 | sa.Column('follower_id', sa.Integer(), nullable=False),
21 | sa.Column('followed_id', sa.Integer(), nullable=False),
22 | sa.Column('timestamp', sa.DateTime(), nullable=True),
23 | sa.ForeignKeyConstraint(['followed_id'], ['users.id'], ),
24 | sa.ForeignKeyConstraint(['follower_id'], ['users.id'], ),
25 | sa.PrimaryKeyConstraint('follower_id', 'followed_id')
26 | )
27 | ### end Alembic commands ###
28 |
29 |
30 | def downgrade():
31 | ### commands auto generated by Alembic - please adjust! ###
32 | op.drop_table('follows')
33 | ### end Alembic commands ###
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2013 Miguel Grinberg
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | 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, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
22 |
--------------------------------------------------------------------------------
/app/main/errors.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, request, jsonify
2 | from . import main
3 |
4 |
5 | @main.app_errorhandler(403)
6 | def forbidden(e):
7 | if request.accept_mimetypes.accept_json and \
8 | not request.accept_mimetypes.accept_html:
9 | response = jsonify({'error': 'forbidden'})
10 | response.status_code = 403
11 | return response
12 | return render_template('403.html'), 403
13 |
14 |
15 | @main.app_errorhandler(404)
16 | def page_not_found(e):
17 | if request.accept_mimetypes.accept_json and \
18 | not request.accept_mimetypes.accept_html:
19 | response = jsonify({'error': 'not found'})
20 | response.status_code = 404
21 | return response
22 | return render_template('404.html'), 404
23 |
24 |
25 | @main.app_errorhandler(500)
26 | def internal_server_error(e):
27 | if request.accept_mimetypes.accept_json and \
28 | not request.accept_mimetypes.accept_html:
29 | response = jsonify({'error': 'internal server error'})
30 | response.status_code = 500
31 | return response
32 | return render_template('500.html'), 500
33 |
--------------------------------------------------------------------------------
/migrations/versions/1b966e7f4b9e_post_model.py:
--------------------------------------------------------------------------------
1 | """post model
2 |
3 | Revision ID: 1b966e7f4b9e
4 | Revises: 198b0eebcf9
5 | Create Date: 2013-12-31 00:00:14.700591
6 |
7 | """
8 |
9 | # revision identifiers, used by Alembic.
10 | revision = '1b966e7f4b9e'
11 | down_revision = '198b0eebcf9'
12 |
13 | from alembic import op
14 | import sqlalchemy as sa
15 |
16 |
17 | def upgrade():
18 | ### commands auto generated by Alembic - please adjust! ###
19 | op.create_table('posts',
20 | sa.Column('id', sa.Integer(), nullable=False),
21 | sa.Column('body', sa.Text(), nullable=True),
22 | sa.Column('timestamp', sa.DateTime(), nullable=True),
23 | sa.Column('author_id', sa.Integer(), nullable=True),
24 | sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
25 | sa.PrimaryKeyConstraint('id')
26 | )
27 | op.create_index('ix_posts_timestamp', 'posts', ['timestamp'], unique=False)
28 | ### end Alembic commands ###
29 |
30 |
31 | def downgrade():
32 | ### commands auto generated by Alembic - please adjust! ###
33 | op.drop_index('ix_posts_timestamp', 'posts')
34 | op.drop_table('posts')
35 | ### end Alembic commands ###
36 |
--------------------------------------------------------------------------------
/app/templates/index.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "bootstrap/wtf.html" as wtf %}
3 | {% import "_macros.html" as macros %}
4 |
5 | {% block title %}Flasky{% endblock %}
6 |
7 | {% block page_content %}
8 |
11 |
12 | {% if current_user.can(Permission.WRITE_ARTICLES) %}
13 | {{ wtf.quick_form(form) }}
14 | {% endif %}
15 |
16 |
17 |
18 | All
19 | {% if current_user.is_authenticated %}
20 | Followers
21 | {% endif %}
22 |
23 | {% include '_posts.html' %}
24 |
25 | {% if pagination %}
26 |
29 | {% endif %}
30 | {% endblock %}
31 |
32 | {% block scripts %}
33 | {{ super() }}
34 | {{ pagedown.include_pagedown() }}
35 | {% endblock %}
36 |
--------------------------------------------------------------------------------
/migrations/versions/d66f086b258_user_information.py:
--------------------------------------------------------------------------------
1 | """user information
2 |
3 | Revision ID: d66f086b258
4 | Revises: 56ed7d33de8d
5 | Create Date: 2013-12-29 23:50:49.566954
6 |
7 | """
8 |
9 | # revision identifiers, used by Alembic.
10 | revision = 'd66f086b258'
11 | down_revision = '56ed7d33de8d'
12 |
13 | from alembic import op
14 | import sqlalchemy as sa
15 |
16 |
17 | def upgrade():
18 | ### commands auto generated by Alembic - please adjust! ###
19 | op.add_column('users', sa.Column('about_me', sa.Text(), nullable=True))
20 | op.add_column('users', sa.Column('last_seen', sa.DateTime(), nullable=True))
21 | op.add_column('users', sa.Column('location', sa.String(length=64), nullable=True))
22 | op.add_column('users', sa.Column('member_since', sa.DateTime(), nullable=True))
23 | op.add_column('users', sa.Column('name', sa.String(length=64), nullable=True))
24 | ### end Alembic commands ###
25 |
26 |
27 | def downgrade():
28 | ### commands auto generated by Alembic - please adjust! ###
29 | op.drop_column('users', 'name')
30 | op.drop_column('users', 'member_since')
31 | op.drop_column('users', 'location')
32 | op.drop_column('users', 'last_seen')
33 | op.drop_column('users', 'about_me')
34 | ### end Alembic commands ###
35 |
--------------------------------------------------------------------------------
/app/templates/_macros.html:
--------------------------------------------------------------------------------
1 | {% macro pagination_widget(pagination, endpoint, fragment='') %}
2 |
29 | {% endmacro %}
30 |
--------------------------------------------------------------------------------
/migrations/versions/38c4e85512a9_initial_migration.py:
--------------------------------------------------------------------------------
1 | """initial migration
2 |
3 | Revision ID: 38c4e85512a9
4 | Revises: None
5 | Create Date: 2013-12-27 01:23:59.392801
6 |
7 | """
8 |
9 | # revision identifiers, used by Alembic.
10 | revision = '38c4e85512a9'
11 | down_revision = None
12 |
13 | from alembic import op
14 | import sqlalchemy as sa
15 |
16 |
17 | def upgrade():
18 | ### commands auto generated by Alembic - please adjust! ###
19 | op.create_table('roles',
20 | sa.Column('id', sa.Integer(), nullable=False),
21 | sa.Column('name', sa.String(length=64), nullable=True),
22 | sa.PrimaryKeyConstraint('id'),
23 | sa.UniqueConstraint('name')
24 | )
25 | op.create_table('users',
26 | sa.Column('id', sa.Integer(), nullable=False),
27 | sa.Column('username', sa.String(length=64), nullable=True),
28 | sa.Column('role_id', sa.Integer(), nullable=True),
29 | sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
30 | sa.PrimaryKeyConstraint('id')
31 | )
32 | op.create_index('ix_users_username', 'users', ['username'], unique=True)
33 | ### end Alembic commands ###
34 |
35 |
36 | def downgrade():
37 | ### commands auto generated by Alembic - please adjust! ###
38 | op.drop_index('ix_users_username', 'users')
39 | op.drop_table('users')
40 | op.drop_table('roles')
41 | ### end Alembic commands ###
42 |
--------------------------------------------------------------------------------
/migrations/versions/51f5ccfba190_comments.py:
--------------------------------------------------------------------------------
1 | """comments
2 |
3 | Revision ID: 51f5ccfba190
4 | Revises: 2356a38169ea
5 | Create Date: 2014-01-01 12:08:43.287523
6 |
7 | """
8 |
9 | # revision identifiers, used by Alembic.
10 | revision = '51f5ccfba190'
11 | down_revision = '2356a38169ea'
12 |
13 | from alembic import op
14 | import sqlalchemy as sa
15 |
16 |
17 | def upgrade():
18 | ### commands auto generated by Alembic - please adjust! ###
19 | op.create_table('comments',
20 | sa.Column('id', sa.Integer(), nullable=False),
21 | sa.Column('body', sa.Text(), nullable=True),
22 | sa.Column('body_html', sa.Text(), nullable=True),
23 | sa.Column('timestamp', sa.DateTime(), nullable=True),
24 | sa.Column('disabled', sa.Boolean(), nullable=True),
25 | sa.Column('author_id', sa.Integer(), nullable=True),
26 | sa.Column('post_id', sa.Integer(), nullable=True),
27 | sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
28 | sa.ForeignKeyConstraint(['post_id'], ['posts.id'], ),
29 | sa.PrimaryKeyConstraint('id')
30 | )
31 | op.create_index('ix_comments_timestamp', 'comments', ['timestamp'], unique=False)
32 | ### end Alembic commands ###
33 |
34 |
35 | def downgrade():
36 | ### commands auto generated by Alembic - please adjust! ###
37 | op.drop_index('ix_comments_timestamp', 'comments')
38 | op.drop_table('comments')
39 | ### end Alembic commands ###
40 |
--------------------------------------------------------------------------------
/app/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Flask
2 | from flask_bootstrap import Bootstrap
3 | from flask_mail import Mail
4 | from flask_moment import Moment
5 | from flask_sqlalchemy import SQLAlchemy
6 | from flask_login import LoginManager
7 | from flask_pagedown import PageDown
8 | from config import config
9 |
10 | bootstrap = Bootstrap()
11 | mail = Mail()
12 | moment = Moment()
13 | db = SQLAlchemy()
14 | pagedown = PageDown()
15 |
16 | login_manager = LoginManager()
17 | login_manager.session_protection = 'strong'
18 | login_manager.login_view = 'auth.login'
19 |
20 |
21 | def create_app(config_name):
22 | app = Flask(__name__)
23 | app.config.from_object(config[config_name])
24 | config[config_name].init_app(app)
25 |
26 | bootstrap.init_app(app)
27 | mail.init_app(app)
28 | moment.init_app(app)
29 | db.init_app(app)
30 | login_manager.init_app(app)
31 | pagedown.init_app(app)
32 |
33 | if not app.debug and not app.testing and not app.config['SSL_DISABLE']:
34 | from flask_sslify import SSLify
35 | sslify = SSLify(app)
36 |
37 | from .main import main as main_blueprint
38 | app.register_blueprint(main_blueprint)
39 |
40 | from .auth import auth as auth_blueprint
41 | app.register_blueprint(auth_blueprint, url_prefix='/auth')
42 |
43 | from .api_1_0 import api as api_1_0_blueprint
44 | app.register_blueprint(api_1_0_blueprint, url_prefix='/api/v1.0')
45 |
46 | return app
47 |
--------------------------------------------------------------------------------
/app/api_1_0/authentication.py:
--------------------------------------------------------------------------------
1 | from flask import g, jsonify
2 | from flask_httpauth import HTTPBasicAuth
3 | from ..models import User, AnonymousUser
4 | from . import api
5 | from .errors import unauthorized, forbidden
6 |
7 | auth = HTTPBasicAuth()
8 |
9 |
10 | @auth.verify_password
11 | def verify_password(email_or_token, password):
12 | if email_or_token == '':
13 | g.current_user = AnonymousUser()
14 | return True
15 | if password == '':
16 | g.current_user = User.verify_auth_token(email_or_token)
17 | g.token_used = True
18 | return g.current_user is not None
19 | user = User.query.filter_by(email=email_or_token).first()
20 | if not user:
21 | return False
22 | g.current_user = user
23 | g.token_used = False
24 | return user.verify_password(password)
25 |
26 |
27 | @auth.error_handler
28 | def auth_error():
29 | return unauthorized('Invalid credentials')
30 |
31 |
32 | @api.before_request
33 | @auth.login_required
34 | def before_request():
35 | if not g.current_user.is_anonymous and \
36 | not g.current_user.confirmed:
37 | return forbidden('Unconfirmed account')
38 |
39 |
40 | @api.route('/token')
41 | def get_token():
42 | if g.current_user.is_anonymous or g.token_used:
43 | return unauthorized('Invalid credentials')
44 | return jsonify({'token': g.current_user.generate_auth_token(
45 | expiration=3600), 'expiration': 3600})
46 |
--------------------------------------------------------------------------------
/app/templates/_comments.html:
--------------------------------------------------------------------------------
1 |
36 |
--------------------------------------------------------------------------------
/app/templates/_posts.html:
--------------------------------------------------------------------------------
1 |
2 | {% for post in posts %}
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
{{ moment(post.timestamp).fromNow() }}
11 |
12 |
13 | {% if post.body_html %}
14 | {{ post.body_html | safe }}
15 | {% else %}
16 | {{ post.body }}
17 | {% endif %}
18 |
19 |
36 |
37 |
38 | {% endfor %}
39 |
40 |
--------------------------------------------------------------------------------
/app/api_1_0/posts.py:
--------------------------------------------------------------------------------
1 | from flask import jsonify, request, g, abort, url_for, current_app
2 | from .. import db
3 | from ..models import Post, Permission
4 | from . import api
5 | from .decorators import permission_required
6 | from .errors import forbidden
7 |
8 |
9 | @api.route('/posts/')
10 | def get_posts():
11 | page = request.args.get('page', 1, type=int)
12 | pagination = Post.query.paginate(
13 | page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
14 | error_out=False)
15 | posts = pagination.items
16 | prev = None
17 | if pagination.has_prev:
18 | prev = url_for('api.get_posts', page=page-1, _external=True)
19 | next = None
20 | if pagination.has_next:
21 | next = url_for('api.get_posts', page=page+1, _external=True)
22 | return jsonify({
23 | 'posts': [post.to_json() for post in posts],
24 | 'prev': prev,
25 | 'next': next,
26 | 'count': pagination.total
27 | })
28 |
29 |
30 | @api.route('/posts/')
31 | def get_post(id):
32 | post = Post.query.get_or_404(id)
33 | return jsonify(post.to_json())
34 |
35 |
36 | @api.route('/posts/', methods=['POST'])
37 | @permission_required(Permission.WRITE_ARTICLES)
38 | def new_post():
39 | post = Post.from_json(request.json)
40 | post.author = g.current_user
41 | db.session.add(post)
42 | db.session.commit()
43 | return jsonify(post.to_json()), 201, \
44 | {'Location': url_for('api.get_post', id=post.id, _external=True)}
45 |
46 |
47 | @api.route('/posts/', methods=['PUT'])
48 | @permission_required(Permission.WRITE_ARTICLES)
49 | def edit_post(id):
50 | post = Post.query.get_or_404(id)
51 | if g.current_user != post.author and \
52 | not g.current_user.can(Permission.ADMINISTER):
53 | return forbidden('Insufficient permissions')
54 | post.body = request.json.get('body', post.body)
55 | db.session.add(post)
56 | return jsonify(post.to_json())
57 |
--------------------------------------------------------------------------------
/app/api_1_0/users.py:
--------------------------------------------------------------------------------
1 | from flask import jsonify, request, current_app, url_for
2 | from . import api
3 | from ..models import User, Post
4 |
5 |
6 | @api.route('/users/')
7 | def get_user(id):
8 | user = User.query.get_or_404(id)
9 | return jsonify(user.to_json())
10 |
11 |
12 | @api.route('/users//posts/')
13 | def get_user_posts(id):
14 | user = User.query.get_or_404(id)
15 | page = request.args.get('page', 1, type=int)
16 | pagination = user.posts.order_by(Post.timestamp.desc()).paginate(
17 | page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
18 | error_out=False)
19 | posts = pagination.items
20 | prev = None
21 | if pagination.has_prev:
22 | prev = url_for('api.get_user_posts', page=page-1, _external=True)
23 | next = None
24 | if pagination.has_next:
25 | next = url_for('api.get_user_posts', page=page+1, _external=True)
26 | return jsonify({
27 | 'posts': [post.to_json() for post in posts],
28 | 'prev': prev,
29 | 'next': next,
30 | 'count': pagination.total
31 | })
32 |
33 |
34 | @api.route('/users//timeline/')
35 | def get_user_followed_posts(id):
36 | user = User.query.get_or_404(id)
37 | page = request.args.get('page', 1, type=int)
38 | pagination = user.followed_posts.order_by(Post.timestamp.desc()).paginate(
39 | page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
40 | error_out=False)
41 | posts = pagination.items
42 | prev = None
43 | if pagination.has_prev:
44 | prev = url_for('api.get_user_followed_posts', page=page-1,
45 | _external=True)
46 | next = None
47 | if pagination.has_next:
48 | next = url_for('api.get_user_followed_posts', page=page+1,
49 | _external=True)
50 | return jsonify({
51 | 'posts': [post.to_json() for post in posts],
52 | 'prev': prev,
53 | 'next': next,
54 | 'count': pagination.total
55 | })
56 |
--------------------------------------------------------------------------------
/tests/test_client.py:
--------------------------------------------------------------------------------
1 | import re
2 | import unittest
3 | from flask import url_for
4 | from app import create_app, db
5 | from app.models import User, Role
6 |
7 | class FlaskClientTestCase(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 | Role.insert_roles()
14 | self.client = self.app.test_client(use_cookies=True)
15 |
16 | def tearDown(self):
17 | db.session.remove()
18 | db.drop_all()
19 | self.app_context.pop()
20 |
21 | def test_home_page(self):
22 | response = self.client.get(url_for('main.index'))
23 | self.assertTrue(b'Stranger' in response.data)
24 |
25 | def test_register_and_login(self):
26 | # register a new account
27 | response = self.client.post(url_for('auth.register'), data={
28 | 'email': 'john@example.com',
29 | 'username': 'john',
30 | 'password': 'cat',
31 | 'password2': 'cat'
32 | })
33 | self.assertTrue(response.status_code == 302)
34 |
35 | # login with the new account
36 | response = self.client.post(url_for('auth.login'), data={
37 | 'email': 'john@example.com',
38 | 'password': 'cat'
39 | }, follow_redirects=True)
40 | self.assertTrue(re.search(b'Hello,\s+john!', response.data))
41 | self.assertTrue(
42 | b'You have not confirmed your account yet' in response.data)
43 |
44 | # send a confirmation token
45 | user = User.query.filter_by(email='john@example.com').first()
46 | token = user.generate_confirmation_token()
47 | response = self.client.get(url_for('auth.confirm', token=token),
48 | follow_redirects=True)
49 | self.assertTrue(
50 | b'You have confirmed your account' in response.data)
51 |
52 | # log out
53 | response = self.client.get(url_for('auth.logout'), follow_redirects=True)
54 | self.assertTrue(b'You have been logged out' in response.data)
55 |
--------------------------------------------------------------------------------
/app/static/styles.css:
--------------------------------------------------------------------------------
1 | .profile-thumbnail {
2 | position: absolute;
3 | }
4 | .profile-header {
5 | min-height: 260px;
6 | margin-left: 280px;
7 | }
8 | div.post-tabs {
9 | margin-top: 16px;
10 | }
11 | ul.posts {
12 | list-style-type: none;
13 | padding: 0px;
14 | margin: 16px 0px 0px 0px;
15 | border-top: 1px solid #e0e0e0;
16 | }
17 | div.post-tabs ul.posts {
18 | margin: 0px;
19 | border-top: none;
20 | }
21 | ul.posts li.post {
22 | padding: 8px;
23 | border-bottom: 1px solid #e0e0e0;
24 | }
25 | ul.posts li.post:hover {
26 | background-color: #f0f0f0;
27 | }
28 | div.post-date {
29 | float: right;
30 | }
31 | div.post-author {
32 | font-weight: bold;
33 | }
34 | div.post-thumbnail {
35 | position: absolute;
36 | }
37 | div.post-content {
38 | margin-left: 48px;
39 | min-height: 48px;
40 | }
41 | div.post-footer {
42 | text-align: right;
43 | }
44 | ul.comments {
45 | list-style-type: none;
46 | padding: 0px;
47 | margin: 16px 0px 0px 0px;
48 | }
49 | ul.comments li.comment {
50 | margin-left: 32px;
51 | padding: 8px;
52 | border-bottom: 1px solid #e0e0e0;
53 | }
54 | ul.comments li.comment:nth-child(1) {
55 | border-top: 1px solid #e0e0e0;
56 | }
57 | ul.comments li.comment:hover {
58 | background-color: #f0f0f0;
59 | }
60 | div.comment-date {
61 | float: right;
62 | }
63 | div.comment-author {
64 | font-weight: bold;
65 | }
66 | div.comment-thumbnail {
67 | position: absolute;
68 | }
69 | div.comment-content {
70 | margin-left: 48px;
71 | min-height: 48px;
72 | }
73 | div.comment-form {
74 | margin: 16px 0px 16px 32px;
75 | }
76 | div.pagination {
77 | width: 100%;
78 | text-align: right;
79 | padding: 0px;
80 | margin: 0px;
81 | }
82 | div.flask-pagedown-preview {
83 | margin: 10px 0px 10px 0px;
84 | border: 1px solid #e0e0e0;
85 | padding: 4px;
86 | }
87 | div.flask-pagedown-preview h1 {
88 | font-size: 140%;
89 | }
90 | div.flask-pagedown-preview h2 {
91 | font-size: 130%;
92 | }
93 | div.flask-pagedown-preview h3 {
94 | font-size: 120%;
95 | }
96 | .post-body h1 {
97 | font-size: 140%;
98 | }
99 | .post-body h2 {
100 | font-size: 130%;
101 | }
102 | .post-body h3 {
103 | font-size: 120%;
104 | }
105 | .table.followers tr {
106 | border-bottom: 1px solid #e0e0e0;
107 | }
108 |
--------------------------------------------------------------------------------
/migrations/env.py:
--------------------------------------------------------------------------------
1 | from __future__ import with_statement
2 | from alembic import context
3 | from sqlalchemy import engine_from_config, pool
4 | from logging.config import fileConfig
5 |
6 | # this is the Alembic Config object, which provides
7 | # access to the values within the .ini file in use.
8 | config = context.config
9 |
10 | # Interpret the config file for Python logging.
11 | # This line sets up loggers basically.
12 | fileConfig(config.config_file_name)
13 |
14 | # add your model's MetaData object here
15 | # for 'autogenerate' support
16 | # from myapp import mymodel
17 | # target_metadata = mymodel.Base.metadata
18 | from flask import current_app
19 | config.set_main_option('sqlalchemy.url', current_app.config.get('SQLALCHEMY_DATABASE_URI'))
20 | target_metadata = current_app.extensions['migrate'].db.metadata
21 |
22 | # other values from the config, defined by the needs of env.py,
23 | # can be acquired:
24 | # my_important_option = config.get_main_option("my_important_option")
25 | # ... etc.
26 |
27 | def run_migrations_offline():
28 | """Run migrations in 'offline' mode.
29 |
30 | This configures the context with just a URL
31 | and not an Engine, though an Engine is acceptable
32 | here as well. By skipping the Engine creation
33 | we don't even need a DBAPI to be available.
34 |
35 | Calls to context.execute() here emit the given string to the
36 | script output.
37 |
38 | """
39 | url = config.get_main_option("sqlalchemy.url")
40 | context.configure(url=url)
41 |
42 | with context.begin_transaction():
43 | context.run_migrations()
44 |
45 | def run_migrations_online():
46 | """Run migrations in 'online' mode.
47 |
48 | In this scenario we need to create an Engine
49 | and associate a connection with the context.
50 |
51 | """
52 | engine = engine_from_config(
53 | config.get_section(config.config_ini_section),
54 | prefix='sqlalchemy.',
55 | poolclass=pool.NullPool)
56 |
57 | connection = engine.connect()
58 | context.configure(
59 | connection=connection,
60 | target_metadata=target_metadata
61 | )
62 |
63 | try:
64 | with context.begin_transaction():
65 | context.run_migrations()
66 | finally:
67 | connection.close()
68 |
69 | if context.is_offline_mode():
70 | run_migrations_offline()
71 | else:
72 | run_migrations_online()
73 |
74 |
--------------------------------------------------------------------------------
/app/api_1_0/comments.py:
--------------------------------------------------------------------------------
1 | from flask import jsonify, request, g, url_for, current_app
2 | from .. import db
3 | from ..models import Post, Permission, Comment
4 | from . import api
5 | from .decorators import permission_required
6 |
7 |
8 | @api.route('/comments/')
9 | def get_comments():
10 | page = request.args.get('page', 1, type=int)
11 | pagination = Comment.query.order_by(Comment.timestamp.desc()).paginate(
12 | page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'],
13 | error_out=False)
14 | comments = pagination.items
15 | prev = None
16 | if pagination.has_prev:
17 | prev = url_for('api.get_comments', page=page-1, _external=True)
18 | next = None
19 | if pagination.has_next:
20 | next = url_for('api.get_comments', page=page+1, _external=True)
21 | return jsonify({
22 | 'comments': [comment.to_json() for comment in comments],
23 | 'prev': prev,
24 | 'next': next,
25 | 'count': pagination.total
26 | })
27 |
28 |
29 | @api.route('/comments/')
30 | def get_comment(id):
31 | comment = Comment.query.get_or_404(id)
32 | return jsonify(comment.to_json())
33 |
34 |
35 | @api.route('/posts//comments/')
36 | def get_post_comments(id):
37 | post = Post.query.get_or_404(id)
38 | page = request.args.get('page', 1, type=int)
39 | pagination = post.comments.order_by(Comment.timestamp.asc()).paginate(
40 | page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'],
41 | error_out=False)
42 | comments = pagination.items
43 | prev = None
44 | if pagination.has_prev:
45 | prev = url_for('api.get_post_comments', page=page-1, _external=True)
46 | next = None
47 | if pagination.has_next:
48 | next = url_for('api.get_post_comments', page=page+1, _external=True)
49 | return jsonify({
50 | 'comments': [comment.to_json() for comment in comments],
51 | 'prev': prev,
52 | 'next': next,
53 | 'count': pagination.total
54 | })
55 |
56 |
57 | @api.route('/posts//comments/', methods=['POST'])
58 | @permission_required(Permission.COMMENT)
59 | def new_post_comment(id):
60 | post = Post.query.get_or_404(id)
61 | comment = Comment.from_json(request.json)
62 | comment.author = g.current_user
63 | comment.post = post
64 | db.session.add(comment)
65 | db.session.commit()
66 | return jsonify(comment.to_json()), 201, \
67 | {'Location': url_for('api.get_comment', id=comment.id,
68 | _external=True)}
69 |
--------------------------------------------------------------------------------
/app/main/forms.py:
--------------------------------------------------------------------------------
1 | from flask_wtf import Form
2 | from wtforms import StringField, TextAreaField, BooleanField, SelectField,\
3 | SubmitField
4 | from wtforms.validators import Required, Length, Email, Regexp
5 | from wtforms import ValidationError
6 | from flask_pagedown.fields import PageDownField
7 | from ..models import Role, User
8 |
9 |
10 | class NameForm(Form):
11 | name = StringField('What is your name?', validators=[Required()])
12 | submit = SubmitField('Submit')
13 |
14 |
15 | class EditProfileForm(Form):
16 | name = StringField('Real name', validators=[Length(0, 64)])
17 | location = StringField('Location', validators=[Length(0, 64)])
18 | about_me = TextAreaField('About me')
19 | submit = SubmitField('Submit')
20 |
21 |
22 | class EditProfileAdminForm(Form):
23 | email = StringField('Email', validators=[Required(), Length(1, 64),
24 | Email()])
25 | username = StringField('Username', validators=[
26 | Required(), Length(1, 64), Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0,
27 | 'Usernames must have only letters, '
28 | 'numbers, dots or underscores')])
29 | confirmed = BooleanField('Confirmed')
30 | role = SelectField('Role', coerce=int)
31 | name = StringField('Real name', validators=[Length(0, 64)])
32 | location = StringField('Location', validators=[Length(0, 64)])
33 | about_me = TextAreaField('About me')
34 | submit = SubmitField('Submit')
35 |
36 | def __init__(self, user, *args, **kwargs):
37 | super(EditProfileAdminForm, self).__init__(*args, **kwargs)
38 | self.role.choices = [(role.id, role.name)
39 | for role in Role.query.order_by(Role.name).all()]
40 | self.user = user
41 |
42 | def validate_email(self, field):
43 | if field.data != self.user.email and \
44 | User.query.filter_by(email=field.data).first():
45 | raise ValidationError('Email already registered.')
46 |
47 | def validate_username(self, field):
48 | if field.data != self.user.username and \
49 | User.query.filter_by(username=field.data).first():
50 | raise ValidationError('Username already in use.')
51 |
52 |
53 | class PostForm(Form):
54 | body = PageDownField("What's on your mind?", validators=[Required()])
55 | submit = SubmitField('Submit')
56 |
57 |
58 | class CommentForm(Form):
59 | body = StringField('Enter your comment', validators=[Required()])
60 | submit = SubmitField('Submit')
61 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os
3 | COV = None
4 | if os.environ.get('FLASK_COVERAGE'):
5 | import coverage
6 | COV = coverage.coverage(branch=True, include='app/*')
7 | COV.start()
8 |
9 | if os.path.exists('.env'):
10 | print('Importing environment from .env...')
11 | for line in open('.env'):
12 | var = line.strip().split('=')
13 | if len(var) == 2:
14 | os.environ[var[0]] = var[1]
15 |
16 | from app import create_app, db
17 | from app.models import User, Follow, Role, Permission, Post, Comment
18 | from flask_script import Manager, Shell
19 | from flask_migrate import Migrate, MigrateCommand
20 |
21 | app = create_app(os.getenv('FLASK_CONFIG') or 'default')
22 | manager = Manager(app)
23 | migrate = Migrate(app, db)
24 |
25 |
26 | def make_shell_context():
27 | return dict(app=app, db=db, User=User, Follow=Follow, Role=Role,
28 | Permission=Permission, Post=Post, Comment=Comment)
29 | manager.add_command("shell", Shell(make_context=make_shell_context))
30 | manager.add_command('db', MigrateCommand)
31 |
32 |
33 | @manager.command
34 | def test(coverage=False):
35 | """Run the unit tests."""
36 | if coverage and not os.environ.get('FLASK_COVERAGE'):
37 | import sys
38 | os.environ['FLASK_COVERAGE'] = '1'
39 | os.execvp(sys.executable, [sys.executable] + sys.argv)
40 | import unittest
41 | tests = unittest.TestLoader().discover('tests')
42 | unittest.TextTestRunner(verbosity=2).run(tests)
43 | if COV:
44 | COV.stop()
45 | COV.save()
46 | print('Coverage Summary:')
47 | COV.report()
48 | basedir = os.path.abspath(os.path.dirname(__file__))
49 | covdir = os.path.join(basedir, 'tmp/coverage')
50 | COV.html_report(directory=covdir)
51 | print('HTML version: file://%s/index.html' % covdir)
52 | COV.erase()
53 |
54 |
55 | @manager.command
56 | def profile(length=25, profile_dir=None):
57 | """Start the application under the code profiler."""
58 | from werkzeug.contrib.profiler import ProfilerMiddleware
59 | app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[length],
60 | profile_dir=profile_dir)
61 | app.run()
62 |
63 |
64 | @manager.command
65 | def deploy():
66 | """Run deployment tasks."""
67 | from flask.ext.migrate import upgrade
68 | from app.models import Role, User
69 |
70 | # migrate database to latest revision
71 | upgrade()
72 |
73 | # create user roles
74 | Role.insert_roles()
75 |
76 | # create self-follows for all users
77 | User.add_self_follows()
78 |
79 |
80 | if __name__ == '__main__':
81 | manager.run()
82 |
--------------------------------------------------------------------------------
/app/templates/user.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "_macros.html" as macros %}
3 |
4 | {% block title %}Flasky - {{ user.username }}{% endblock %}
5 |
6 | {% block page_content %}
7 |
49 | Posts by {{ user.username }}
50 | {% include '_posts.html' %}
51 | {% if pagination %}
52 |
55 | {% endif %}
56 | {% endblock %}
57 |
--------------------------------------------------------------------------------
/app/templates/base.html:
--------------------------------------------------------------------------------
1 | {% extends "bootstrap/base.html" %}
2 |
3 | {% block title %}Flasky{% endblock %}
4 |
5 | {% block head %}
6 | {{ super() }}
7 |
8 |
9 |
10 | {% endblock %}
11 |
12 | {% block navbar %}
13 |
14 |
15 |
24 |
25 |
26 | Home
27 | {% if current_user.is_authenticated %}
28 | Profile
29 | {% endif %}
30 |
31 |
32 | {% if current_user.can(Permission.MODERATE_COMMENTS) %}
33 | Moderate Comments
34 | {% endif %}
35 | {% if current_user.is_authenticated %}
36 |
37 |
38 |
39 | Account
40 |
41 |
46 |
47 | {% else %}
48 | Log In
49 | {% endif %}
50 |
51 |
52 |
53 |
54 | {% endblock %}
55 |
56 | {% block content %}
57 |
58 | {% for message in get_flashed_messages() %}
59 |
60 | ×
61 | {{ message }}
62 |
63 | {% endfor %}
64 |
65 | {% block page_content %}{% endblock %}
66 |
67 | {% endblock %}
68 |
69 | {% block scripts %}
70 | {{ super() }}
71 | {{ moment.include_moment() }}
72 | {% endblock %}
73 |
--------------------------------------------------------------------------------
/tests/test_selenium.py:
--------------------------------------------------------------------------------
1 | import re
2 | import threading
3 | import time
4 | import unittest
5 | from selenium import webdriver
6 | from app import create_app, db
7 | from app.models import Role, User, Post
8 |
9 |
10 | class SeleniumTestCase(unittest.TestCase):
11 | client = None
12 |
13 | @classmethod
14 | def setUpClass(cls):
15 | # start Firefox
16 | try:
17 | cls.client = webdriver.Firefox()
18 | except:
19 | pass
20 |
21 | # skip these tests if the browser could not be started
22 | if cls.client:
23 | # create the application
24 | cls.app = create_app('testing')
25 | cls.app_context = cls.app.app_context()
26 | cls.app_context.push()
27 |
28 | # suppress logging to keep unittest output clean
29 | import logging
30 | logger = logging.getLogger('werkzeug')
31 | logger.setLevel("ERROR")
32 |
33 | # create the database and populate with some fake data
34 | db.create_all()
35 | Role.insert_roles()
36 | User.generate_fake(10)
37 | Post.generate_fake(10)
38 |
39 | # add an administrator user
40 | admin_role = Role.query.filter_by(permissions=0xff).first()
41 | admin = User(email='john@example.com',
42 | username='john', password='cat',
43 | role=admin_role, confirmed=True)
44 | db.session.add(admin)
45 | db.session.commit()
46 |
47 | # start the Flask server in a thread
48 | threading.Thread(target=cls.app.run).start()
49 |
50 | # give the server a second to ensure it is up
51 | time.sleep(1)
52 |
53 | @classmethod
54 | def tearDownClass(cls):
55 | if cls.client:
56 | # stop the flask server and the browser
57 | cls.client.get('http://localhost:5000/shutdown')
58 | cls.client.close()
59 |
60 | # destroy database
61 | db.drop_all()
62 | db.session.remove()
63 |
64 | # remove application context
65 | cls.app_context.pop()
66 |
67 | def setUp(self):
68 | if not self.client:
69 | self.skipTest('Web browser not available')
70 |
71 | def tearDown(self):
72 | pass
73 |
74 | def test_admin_home_page(self):
75 | # navigate to home page
76 | self.client.get('http://localhost:5000/')
77 | self.assertTrue(re.search('Hello,\s+Stranger!',
78 | self.client.page_source))
79 |
80 | # navigate to login page
81 | self.client.find_element_by_link_text('Log In').click()
82 | self.assertTrue('Login ' in self.client.page_source)
83 |
84 | # login
85 | self.client.find_element_by_name('email').\
86 | send_keys('john@example.com')
87 | self.client.find_element_by_name('password').send_keys('cat')
88 | self.client.find_element_by_name('submit').click()
89 | self.assertTrue(re.search('Hello,\s+john!', self.client.page_source))
90 |
91 | # navigate to the user's profile page
92 | self.client.find_element_by_link_text('Profile').click()
93 | self.assertTrue('john ' in self.client.page_source)
94 |
--------------------------------------------------------------------------------
/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, Regexp, EqualTo
4 | from wtforms import ValidationError
5 | from ..models import User
6 |
7 |
8 | class LoginForm(Form):
9 | email = StringField('Email', validators=[Required(), Length(1, 64),
10 | Email()])
11 | password = PasswordField('Password', validators=[Required()])
12 | remember_me = BooleanField('Keep me logged in')
13 | submit = SubmitField('Log In')
14 |
15 |
16 | class RegistrationForm(Form):
17 | email = StringField('Email', validators=[Required(), Length(1, 64),
18 | Email()])
19 | username = StringField('Username', validators=[
20 | Required(), Length(1, 64), Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0,
21 | 'Usernames must have only letters, '
22 | 'numbers, dots or underscores')])
23 | password = PasswordField('Password', validators=[
24 | Required(), EqualTo('password2', message='Passwords must match.')])
25 | password2 = PasswordField('Confirm password', validators=[Required()])
26 | submit = SubmitField('Register')
27 |
28 | def validate_email(self, field):
29 | if User.query.filter_by(email=field.data).first():
30 | raise ValidationError('Email already registered.')
31 |
32 | def validate_username(self, field):
33 | if User.query.filter_by(username=field.data).first():
34 | raise ValidationError('Username already in use.')
35 |
36 |
37 | class ChangePasswordForm(Form):
38 | old_password = PasswordField('Old password', validators=[Required()])
39 | password = PasswordField('New password', validators=[
40 | Required(), EqualTo('password2', message='Passwords must match')])
41 | password2 = PasswordField('Confirm new password', validators=[Required()])
42 | submit = SubmitField('Update Password')
43 |
44 |
45 | class PasswordResetRequestForm(Form):
46 | email = StringField('Email', validators=[Required(), Length(1, 64),
47 | Email()])
48 | submit = SubmitField('Reset Password')
49 |
50 |
51 | class PasswordResetForm(Form):
52 | email = StringField('Email', validators=[Required(), Length(1, 64),
53 | Email()])
54 | password = PasswordField('New Password', validators=[
55 | Required(), EqualTo('password2', message='Passwords must match')])
56 | password2 = PasswordField('Confirm password', validators=[Required()])
57 | submit = SubmitField('Reset Password')
58 |
59 | def validate_email(self, field):
60 | if User.query.filter_by(email=field.data).first() is None:
61 | raise ValidationError('Unknown email address.')
62 |
63 |
64 | class ChangeEmailForm(Form):
65 | email = StringField('New Email', validators=[Required(), Length(1, 64),
66 | Email()])
67 | password = PasswordField('Password', validators=[Required()])
68 | submit = SubmitField('Update Email Address')
69 |
70 | def validate_email(self, field):
71 | if User.query.filter_by(email=field.data).first():
72 | raise ValidationError('Email already registered.')
73 |
--------------------------------------------------------------------------------
/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') or 'hard to guess string'
7 | SSL_DISABLE = False
8 | SQLALCHEMY_COMMIT_ON_TEARDOWN = True
9 | SQLALCHEMY_RECORD_QUERIES = True
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 | FLASKY_MAIL_SUBJECT_PREFIX = '[Flasky]'
16 | FLASKY_MAIL_SENDER = 'Flasky Admin '
17 | FLASKY_ADMIN = os.environ.get('FLASKY_ADMIN')
18 | FLASKY_POSTS_PER_PAGE = 20
19 | FLASKY_FOLLOWERS_PER_PAGE = 50
20 | FLASKY_COMMENTS_PER_PAGE = 30
21 | FLASKY_SLOW_DB_QUERY_TIME=0.5
22 |
23 | @staticmethod
24 | def init_app(app):
25 | pass
26 |
27 |
28 | class DevelopmentConfig(Config):
29 | DEBUG = True
30 | SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
31 | 'sqlite:///' + os.path.join(basedir, 'data-dev.sqlite')
32 |
33 |
34 | class TestingConfig(Config):
35 | TESTING = True
36 | SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
37 | 'sqlite:///' + os.path.join(basedir, 'data-test.sqlite')
38 | WTF_CSRF_ENABLED = False
39 |
40 |
41 | class ProductionConfig(Config):
42 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
43 | 'sqlite:///' + os.path.join(basedir, 'data.sqlite')
44 |
45 | @classmethod
46 | def init_app(cls, app):
47 | Config.init_app(app)
48 |
49 | # email errors to the administrators
50 | import logging
51 | from logging.handlers import SMTPHandler
52 | credentials = None
53 | secure = None
54 | if getattr(cls, 'MAIL_USERNAME', None) is not None:
55 | credentials = (cls.MAIL_USERNAME, cls.MAIL_PASSWORD)
56 | if getattr(cls, 'MAIL_USE_TLS', None):
57 | secure = ()
58 | mail_handler = SMTPHandler(
59 | mailhost=(cls.MAIL_SERVER, cls.MAIL_PORT),
60 | fromaddr=cls.FLASKY_MAIL_SENDER,
61 | toaddrs=[cls.FLASKY_ADMIN],
62 | subject=cls.FLASKY_MAIL_SUBJECT_PREFIX + ' Application Error',
63 | credentials=credentials,
64 | secure=secure)
65 | mail_handler.setLevel(logging.ERROR)
66 | app.logger.addHandler(mail_handler)
67 |
68 |
69 | class HerokuConfig(ProductionConfig):
70 | SSL_DISABLE = bool(os.environ.get('SSL_DISABLE'))
71 |
72 | @classmethod
73 | def init_app(cls, app):
74 | ProductionConfig.init_app(app)
75 |
76 | # handle proxy server headers
77 | from werkzeug.contrib.fixers import ProxyFix
78 | app.wsgi_app = ProxyFix(app.wsgi_app)
79 |
80 | # log to stderr
81 | import logging
82 | from logging import StreamHandler
83 | file_handler = StreamHandler()
84 | file_handler.setLevel(logging.WARNING)
85 | app.logger.addHandler(file_handler)
86 |
87 |
88 | class UnixConfig(ProductionConfig):
89 | @classmethod
90 | def init_app(cls, app):
91 | ProductionConfig.init_app(app)
92 |
93 | # log to syslog
94 | import logging
95 | from logging.handlers import SysLogHandler
96 | syslog_handler = SysLogHandler()
97 | syslog_handler.setLevel(logging.WARNING)
98 | app.logger.addHandler(syslog_handler)
99 |
100 |
101 | config = {
102 | 'development': DevelopmentConfig,
103 | 'testing': TestingConfig,
104 | 'production': ProductionConfig,
105 | 'heroku': HerokuConfig,
106 | 'unix': UnixConfig,
107 |
108 | 'default': DevelopmentConfig
109 | }
110 |
--------------------------------------------------------------------------------
/app/auth/views.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, redirect, request, url_for, flash
2 | from flask_login import login_user, logout_user, login_required, \
3 | current_user
4 | from . import auth
5 | from .. import db
6 | from ..models import User
7 | from ..email import send_email
8 | from .forms import LoginForm, RegistrationForm, ChangePasswordForm,\
9 | PasswordResetRequestForm, PasswordResetForm, ChangeEmailForm
10 |
11 |
12 | @auth.before_app_request
13 | def before_request():
14 | if current_user.is_authenticated:
15 | current_user.ping()
16 | if not current_user.confirmed \
17 | and request.endpoint[:5] != 'auth.' \
18 | and request.endpoint != 'static':
19 | return redirect(url_for('auth.unconfirmed'))
20 |
21 |
22 | @auth.route('/unconfirmed')
23 | def unconfirmed():
24 | if current_user.is_anonymous or current_user.confirmed:
25 | return redirect(url_for('main.index'))
26 | return render_template('auth/unconfirmed.html')
27 |
28 |
29 | @auth.route('/login', methods=['GET', 'POST'])
30 | def login():
31 | form = LoginForm()
32 | if form.validate_on_submit():
33 | user = User.query.filter_by(email=form.email.data).first()
34 | if user is not None and user.verify_password(form.password.data):
35 | login_user(user, form.remember_me.data)
36 | return redirect(request.args.get('next') or url_for('main.index'))
37 | flash('Invalid username or password.')
38 | return render_template('auth/login.html', form=form)
39 |
40 |
41 | @auth.route('/logout')
42 | @login_required
43 | def logout():
44 | logout_user()
45 | flash('You have been logged out.')
46 | return redirect(url_for('main.index'))
47 |
48 |
49 | @auth.route('/register', methods=['GET', 'POST'])
50 | def register():
51 | form = RegistrationForm()
52 | if form.validate_on_submit():
53 | user = User(email=form.email.data,
54 | username=form.username.data,
55 | password=form.password.data)
56 | db.session.add(user)
57 | db.session.commit()
58 | token = user.generate_confirmation_token()
59 | send_email(user.email, 'Confirm Your Account',
60 | 'auth/email/confirm', user=user, token=token)
61 | flash('A confirmation email has been sent to you by email.')
62 | return redirect(url_for('auth.login'))
63 | return render_template('auth/register.html', form=form)
64 |
65 |
66 | @auth.route('/confirm/')
67 | @login_required
68 | def confirm(token):
69 | if current_user.confirmed:
70 | return redirect(url_for('main.index'))
71 | if current_user.confirm(token):
72 | flash('You have confirmed your account. Thanks!')
73 | else:
74 | flash('The confirmation link is invalid or has expired.')
75 | return redirect(url_for('main.index'))
76 |
77 |
78 | @auth.route('/confirm')
79 | @login_required
80 | def resend_confirmation():
81 | token = current_user.generate_confirmation_token()
82 | send_email(current_user.email, 'Confirm Your Account',
83 | 'auth/email/confirm', user=current_user, token=token)
84 | flash('A new confirmation email has been sent to you by email.')
85 | return redirect(url_for('main.index'))
86 |
87 |
88 | @auth.route('/change-password', methods=['GET', 'POST'])
89 | @login_required
90 | def change_password():
91 | form = ChangePasswordForm()
92 | if form.validate_on_submit():
93 | if current_user.verify_password(form.old_password.data):
94 | current_user.password = form.password.data
95 | db.session.add(current_user)
96 | flash('Your password has been updated.')
97 | return redirect(url_for('main.index'))
98 | else:
99 | flash('Invalid password.')
100 | return render_template("auth/change_password.html", form=form)
101 |
102 |
103 | @auth.route('/reset', methods=['GET', 'POST'])
104 | def password_reset_request():
105 | if not current_user.is_anonymous:
106 | return redirect(url_for('main.index'))
107 | form = PasswordResetRequestForm()
108 | if form.validate_on_submit():
109 | user = User.query.filter_by(email=form.email.data).first()
110 | if user:
111 | token = user.generate_reset_token()
112 | send_email(user.email, 'Reset Your Password',
113 | 'auth/email/reset_password',
114 | user=user, token=token,
115 | next=request.args.get('next'))
116 | flash('An email with instructions to reset your password has been '
117 | 'sent to you.')
118 | return redirect(url_for('auth.login'))
119 | return render_template('auth/reset_password.html', form=form)
120 |
121 |
122 | @auth.route('/reset/', methods=['GET', 'POST'])
123 | def password_reset(token):
124 | if not current_user.is_anonymous:
125 | return redirect(url_for('main.index'))
126 | form = PasswordResetForm()
127 | if form.validate_on_submit():
128 | user = User.query.filter_by(email=form.email.data).first()
129 | if user is None:
130 | return redirect(url_for('main.index'))
131 | if user.reset_password(token, form.password.data):
132 | flash('Your password has been updated.')
133 | return redirect(url_for('auth.login'))
134 | else:
135 | return redirect(url_for('main.index'))
136 | return render_template('auth/reset_password.html', form=form)
137 |
138 |
139 | @auth.route('/change-email', methods=['GET', 'POST'])
140 | @login_required
141 | def change_email_request():
142 | form = ChangeEmailForm()
143 | if form.validate_on_submit():
144 | if current_user.verify_password(form.password.data):
145 | new_email = form.email.data
146 | token = current_user.generate_email_change_token(new_email)
147 | send_email(new_email, 'Confirm your email address',
148 | 'auth/email/change_email',
149 | user=current_user, token=token)
150 | flash('An email with instructions to confirm your new email '
151 | 'address has been sent to you.')
152 | return redirect(url_for('main.index'))
153 | else:
154 | flash('Invalid email or password.')
155 | return render_template("auth/change_email.html", form=form)
156 |
157 |
158 | @auth.route('/change-email/')
159 | @login_required
160 | def change_email(token):
161 | if current_user.change_email(token):
162 | flash('Your email address has been updated.')
163 | else:
164 | flash('Invalid request.')
165 | return redirect(url_for('main.index'))
166 |
--------------------------------------------------------------------------------
/tests/test_user_model.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import time
3 | from datetime import datetime
4 | from app import create_app, db
5 | from app.models import User, AnonymousUser, Role, Permission, Follow
6 |
7 |
8 | class UserModelTestCase(unittest.TestCase):
9 | def setUp(self):
10 | self.app = create_app('testing')
11 | self.app_context = self.app.app_context()
12 | self.app_context.push()
13 | db.create_all()
14 | Role.insert_roles()
15 |
16 | def tearDown(self):
17 | db.session.remove()
18 | db.drop_all()
19 | self.app_context.pop()
20 |
21 | def test_password_setter(self):
22 | u = User(password='cat')
23 | self.assertTrue(u.password_hash is not None)
24 |
25 | def test_no_password_getter(self):
26 | u = User(password='cat')
27 | with self.assertRaises(AttributeError):
28 | u.password
29 |
30 | def test_password_verification(self):
31 | u = User(password='cat')
32 | self.assertTrue(u.verify_password('cat'))
33 | self.assertFalse(u.verify_password('dog'))
34 |
35 | def test_password_salts_are_random(self):
36 | u = User(password='cat')
37 | u2 = User(password='cat')
38 | self.assertTrue(u.password_hash != u2.password_hash)
39 |
40 | def test_valid_confirmation_token(self):
41 | u = User(password='cat')
42 | db.session.add(u)
43 | db.session.commit()
44 | token = u.generate_confirmation_token()
45 | self.assertTrue(u.confirm(token))
46 |
47 | def test_invalid_confirmation_token(self):
48 | u1 = User(password='cat')
49 | u2 = User(password='dog')
50 | db.session.add(u1)
51 | db.session.add(u2)
52 | db.session.commit()
53 | token = u1.generate_confirmation_token()
54 | self.assertFalse(u2.confirm(token))
55 |
56 | def test_expired_confirmation_token(self):
57 | u = User(password='cat')
58 | db.session.add(u)
59 | db.session.commit()
60 | token = u.generate_confirmation_token(1)
61 | time.sleep(2)
62 | self.assertFalse(u.confirm(token))
63 |
64 | def test_valid_reset_token(self):
65 | u = User(password='cat')
66 | db.session.add(u)
67 | db.session.commit()
68 | token = u.generate_reset_token()
69 | self.assertTrue(u.reset_password(token, 'dog'))
70 | self.assertTrue(u.verify_password('dog'))
71 |
72 | def test_invalid_reset_token(self):
73 | u1 = User(password='cat')
74 | u2 = User(password='dog')
75 | db.session.add(u1)
76 | db.session.add(u2)
77 | db.session.commit()
78 | token = u1.generate_reset_token()
79 | self.assertFalse(u2.reset_password(token, 'horse'))
80 | self.assertTrue(u2.verify_password('dog'))
81 |
82 | def test_valid_email_change_token(self):
83 | u = User(email='john@example.com', password='cat')
84 | db.session.add(u)
85 | db.session.commit()
86 | token = u.generate_email_change_token('susan@example.org')
87 | self.assertTrue(u.change_email(token))
88 | self.assertTrue(u.email == 'susan@example.org')
89 |
90 | def test_invalid_email_change_token(self):
91 | u1 = User(email='john@example.com', password='cat')
92 | u2 = User(email='susan@example.org', password='dog')
93 | db.session.add(u1)
94 | db.session.add(u2)
95 | db.session.commit()
96 | token = u1.generate_email_change_token('david@example.net')
97 | self.assertFalse(u2.change_email(token))
98 | self.assertTrue(u2.email == 'susan@example.org')
99 |
100 | def test_duplicate_email_change_token(self):
101 | u1 = User(email='john@example.com', password='cat')
102 | u2 = User(email='susan@example.org', password='dog')
103 | db.session.add(u1)
104 | db.session.add(u2)
105 | db.session.commit()
106 | token = u2.generate_email_change_token('john@example.com')
107 | self.assertFalse(u2.change_email(token))
108 | self.assertTrue(u2.email == 'susan@example.org')
109 |
110 | def test_roles_and_permissions(self):
111 | u = User(email='john@example.com', password='cat')
112 | self.assertTrue(u.can(Permission.WRITE_ARTICLES))
113 | self.assertFalse(u.can(Permission.MODERATE_COMMENTS))
114 |
115 | def test_anonymous_user(self):
116 | u = AnonymousUser()
117 | self.assertFalse(u.can(Permission.FOLLOW))
118 |
119 | def test_timestamps(self):
120 | u = User(password='cat')
121 | db.session.add(u)
122 | db.session.commit()
123 | self.assertTrue(
124 | (datetime.utcnow() - u.member_since).total_seconds() < 3)
125 | self.assertTrue(
126 | (datetime.utcnow() - u.last_seen).total_seconds() < 3)
127 |
128 | def test_ping(self):
129 | u = User(password='cat')
130 | db.session.add(u)
131 | db.session.commit()
132 | time.sleep(2)
133 | last_seen_before = u.last_seen
134 | u.ping()
135 | self.assertTrue(u.last_seen > last_seen_before)
136 |
137 | def test_gravatar(self):
138 | u = User(email='john@example.com', password='cat')
139 | with self.app.test_request_context('/'):
140 | gravatar = u.gravatar()
141 | gravatar_256 = u.gravatar(size=256)
142 | gravatar_pg = u.gravatar(rating='pg')
143 | gravatar_retro = u.gravatar(default='retro')
144 | with self.app.test_request_context('/', base_url='https://example.com'):
145 | gravatar_ssl = u.gravatar()
146 | self.assertTrue('http://www.gravatar.com/avatar/' +
147 | 'd4c74594d841139328695756648b6bd6'in gravatar)
148 | self.assertTrue('s=256' in gravatar_256)
149 | self.assertTrue('r=pg' in gravatar_pg)
150 | self.assertTrue('d=retro' in gravatar_retro)
151 | self.assertTrue('https://secure.gravatar.com/avatar/' +
152 | 'd4c74594d841139328695756648b6bd6' in gravatar_ssl)
153 |
154 | def test_follows(self):
155 | u1 = User(email='john@example.com', password='cat')
156 | u2 = User(email='susan@example.org', password='dog')
157 | db.session.add(u1)
158 | db.session.add(u2)
159 | db.session.commit()
160 | self.assertFalse(u1.is_following(u2))
161 | self.assertFalse(u1.is_followed_by(u2))
162 | timestamp_before = datetime.utcnow()
163 | u1.follow(u2)
164 | db.session.add(u1)
165 | db.session.commit()
166 | timestamp_after = datetime.utcnow()
167 | self.assertTrue(u1.is_following(u2))
168 | self.assertFalse(u1.is_followed_by(u2))
169 | self.assertTrue(u2.is_followed_by(u1))
170 | self.assertTrue(u1.followed.count() == 2)
171 | self.assertTrue(u2.followers.count() == 2)
172 | f = u1.followed.all()[-1]
173 | self.assertTrue(f.followed == u2)
174 | self.assertTrue(timestamp_before <= f.timestamp <= timestamp_after)
175 | f = u2.followers.all()[-1]
176 | self.assertTrue(f.follower == u1)
177 | u1.unfollow(u2)
178 | db.session.add(u1)
179 | db.session.commit()
180 | self.assertTrue(u1.followed.count() == 1)
181 | self.assertTrue(u2.followers.count() == 1)
182 | self.assertTrue(Follow.query.count() == 2)
183 | u2.follow(u1)
184 | db.session.add(u1)
185 | db.session.add(u2)
186 | db.session.commit()
187 | db.session.delete(u2)
188 | db.session.commit()
189 | self.assertTrue(Follow.query.count() == 1)
190 |
191 | def test_to_json(self):
192 | u = User(email='john@example.com', password='cat')
193 | db.session.add(u)
194 | db.session.commit()
195 | json_user = u.to_json()
196 | expected_keys = ['url', 'username', 'member_since', 'last_seen',
197 | 'posts', 'followed_posts', 'post_count']
198 | self.assertEqual(sorted(json_user.keys()), sorted(expected_keys))
199 | self.assertTrue('api/v1.0/users/' in json_user['url'])
200 |
--------------------------------------------------------------------------------
/app/main/views.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, redirect, url_for, abort, flash, request,\
2 | current_app, make_response
3 | from flask_login import login_required, current_user
4 | from flask_sqlalchemy import get_debug_queries
5 | from . import main
6 | from .forms import EditProfileForm, EditProfileAdminForm, PostForm,\
7 | CommentForm
8 | from .. import db
9 | from ..models import Permission, Role, User, Post, Comment
10 | from ..decorators import admin_required, permission_required
11 |
12 |
13 | @main.after_app_request
14 | def after_request(response):
15 | for query in get_debug_queries():
16 | if query.duration >= current_app.config['FLASKY_SLOW_DB_QUERY_TIME']:
17 | current_app.logger.warning(
18 | 'Slow query: %s\nParameters: %s\nDuration: %fs\nContext: %s\n'
19 | % (query.statement, query.parameters, query.duration,
20 | query.context))
21 | return response
22 |
23 |
24 | @main.route('/shutdown')
25 | def server_shutdown():
26 | if not current_app.testing:
27 | abort(404)
28 | shutdown = request.environ.get('werkzeug.server.shutdown')
29 | if not shutdown:
30 | abort(500)
31 | shutdown()
32 | return 'Shutting down...'
33 |
34 |
35 | @main.route('/', methods=['GET', 'POST'])
36 | def index():
37 | form = PostForm()
38 | if current_user.can(Permission.WRITE_ARTICLES) and \
39 | form.validate_on_submit():
40 | post = Post(body=form.body.data,
41 | author=current_user._get_current_object())
42 | db.session.add(post)
43 | return redirect(url_for('.index'))
44 | page = request.args.get('page', 1, type=int)
45 | show_followed = False
46 | if current_user.is_authenticated:
47 | show_followed = bool(request.cookies.get('show_followed', ''))
48 | if show_followed:
49 | query = current_user.followed_posts
50 | else:
51 | query = Post.query
52 | pagination = query.order_by(Post.timestamp.desc()).paginate(
53 | page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
54 | error_out=False)
55 | posts = pagination.items
56 | return render_template('index.html', form=form, posts=posts,
57 | show_followed=show_followed, pagination=pagination)
58 |
59 |
60 | @main.route('/user/')
61 | def user(username):
62 | user = User.query.filter_by(username=username).first_or_404()
63 | page = request.args.get('page', 1, type=int)
64 | pagination = user.posts.order_by(Post.timestamp.desc()).paginate(
65 | page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
66 | error_out=False)
67 | posts = pagination.items
68 | return render_template('user.html', user=user, posts=posts,
69 | pagination=pagination)
70 |
71 |
72 | @main.route('/edit-profile', methods=['GET', 'POST'])
73 | @login_required
74 | def edit_profile():
75 | form = EditProfileForm()
76 | if form.validate_on_submit():
77 | current_user.name = form.name.data
78 | current_user.location = form.location.data
79 | current_user.about_me = form.about_me.data
80 | db.session.add(current_user)
81 | flash('Your profile has been updated.')
82 | return redirect(url_for('.user', username=current_user.username))
83 | form.name.data = current_user.name
84 | form.location.data = current_user.location
85 | form.about_me.data = current_user.about_me
86 | return render_template('edit_profile.html', form=form)
87 |
88 |
89 | @main.route('/edit-profile/', methods=['GET', 'POST'])
90 | @login_required
91 | @admin_required
92 | def edit_profile_admin(id):
93 | user = User.query.get_or_404(id)
94 | form = EditProfileAdminForm(user=user)
95 | if form.validate_on_submit():
96 | user.email = form.email.data
97 | user.username = form.username.data
98 | user.confirmed = form.confirmed.data
99 | user.role = Role.query.get(form.role.data)
100 | user.name = form.name.data
101 | user.location = form.location.data
102 | user.about_me = form.about_me.data
103 | db.session.add(user)
104 | flash('The profile has been updated.')
105 | return redirect(url_for('.user', username=user.username))
106 | form.email.data = user.email
107 | form.username.data = user.username
108 | form.confirmed.data = user.confirmed
109 | form.role.data = user.role_id
110 | form.name.data = user.name
111 | form.location.data = user.location
112 | form.about_me.data = user.about_me
113 | return render_template('edit_profile.html', form=form, user=user)
114 |
115 |
116 | @main.route('/post/', methods=['GET', 'POST'])
117 | def post(id):
118 | post = Post.query.get_or_404(id)
119 | form = CommentForm()
120 | if form.validate_on_submit():
121 | comment = Comment(body=form.body.data,
122 | post=post,
123 | author=current_user._get_current_object())
124 | db.session.add(comment)
125 | flash('Your comment has been published.')
126 | return redirect(url_for('.post', id=post.id, page=-1))
127 | page = request.args.get('page', 1, type=int)
128 | if page == -1:
129 | page = (post.comments.count() - 1) // \
130 | current_app.config['FLASKY_COMMENTS_PER_PAGE'] + 1
131 | pagination = post.comments.order_by(Comment.timestamp.asc()).paginate(
132 | page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'],
133 | error_out=False)
134 | comments = pagination.items
135 | return render_template('post.html', posts=[post], form=form,
136 | comments=comments, pagination=pagination)
137 |
138 |
139 | @main.route('/edit/', methods=['GET', 'POST'])
140 | @login_required
141 | def edit(id):
142 | post = Post.query.get_or_404(id)
143 | if current_user != post.author and \
144 | not current_user.can(Permission.ADMINISTER):
145 | abort(403)
146 | form = PostForm()
147 | if form.validate_on_submit():
148 | post.body = form.body.data
149 | db.session.add(post)
150 | flash('The post has been updated.')
151 | return redirect(url_for('.post', id=post.id))
152 | form.body.data = post.body
153 | return render_template('edit_post.html', form=form)
154 |
155 |
156 | @main.route('/follow/')
157 | @login_required
158 | @permission_required(Permission.FOLLOW)
159 | def follow(username):
160 | user = User.query.filter_by(username=username).first()
161 | if user is None:
162 | flash('Invalid user.')
163 | return redirect(url_for('.index'))
164 | if current_user.is_following(user):
165 | flash('You are already following this user.')
166 | return redirect(url_for('.user', username=username))
167 | current_user.follow(user)
168 | flash('You are now following %s.' % username)
169 | return redirect(url_for('.user', username=username))
170 |
171 |
172 | @main.route('/unfollow/')
173 | @login_required
174 | @permission_required(Permission.FOLLOW)
175 | def unfollow(username):
176 | user = User.query.filter_by(username=username).first()
177 | if user is None:
178 | flash('Invalid user.')
179 | return redirect(url_for('.index'))
180 | if not current_user.is_following(user):
181 | flash('You are not following this user.')
182 | return redirect(url_for('.user', username=username))
183 | current_user.unfollow(user)
184 | flash('You are not following %s anymore.' % username)
185 | return redirect(url_for('.user', username=username))
186 |
187 |
188 | @main.route('/followers/')
189 | def followers(username):
190 | user = User.query.filter_by(username=username).first()
191 | if user is None:
192 | flash('Invalid user.')
193 | return redirect(url_for('.index'))
194 | page = request.args.get('page', 1, type=int)
195 | pagination = user.followers.paginate(
196 | page, per_page=current_app.config['FLASKY_FOLLOWERS_PER_PAGE'],
197 | error_out=False)
198 | follows = [{'user': item.follower, 'timestamp': item.timestamp}
199 | for item in pagination.items]
200 | return render_template('followers.html', user=user, title="Followers of",
201 | endpoint='.followers', pagination=pagination,
202 | follows=follows)
203 |
204 |
205 | @main.route('/followed-by/')
206 | def followed_by(username):
207 | user = User.query.filter_by(username=username).first()
208 | if user is None:
209 | flash('Invalid user.')
210 | return redirect(url_for('.index'))
211 | page = request.args.get('page', 1, type=int)
212 | pagination = user.followed.paginate(
213 | page, per_page=current_app.config['FLASKY_FOLLOWERS_PER_PAGE'],
214 | error_out=False)
215 | follows = [{'user': item.followed, 'timestamp': item.timestamp}
216 | for item in pagination.items]
217 | return render_template('followers.html', user=user, title="Followed by",
218 | endpoint='.followed_by', pagination=pagination,
219 | follows=follows)
220 |
221 |
222 | @main.route('/all')
223 | @login_required
224 | def show_all():
225 | resp = make_response(redirect(url_for('.index')))
226 | resp.set_cookie('show_followed', '', max_age=30*24*60*60)
227 | return resp
228 |
229 |
230 | @main.route('/followed')
231 | @login_required
232 | def show_followed():
233 | resp = make_response(redirect(url_for('.index')))
234 | resp.set_cookie('show_followed', '1', max_age=30*24*60*60)
235 | return resp
236 |
237 |
238 | @main.route('/moderate')
239 | @login_required
240 | @permission_required(Permission.MODERATE_COMMENTS)
241 | def moderate():
242 | page = request.args.get('page', 1, type=int)
243 | pagination = Comment.query.order_by(Comment.timestamp.desc()).paginate(
244 | page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'],
245 | error_out=False)
246 | comments = pagination.items
247 | return render_template('moderate.html', comments=comments,
248 | pagination=pagination, page=page)
249 |
250 |
251 | @main.route('/moderate/enable/')
252 | @login_required
253 | @permission_required(Permission.MODERATE_COMMENTS)
254 | def moderate_enable(id):
255 | comment = Comment.query.get_or_404(id)
256 | comment.disabled = False
257 | db.session.add(comment)
258 | return redirect(url_for('.moderate',
259 | page=request.args.get('page', 1, type=int)))
260 |
261 |
262 | @main.route('/moderate/disable/')
263 | @login_required
264 | @permission_required(Permission.MODERATE_COMMENTS)
265 | def moderate_disable(id):
266 | comment = Comment.query.get_or_404(id)
267 | comment.disabled = True
268 | db.session.add(comment)
269 | return redirect(url_for('.moderate',
270 | page=request.args.get('page', 1, type=int)))
271 |
--------------------------------------------------------------------------------
/tests/test_api.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import json
3 | import re
4 | from base64 import b64encode
5 | from flask import url_for
6 | from app import create_app, db
7 | from app.models import User, Role, Post, Comment
8 |
9 |
10 | class APITestCase(unittest.TestCase):
11 | def setUp(self):
12 | self.app = create_app('testing')
13 | self.app_context = self.app.app_context()
14 | self.app_context.push()
15 | db.create_all()
16 | Role.insert_roles()
17 | self.client = self.app.test_client()
18 |
19 | def tearDown(self):
20 | db.session.remove()
21 | db.drop_all()
22 | self.app_context.pop()
23 |
24 | def get_api_headers(self, username, password):
25 | return {
26 | 'Authorization': 'Basic ' + b64encode(
27 | (username + ':' + password).encode('utf-8')).decode('utf-8'),
28 | 'Accept': 'application/json',
29 | 'Content-Type': 'application/json'
30 | }
31 |
32 | def test_404(self):
33 | response = self.client.get(
34 | '/wrong/url',
35 | headers=self.get_api_headers('email', 'password'))
36 | self.assertTrue(response.status_code == 404)
37 | json_response = json.loads(response.data.decode('utf-8'))
38 | self.assertTrue(json_response['error'] == 'not found')
39 |
40 | def test_no_auth(self):
41 | response = self.client.get(url_for('api.get_posts'),
42 | content_type='application/json')
43 | self.assertTrue(response.status_code == 200)
44 |
45 | def test_bad_auth(self):
46 | # add a user
47 | r = Role.query.filter_by(name='User').first()
48 | self.assertIsNotNone(r)
49 | u = User(email='john@example.com', password='cat', confirmed=True,
50 | role=r)
51 | db.session.add(u)
52 | db.session.commit()
53 |
54 | # authenticate with bad password
55 | response = self.client.get(
56 | url_for('api.get_posts'),
57 | headers=self.get_api_headers('john@example.com', 'dog'))
58 | self.assertTrue(response.status_code == 401)
59 |
60 | def test_token_auth(self):
61 | # add a user
62 | r = Role.query.filter_by(name='User').first()
63 | self.assertIsNotNone(r)
64 | u = User(email='john@example.com', password='cat', confirmed=True,
65 | role=r)
66 | db.session.add(u)
67 | db.session.commit()
68 |
69 | # issue a request with a bad token
70 | response = self.client.get(
71 | url_for('api.get_posts'),
72 | headers=self.get_api_headers('bad-token', ''))
73 | self.assertTrue(response.status_code == 401)
74 |
75 | # get a token
76 | response = self.client.get(
77 | url_for('api.get_token'),
78 | headers=self.get_api_headers('john@example.com', 'cat'))
79 | self.assertTrue(response.status_code == 200)
80 | json_response = json.loads(response.data.decode('utf-8'))
81 | self.assertIsNotNone(json_response.get('token'))
82 | token = json_response['token']
83 |
84 | # issue a request with the token
85 | response = self.client.get(
86 | url_for('api.get_posts'),
87 | headers=self.get_api_headers(token, ''))
88 | self.assertTrue(response.status_code == 200)
89 |
90 | def test_anonymous(self):
91 | response = self.client.get(
92 | url_for('api.get_posts'),
93 | headers=self.get_api_headers('', ''))
94 | self.assertTrue(response.status_code == 200)
95 |
96 | def test_unconfirmed_account(self):
97 | # add an unconfirmed user
98 | r = Role.query.filter_by(name='User').first()
99 | self.assertIsNotNone(r)
100 | u = User(email='john@example.com', password='cat', confirmed=False,
101 | role=r)
102 | db.session.add(u)
103 | db.session.commit()
104 |
105 | # get list of posts with the unconfirmed account
106 | response = self.client.get(
107 | url_for('api.get_posts'),
108 | headers=self.get_api_headers('john@example.com', 'cat'))
109 | self.assertTrue(response.status_code == 403)
110 |
111 | def test_posts(self):
112 | # add a user
113 | r = Role.query.filter_by(name='User').first()
114 | self.assertIsNotNone(r)
115 | u = User(email='john@example.com', password='cat', confirmed=True,
116 | role=r)
117 | db.session.add(u)
118 | db.session.commit()
119 |
120 | # write an empty post
121 | response = self.client.post(
122 | url_for('api.new_post'),
123 | headers=self.get_api_headers('john@example.com', 'cat'),
124 | data=json.dumps({'body': ''}))
125 | self.assertTrue(response.status_code == 400)
126 |
127 | # write a post
128 | response = self.client.post(
129 | url_for('api.new_post'),
130 | headers=self.get_api_headers('john@example.com', 'cat'),
131 | data=json.dumps({'body': 'body of the *blog* post'}))
132 | self.assertTrue(response.status_code == 201)
133 | url = response.headers.get('Location')
134 | self.assertIsNotNone(url)
135 |
136 | # get the new post
137 | response = self.client.get(
138 | url,
139 | headers=self.get_api_headers('john@example.com', 'cat'))
140 | self.assertTrue(response.status_code == 200)
141 | json_response = json.loads(response.data.decode('utf-8'))
142 | self.assertTrue(json_response['url'] == url)
143 | self.assertTrue(json_response['body'] == 'body of the *blog* post')
144 | self.assertTrue(json_response['body_html'] ==
145 | 'body of the blog post
')
146 | json_post = json_response
147 |
148 | # get the post from the user
149 | response = self.client.get(
150 | url_for('api.get_user_posts', id=u.id),
151 | headers=self.get_api_headers('john@example.com', 'cat'))
152 | self.assertTrue(response.status_code == 200)
153 | json_response = json.loads(response.data.decode('utf-8'))
154 | self.assertIsNotNone(json_response.get('posts'))
155 | self.assertTrue(json_response.get('count', 0) == 1)
156 | self.assertTrue(json_response['posts'][0] == json_post)
157 |
158 | # get the post from the user as a follower
159 | response = self.client.get(
160 | url_for('api.get_user_followed_posts', id=u.id),
161 | headers=self.get_api_headers('john@example.com', 'cat'))
162 | self.assertTrue(response.status_code == 200)
163 | json_response = json.loads(response.data.decode('utf-8'))
164 | self.assertIsNotNone(json_response.get('posts'))
165 | self.assertTrue(json_response.get('count', 0) == 1)
166 | self.assertTrue(json_response['posts'][0] == json_post)
167 |
168 | # edit post
169 | response = self.client.put(
170 | url,
171 | headers=self.get_api_headers('john@example.com', 'cat'),
172 | data=json.dumps({'body': 'updated body'}))
173 | self.assertTrue(response.status_code == 200)
174 | json_response = json.loads(response.data.decode('utf-8'))
175 | self.assertTrue(json_response['url'] == url)
176 | self.assertTrue(json_response['body'] == 'updated body')
177 | self.assertTrue(json_response['body_html'] == 'updated body
')
178 |
179 | def test_users(self):
180 | # add two users
181 | r = Role.query.filter_by(name='User').first()
182 | self.assertIsNotNone(r)
183 | u1 = User(email='john@example.com', username='john',
184 | password='cat', confirmed=True, role=r)
185 | u2 = User(email='susan@example.com', username='susan',
186 | password='dog', confirmed=True, role=r)
187 | db.session.add_all([u1, u2])
188 | db.session.commit()
189 |
190 | # get users
191 | response = self.client.get(
192 | url_for('api.get_user', id=u1.id),
193 | headers=self.get_api_headers('susan@example.com', 'dog'))
194 | self.assertTrue(response.status_code == 200)
195 | json_response = json.loads(response.data.decode('utf-8'))
196 | self.assertTrue(json_response['username'] == 'john')
197 | response = self.client.get(
198 | url_for('api.get_user', id=u2.id),
199 | headers=self.get_api_headers('susan@example.com', 'dog'))
200 | self.assertTrue(response.status_code == 200)
201 | json_response = json.loads(response.data.decode('utf-8'))
202 | self.assertTrue(json_response['username'] == 'susan')
203 |
204 | def test_comments(self):
205 | # add two users
206 | r = Role.query.filter_by(name='User').first()
207 | self.assertIsNotNone(r)
208 | u1 = User(email='john@example.com', username='john',
209 | password='cat', confirmed=True, role=r)
210 | u2 = User(email='susan@example.com', username='susan',
211 | password='dog', confirmed=True, role=r)
212 | db.session.add_all([u1, u2])
213 | db.session.commit()
214 |
215 | # add a post
216 | post = Post(body='body of the post', author=u1)
217 | db.session.add(post)
218 | db.session.commit()
219 |
220 | # write a comment
221 | response = self.client.post(
222 | url_for('api.new_post_comment', id=post.id),
223 | headers=self.get_api_headers('susan@example.com', 'dog'),
224 | data=json.dumps({'body': 'Good [post](http://example.com)!'}))
225 | self.assertTrue(response.status_code == 201)
226 | json_response = json.loads(response.data.decode('utf-8'))
227 | url = response.headers.get('Location')
228 | self.assertIsNotNone(url)
229 | self.assertTrue(json_response['body'] ==
230 | 'Good [post](http://example.com)!')
231 | self.assertTrue(
232 | re.sub('<.*?>', '', json_response['body_html']) == 'Good post!')
233 |
234 | # get the new comment
235 | response = self.client.get(
236 | url,
237 | headers=self.get_api_headers('john@example.com', 'cat'))
238 | self.assertTrue(response.status_code == 200)
239 | json_response = json.loads(response.data.decode('utf-8'))
240 | self.assertTrue(json_response['url'] == url)
241 | self.assertTrue(json_response['body'] ==
242 | 'Good [post](http://example.com)!')
243 |
244 | # add another comment
245 | comment = Comment(body='Thank you!', author=u1, post=post)
246 | db.session.add(comment)
247 | db.session.commit()
248 |
249 | # get the two comments from the post
250 | response = self.client.get(
251 | url_for('api.get_post_comments', id=post.id),
252 | headers=self.get_api_headers('susan@example.com', 'dog'))
253 | self.assertTrue(response.status_code == 200)
254 | json_response = json.loads(response.data.decode('utf-8'))
255 | self.assertIsNotNone(json_response.get('comments'))
256 | self.assertTrue(json_response.get('count', 0) == 2)
257 |
258 | # get all the comments
259 | response = self.client.get(
260 | url_for('api.get_comments', id=post.id),
261 | headers=self.get_api_headers('susan@example.com', 'dog'))
262 | self.assertTrue(response.status_code == 200)
263 | json_response = json.loads(response.data.decode('utf-8'))
264 | self.assertIsNotNone(json_response.get('comments'))
265 | self.assertTrue(json_response.get('count', 0) == 2)
266 |
--------------------------------------------------------------------------------
/app/models.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | import hashlib
3 | from werkzeug.security import generate_password_hash, check_password_hash
4 | from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
5 | from markdown import markdown
6 | import bleach
7 | from flask import current_app, request, url_for
8 | from flask_login import UserMixin, AnonymousUserMixin
9 | from app.exceptions import ValidationError
10 | from . import db, login_manager
11 |
12 |
13 | class Permission:
14 | FOLLOW = 0x01
15 | COMMENT = 0x02
16 | WRITE_ARTICLES = 0x04
17 | MODERATE_COMMENTS = 0x08
18 | ADMINISTER = 0x80
19 |
20 |
21 | class Role(db.Model):
22 | __tablename__ = 'roles'
23 | id = db.Column(db.Integer, primary_key=True)
24 | name = db.Column(db.String(64), unique=True)
25 | default = db.Column(db.Boolean, default=False, index=True)
26 | permissions = db.Column(db.Integer)
27 | users = db.relationship('User', backref='role', lazy='dynamic')
28 |
29 | @staticmethod
30 | def insert_roles():
31 | roles = {
32 | 'User': (Permission.FOLLOW |
33 | Permission.COMMENT |
34 | Permission.WRITE_ARTICLES, True),
35 | 'Moderator': (Permission.FOLLOW |
36 | Permission.COMMENT |
37 | Permission.WRITE_ARTICLES |
38 | Permission.MODERATE_COMMENTS, False),
39 | 'Administrator': (0xff, False)
40 | }
41 | for r in roles:
42 | role = Role.query.filter_by(name=r).first()
43 | if role is None:
44 | role = Role(name=r)
45 | role.permissions = roles[r][0]
46 | role.default = roles[r][1]
47 | db.session.add(role)
48 | db.session.commit()
49 |
50 | def __repr__(self):
51 | return '' % self.name
52 |
53 |
54 | class Follow(db.Model):
55 | __tablename__ = 'follows'
56 | follower_id = db.Column(db.Integer, db.ForeignKey('users.id'),
57 | primary_key=True)
58 | followed_id = db.Column(db.Integer, db.ForeignKey('users.id'),
59 | primary_key=True)
60 | timestamp = db.Column(db.DateTime, default=datetime.utcnow)
61 |
62 |
63 | class User(UserMixin, db.Model):
64 | __tablename__ = 'users'
65 | id = db.Column(db.Integer, primary_key=True)
66 | email = db.Column(db.String(64), unique=True, index=True)
67 | username = db.Column(db.String(64), unique=True, index=True)
68 | role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
69 | password_hash = db.Column(db.String(128))
70 | confirmed = db.Column(db.Boolean, default=False)
71 | name = db.Column(db.String(64))
72 | location = db.Column(db.String(64))
73 | about_me = db.Column(db.Text())
74 | member_since = db.Column(db.DateTime(), default=datetime.utcnow)
75 | last_seen = db.Column(db.DateTime(), default=datetime.utcnow)
76 | avatar_hash = db.Column(db.String(32))
77 | posts = db.relationship('Post', backref='author', lazy='dynamic')
78 | followed = db.relationship('Follow',
79 | foreign_keys=[Follow.follower_id],
80 | backref=db.backref('follower', lazy='joined'),
81 | lazy='dynamic',
82 | cascade='all, delete-orphan')
83 | followers = db.relationship('Follow',
84 | foreign_keys=[Follow.followed_id],
85 | backref=db.backref('followed', lazy='joined'),
86 | lazy='dynamic',
87 | cascade='all, delete-orphan')
88 | comments = db.relationship('Comment', backref='author', lazy='dynamic')
89 |
90 | @staticmethod
91 | def generate_fake(count=100):
92 | from sqlalchemy.exc import IntegrityError
93 | from random import seed
94 | import forgery_py
95 |
96 | seed()
97 | for i in range(count):
98 | u = User(email=forgery_py.internet.email_address(),
99 | username=forgery_py.internet.user_name(True),
100 | password=forgery_py.lorem_ipsum.word(),
101 | confirmed=True,
102 | name=forgery_py.name.full_name(),
103 | location=forgery_py.address.city(),
104 | about_me=forgery_py.lorem_ipsum.sentence(),
105 | member_since=forgery_py.date.date(True))
106 | db.session.add(u)
107 | try:
108 | db.session.commit()
109 | except IntegrityError:
110 | db.session.rollback()
111 |
112 | @staticmethod
113 | def add_self_follows():
114 | for user in User.query.all():
115 | if not user.is_following(user):
116 | user.follow(user)
117 | db.session.add(user)
118 | db.session.commit()
119 |
120 | def __init__(self, **kwargs):
121 | super(User, self).__init__(**kwargs)
122 | if self.role is None:
123 | if self.email == current_app.config['FLASKY_ADMIN']:
124 | self.role = Role.query.filter_by(permissions=0xff).first()
125 | if self.role is None:
126 | self.role = Role.query.filter_by(default=True).first()
127 | if self.email is not None and self.avatar_hash is None:
128 | self.avatar_hash = hashlib.md5(
129 | self.email.encode('utf-8')).hexdigest()
130 | self.followed.append(Follow(followed=self))
131 |
132 | @property
133 | def password(self):
134 | raise AttributeError('password is not a readable attribute')
135 |
136 | @password.setter
137 | def password(self, password):
138 | self.password_hash = generate_password_hash(password)
139 |
140 | def verify_password(self, password):
141 | return check_password_hash(self.password_hash, password)
142 |
143 | def generate_confirmation_token(self, expiration=3600):
144 | s = Serializer(current_app.config['SECRET_KEY'], expiration)
145 | return s.dumps({'confirm': self.id})
146 |
147 | def confirm(self, token):
148 | s = Serializer(current_app.config['SECRET_KEY'])
149 | try:
150 | data = s.loads(token)
151 | except:
152 | return False
153 | if data.get('confirm') != self.id:
154 | return False
155 | self.confirmed = True
156 | db.session.add(self)
157 | return True
158 |
159 | def generate_reset_token(self, expiration=3600):
160 | s = Serializer(current_app.config['SECRET_KEY'], expiration)
161 | return s.dumps({'reset': self.id})
162 |
163 | def reset_password(self, token, new_password):
164 | s = Serializer(current_app.config['SECRET_KEY'])
165 | try:
166 | data = s.loads(token)
167 | except:
168 | return False
169 | if data.get('reset') != self.id:
170 | return False
171 | self.password = new_password
172 | db.session.add(self)
173 | return True
174 |
175 | def generate_email_change_token(self, new_email, expiration=3600):
176 | s = Serializer(current_app.config['SECRET_KEY'], expiration)
177 | return s.dumps({'change_email': self.id, 'new_email': new_email})
178 |
179 | def change_email(self, token):
180 | s = Serializer(current_app.config['SECRET_KEY'])
181 | try:
182 | data = s.loads(token)
183 | except:
184 | return False
185 | if data.get('change_email') != self.id:
186 | return False
187 | new_email = data.get('new_email')
188 | if new_email is None:
189 | return False
190 | if self.query.filter_by(email=new_email).first() is not None:
191 | return False
192 | self.email = new_email
193 | self.avatar_hash = hashlib.md5(
194 | self.email.encode('utf-8')).hexdigest()
195 | db.session.add(self)
196 | return True
197 |
198 | def can(self, permissions):
199 | return self.role is not None and \
200 | (self.role.permissions & permissions) == permissions
201 |
202 | def is_administrator(self):
203 | return self.can(Permission.ADMINISTER)
204 |
205 | def ping(self):
206 | self.last_seen = datetime.utcnow()
207 | db.session.add(self)
208 |
209 | def gravatar(self, size=100, default='identicon', rating='g'):
210 | if request.is_secure:
211 | url = 'https://secure.gravatar.com/avatar'
212 | else:
213 | url = 'http://www.gravatar.com/avatar'
214 | hash = self.avatar_hash or hashlib.md5(
215 | self.email.encode('utf-8')).hexdigest()
216 | return '{url}/{hash}?s={size}&d={default}&r={rating}'.format(
217 | url=url, hash=hash, size=size, default=default, rating=rating)
218 |
219 | def follow(self, user):
220 | if not self.is_following(user):
221 | f = Follow(follower=self, followed=user)
222 | db.session.add(f)
223 |
224 | def unfollow(self, user):
225 | f = self.followed.filter_by(followed_id=user.id).first()
226 | if f:
227 | db.session.delete(f)
228 |
229 | def is_following(self, user):
230 | return self.followed.filter_by(
231 | followed_id=user.id).first() is not None
232 |
233 | def is_followed_by(self, user):
234 | return self.followers.filter_by(
235 | follower_id=user.id).first() is not None
236 |
237 | @property
238 | def followed_posts(self):
239 | return Post.query.join(Follow, Follow.followed_id == Post.author_id)\
240 | .filter(Follow.follower_id == self.id)
241 |
242 | def to_json(self):
243 | json_user = {
244 | 'url': url_for('api.get_user', id=self.id, _external=True),
245 | 'username': self.username,
246 | 'member_since': self.member_since,
247 | 'last_seen': self.last_seen,
248 | 'posts': url_for('api.get_user_posts', id=self.id, _external=True),
249 | 'followed_posts': url_for('api.get_user_followed_posts',
250 | id=self.id, _external=True),
251 | 'post_count': self.posts.count()
252 | }
253 | return json_user
254 |
255 | def generate_auth_token(self, expiration):
256 | s = Serializer(current_app.config['SECRET_KEY'],
257 | expires_in=expiration)
258 | return s.dumps({'id': self.id}).decode('ascii')
259 |
260 | @staticmethod
261 | def verify_auth_token(token):
262 | s = Serializer(current_app.config['SECRET_KEY'])
263 | try:
264 | data = s.loads(token)
265 | except:
266 | return None
267 | return User.query.get(data['id'])
268 |
269 | def __repr__(self):
270 | return '' % self.username
271 |
272 |
273 | class AnonymousUser(AnonymousUserMixin):
274 | def can(self, permissions):
275 | return False
276 |
277 | def is_administrator(self):
278 | return False
279 |
280 | login_manager.anonymous_user = AnonymousUser
281 |
282 |
283 | @login_manager.user_loader
284 | def load_user(user_id):
285 | return User.query.get(int(user_id))
286 |
287 |
288 | class Post(db.Model):
289 | __tablename__ = 'posts'
290 | id = db.Column(db.Integer, primary_key=True)
291 | body = db.Column(db.Text)
292 | body_html = db.Column(db.Text)
293 | timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
294 | author_id = db.Column(db.Integer, db.ForeignKey('users.id'))
295 | comments = db.relationship('Comment', backref='post', lazy='dynamic')
296 |
297 | @staticmethod
298 | def generate_fake(count=100):
299 | from random import seed, randint
300 | import forgery_py
301 |
302 | seed()
303 | user_count = User.query.count()
304 | for i in range(count):
305 | u = User.query.offset(randint(0, user_count - 1)).first()
306 | p = Post(body=forgery_py.lorem_ipsum.sentences(randint(1, 5)),
307 | timestamp=forgery_py.date.date(True),
308 | author=u)
309 | db.session.add(p)
310 | db.session.commit()
311 |
312 | @staticmethod
313 | def on_changed_body(target, value, oldvalue, initiator):
314 | allowed_tags = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code',
315 | 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul',
316 | 'h1', 'h2', 'h3', 'p']
317 | target.body_html = bleach.linkify(bleach.clean(
318 | markdown(value, output_format='html'),
319 | tags=allowed_tags, strip=True))
320 |
321 | def to_json(self):
322 | json_post = {
323 | 'url': url_for('api.get_post', id=self.id, _external=True),
324 | 'body': self.body,
325 | 'body_html': self.body_html,
326 | 'timestamp': self.timestamp,
327 | 'author': url_for('api.get_user', id=self.author_id,
328 | _external=True),
329 | 'comments': url_for('api.get_post_comments', id=self.id,
330 | _external=True),
331 | 'comment_count': self.comments.count()
332 | }
333 | return json_post
334 |
335 | @staticmethod
336 | def from_json(json_post):
337 | body = json_post.get('body')
338 | if body is None or body == '':
339 | raise ValidationError('post does not have a body')
340 | return Post(body=body)
341 |
342 |
343 | db.event.listen(Post.body, 'set', Post.on_changed_body)
344 |
345 |
346 | class Comment(db.Model):
347 | __tablename__ = 'comments'
348 | id = db.Column(db.Integer, primary_key=True)
349 | body = db.Column(db.Text)
350 | body_html = db.Column(db.Text)
351 | timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
352 | disabled = db.Column(db.Boolean)
353 | author_id = db.Column(db.Integer, db.ForeignKey('users.id'))
354 | post_id = db.Column(db.Integer, db.ForeignKey('posts.id'))
355 |
356 | @staticmethod
357 | def on_changed_body(target, value, oldvalue, initiator):
358 | allowed_tags = ['a', 'abbr', 'acronym', 'b', 'code', 'em', 'i',
359 | 'strong']
360 | target.body_html = bleach.linkify(bleach.clean(
361 | markdown(value, output_format='html'),
362 | tags=allowed_tags, strip=True))
363 |
364 | def to_json(self):
365 | json_comment = {
366 | 'url': url_for('api.get_comment', id=self.id, _external=True),
367 | 'post': url_for('api.get_post', id=self.post_id, _external=True),
368 | 'body': self.body,
369 | 'body_html': self.body_html,
370 | 'timestamp': self.timestamp,
371 | 'author': url_for('api.get_user', id=self.author_id,
372 | _external=True),
373 | }
374 | return json_comment
375 |
376 | @staticmethod
377 | def from_json(json_comment):
378 | body = json_comment.get('body')
379 | if body is None or body == '':
380 | raise ValidationError('comment does not have a body')
381 | return Comment(body=body)
382 |
383 |
384 | db.event.listen(Comment.body, 'set', Comment.on_changed_body)
385 |
--------------------------------------------------------------------------------