Skip to content

Commit 0fe030b

Browse files
Chapter 9: User roles and permissions (9a)
1 parent d5b2e68 commit 0fe030b

File tree

8 files changed

+183
-4
lines changed

8 files changed

+183
-4
lines changed

app/decorators.py

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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.ADMIN)(f)

app/main/__init__.py

+6
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,9 @@
33
main = Blueprint('main', __name__)
44

55
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)

app/main/errors.py

+5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
from . import main
33

44

5+
@main.app_errorhandler(403)
6+
def forbidden(e):
7+
return render_template('403.html'), 403
8+
9+
510
@main.app_errorhandler(404)
611
def page_not_found(e):
712
return render_template('404.html'), 404

app/models.py

+76-1
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,67 @@
11
from werkzeug.security import generate_password_hash, check_password_hash
22
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
33
from flask import current_app
4-
from flask_login import UserMixin
4+
from flask_login import UserMixin, AnonymousUserMixin
55
from . import db, login_manager
66

77

8+
class Permission:
9+
FOLLOW = 1
10+
COMMENT = 2
11+
WRITE = 4
12+
MODERATE = 8
13+
ADMIN = 16
14+
15+
816
class Role(db.Model):
917
__tablename__ = 'roles'
1018
id = db.Column(db.Integer, primary_key=True)
1119
name = db.Column(db.String(64), unique=True)
20+
default = db.Column(db.Boolean, default=False, index=True)
21+
permissions = db.Column(db.Integer)
1222
users = db.relationship('User', backref='role', lazy='dynamic')
1323

24+
def __init__(self, **kwargs):
25+
super(Role, self).__init__(**kwargs)
26+
if self.permissions is None:
27+
self.permissions = 0
28+
29+
@staticmethod
30+
def insert_roles():
31+
roles = {
32+
'User': [Permission.FOLLOW, Permission.COMMENT, Permission.WRITE],
33+
'Moderator': [Permission.FOLLOW, Permission.COMMENT,
34+
Permission.WRITE, Permission.MODERATE],
35+
'Administrator': [Permission.FOLLOW, Permission.COMMENT,
36+
Permission.WRITE, Permission.MODERATE,
37+
Permission.ADMIN],
38+
}
39+
default_role = 'User'
40+
for r in roles:
41+
role = Role.query.filter_by(name=r).first()
42+
if role is None:
43+
role = Role(name=r)
44+
role.reset_permissions()
45+
for perm in roles[r]:
46+
role.add_permission(perm)
47+
role.default = (role.name == default_role)
48+
db.session.add(role)
49+
db.session.commit()
50+
51+
def add_permission(self, perm):
52+
if not self.has_permission(perm):
53+
self.permissions += perm
54+
55+
def remove_permission(self, perm):
56+
if self.has_permission(perm):
57+
self.permissions -= perm
58+
59+
def reset_permissions(self):
60+
self.permissions = 0
61+
62+
def has_permission(self, perm):
63+
return self.permissions & perm == perm
64+
1465
def __repr__(self):
1566
return '<Role %r>' % self.name
1667

@@ -24,6 +75,14 @@ class User(UserMixin, db.Model):
2475
password_hash = db.Column(db.String(128))
2576
confirmed = db.Column(db.Boolean, default=False)
2677

78+
def __init__(self, **kwargs):
79+
super(User, self).__init__(**kwargs)
80+
if self.role is None:
81+
if self.email == current_app.config['FLASKY_ADMIN']:
82+
self.role = Role.query.filter_by(name='Administrator').first()
83+
if self.role is None:
84+
self.role = Role.query.filter_by(default=True).first()
85+
2786
@property
2887
def password(self):
2988
raise AttributeError('password is not a readable attribute')
@@ -91,10 +150,26 @@ def change_email(self, token):
91150
db.session.add(self)
92151
return True
93152

153+
def can(self, perm):
154+
return self.role is not None and self.role.has_permission(perm)
155+
156+
def is_administrator(self):
157+
return self.can(Permission.ADMIN)
158+
94159
def __repr__(self):
95160
return '<User %r>' % self.username
96161

97162

163+
class AnonymousUser(AnonymousUserMixin):
164+
def can(self, permissions):
165+
return False
166+
167+
def is_administrator(self):
168+
return False
169+
170+
login_manager.anonymous_user = AnonymousUser
171+
172+
98173
@login_manager.user_loader
99174
def load_user(user_id):
100175
return User.query.get(int(user_id))

app/templates/403.html

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{% extends "base.html" %}
2+
3+
{% block title %}Flasky - Forbidden{% endblock %}
4+
5+
{% block page_content %}
6+
<div class="page-header">
7+
<h1>Forbidden</h1>
8+
</div>
9+
{% endblock %}

flasky.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
import click
33
from flask_migrate import Migrate
44
from app import create_app, db
5-
from app.models import User, Role
5+
from app.models import User, Role, Permission
66

77
app = create_app(os.getenv('FLASK_CONFIG') or 'default')
88
migrate = Migrate(app, db)
99

1010

1111
@app.shell_context_processor
1212
def make_shell_context():
13-
return dict(db=db, User=User, Role=Role)
13+
return dict(db=db, User=User, Role=Role, Permission=Permission)
1414

1515

1616
@app.cli.command()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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 ###

tests/test_user_model.py

+36-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import unittest
22
import time
33
from app import create_app, db
4-
from app.models import User
4+
from app.models import User, AnonymousUser, Role, Permission
55

66

77
class UserModelTestCase(unittest.TestCase):
@@ -10,6 +10,7 @@ def setUp(self):
1010
self.app_context = self.app.app_context()
1111
self.app_context.push()
1212
db.create_all()
13+
Role.insert_roles()
1314

1415
def tearDown(self):
1516
db.session.remove()
@@ -102,3 +103,37 @@ def test_duplicate_email_change_token(self):
102103
token = u2.generate_email_change_token('john@example.com')
103104
self.assertFalse(u2.change_email(token))
104105
self.assertTrue(u2.email == 'susan@example.org')
106+
107+
def test_user_role(self):
108+
u = User(email='john@example.com', password='cat')
109+
self.assertTrue(u.can(Permission.FOLLOW))
110+
self.assertTrue(u.can(Permission.COMMENT))
111+
self.assertTrue(u.can(Permission.WRITE))
112+
self.assertFalse(u.can(Permission.MODERATE))
113+
self.assertFalse(u.can(Permission.ADMIN))
114+
115+
def test_moderator_role(self):
116+
r = Role.query.filter_by(name='Moderator').first()
117+
u = User(email='john@example.com', password='cat', role=r)
118+
self.assertTrue(u.can(Permission.FOLLOW))
119+
self.assertTrue(u.can(Permission.COMMENT))
120+
self.assertTrue(u.can(Permission.WRITE))
121+
self.assertTrue(u.can(Permission.MODERATE))
122+
self.assertFalse(u.can(Permission.ADMIN))
123+
124+
def test_administrator_role(self):
125+
r = Role.query.filter_by(name='Administrator').first()
126+
u = User(email='john@example.com', password='cat', role=r)
127+
self.assertTrue(u.can(Permission.FOLLOW))
128+
self.assertTrue(u.can(Permission.COMMENT))
129+
self.assertTrue(u.can(Permission.WRITE))
130+
self.assertTrue(u.can(Permission.MODERATE))
131+
self.assertTrue(u.can(Permission.ADMIN))
132+
133+
def test_anonymous_user(self):
134+
u = AnonymousUser()
135+
self.assertFalse(u.can(Permission.FOLLOW))
136+
self.assertFalse(u.can(Permission.COMMENT))
137+
self.assertFalse(u.can(Permission.WRITE))
138+
self.assertFalse(u.can(Permission.MODERATE))
139+
self.assertFalse(u.can(Permission.ADMIN))

0 commit comments

Comments
 (0)