From d1363e03260cf1c0cb4821e77ee3fafe70a67e4f Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Sat, 16 Sep 2017 00:05:17 -0700
Subject: [PATCH] Chapter 8: Followers (v0.8)
---
app/forms.py | 4 +
app/models.py | 31 ++++++
app/routes.py | 45 ++++++++-
app/templates/user.html | 15 +++
migrations/versions/ae346256b650_followers.py | 33 +++++++
tests.py | 97 +++++++++++++++++++
6 files changed, 223 insertions(+), 2 deletions(-)
create mode 100644 migrations/versions/ae346256b650_followers.py
create mode 100755 tests.py
diff --git a/app/forms.py b/app/forms.py
index f85a757..5f3cfe6 100644
--- a/app/forms.py
+++ b/app/forms.py
@@ -46,3 +46,7 @@ class EditProfileForm(FlaskForm):
user = User.query.filter_by(username=self.username.data).first()
if user is not None:
raise ValidationError('Please use a different username.')
+
+
+class EmptyForm(FlaskForm):
+ submit = SubmitField('Submit')
diff --git a/app/models.py b/app/models.py
index 7e3d48c..b160ccb 100644
--- a/app/models.py
+++ b/app/models.py
@@ -5,6 +5,13 @@ from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
+followers = db.Table(
+ 'followers',
+ db.Column('follower_id', db.Integer, db.ForeignKey('user.id')),
+ db.Column('followed_id', db.Integer, db.ForeignKey('user.id'))
+)
+
+
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), index=True, unique=True)
@@ -13,6 +20,11 @@ class User(UserMixin, db.Model):
posts = db.relationship('Post', backref='author', lazy='dynamic')
about_me = db.Column(db.String(140))
last_seen = db.Column(db.DateTime, default=datetime.utcnow)
+ followed = db.relationship(
+ 'User', secondary=followers,
+ primaryjoin=(followers.c.follower_id == id),
+ secondaryjoin=(followers.c.followed_id == id),
+ backref=db.backref('followers', lazy='dynamic'), lazy='dynamic')
def __repr__(self):
return ''.format(self.username)
@@ -28,6 +40,25 @@ class User(UserMixin, db.Model):
return 'https://www.gravatar.com/avatar/{}?d=identicon&s={}'.format(
digest, size)
+ def follow(self, user):
+ if not self.is_following(user):
+ self.followed.append(user)
+
+ def unfollow(self, user):
+ if self.is_following(user):
+ self.followed.remove(user)
+
+ def is_following(self, user):
+ return self.followed.filter(
+ followers.c.followed_id == user.id).count() > 0
+
+ def followed_posts(self):
+ followed = Post.query.join(
+ followers, (followers.c.followed_id == Post.user_id)).filter(
+ followers.c.follower_id == self.id)
+ own = Post.query.filter_by(user_id=self.id)
+ return followed.union(own).order_by(Post.timestamp.desc())
+
@login.user_loader
def load_user(id):
diff --git a/app/routes.py b/app/routes.py
index 2c25759..349c3a9 100644
--- a/app/routes.py
+++ b/app/routes.py
@@ -3,7 +3,7 @@ from flask import render_template, flash, redirect, url_for, request
from flask_login import login_user, logout_user, current_user, login_required
from werkzeug.urls import url_parse
from app import app, db
-from app.forms import LoginForm, RegistrationForm, EditProfileForm
+from app.forms import LoginForm, RegistrationForm, EditProfileForm, EmptyForm
from app.models import User
@@ -78,7 +78,8 @@ def user(username):
{'author': user, 'body': 'Test post #1'},
{'author': user, 'body': 'Test post #2'}
]
- return render_template('user.html', user=user, posts=posts)
+ form = EmptyForm()
+ return render_template('user.html', user=user, posts=posts, form=form)
@app.route('/edit_profile', methods=['GET', 'POST'])
@@ -96,3 +97,43 @@ def edit_profile():
form.about_me.data = current_user.about_me
return render_template('edit_profile.html', title='Edit Profile',
form=form)
+
+
+@app.route('/follow/', methods=['POST'])
+@login_required
+def follow(username):
+ form = EmptyForm()
+ if form.validate_on_submit():
+ user = User.query.filter_by(username=username).first()
+ if user is None:
+ flash('User {} not found.'.format(username))
+ return redirect(url_for('index'))
+ if user == current_user:
+ flash('You cannot follow yourself!')
+ return redirect(url_for('user', username=username))
+ current_user.follow(user)
+ db.session.commit()
+ flash('You are following {}!'.format(username))
+ return redirect(url_for('user', username=username))
+ else:
+ return redirect(url_for('index'))
+
+
+@app.route('/unfollow/', methods=['POST'])
+@login_required
+def unfollow(username):
+ form = EmptyForm()
+ if form.validate_on_submit():
+ user = User.query.filter_by(username=username).first()
+ if user is None:
+ flash('User {} not found.'.format(username))
+ return redirect(url_for('index'))
+ if user == current_user:
+ flash('You cannot unfollow yourself!')
+ return redirect(url_for('user', username=username))
+ current_user.unfollow(user)
+ db.session.commit()
+ flash('You are not following {}.'.format(username))
+ return redirect(url_for('user', username=username))
+ else:
+ return redirect(url_for('index'))
diff --git a/app/templates/user.html b/app/templates/user.html
index 5bacb4e..3ecbbff 100644
--- a/app/templates/user.html
+++ b/app/templates/user.html
@@ -8,8 +8,23 @@
User: {{ user.username }}
{% if user.about_me %}{{ user.about_me }}
{% endif %}
{% if user.last_seen %}Last seen on: {{ user.last_seen }}
{% endif %}
+ {{ user.followers.count() }} followers, {{ user.followed.count() }} following.
{% if user == current_user %}
Edit your profile
+ {% elif not current_user.is_following(user) %}
+
+
+
+ {% else %}
+
+
+
{% endif %}
diff --git a/migrations/versions/ae346256b650_followers.py b/migrations/versions/ae346256b650_followers.py
new file mode 100644
index 0000000..31db09e
--- /dev/null
+++ b/migrations/versions/ae346256b650_followers.py
@@ -0,0 +1,33 @@
+"""followers
+
+Revision ID: ae346256b650
+Revises: 37f06a334dbf
+Create Date: 2017-09-17 15:41:30.211082
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'ae346256b650'
+down_revision = '37f06a334dbf'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('followers',
+ sa.Column('follower_id', sa.Integer(), nullable=True),
+ sa.Column('followed_id', sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(['followed_id'], ['user.id'], ),
+ sa.ForeignKeyConstraint(['follower_id'], ['user.id'], )
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('followers')
+ # ### end Alembic commands ###
diff --git a/tests.py b/tests.py
new file mode 100755
index 0000000..ea99d5a
--- /dev/null
+++ b/tests.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python
+import os
+os.environ['DATABASE_URL'] = 'sqlite://'
+
+from datetime import datetime, timedelta
+import unittest
+from app import app, db
+from app.models import User, Post
+
+
+class UserModelCase(unittest.TestCase):
+ def setUp(self):
+ self.app_context = app.app_context()
+ self.app_context.push()
+ db.create_all()
+
+ def tearDown(self):
+ db.session.remove()
+ db.drop_all()
+ self.app_context.pop()
+
+ def test_password_hashing(self):
+ u = User(username='susan')
+ u.set_password('cat')
+ self.assertFalse(u.check_password('dog'))
+ self.assertTrue(u.check_password('cat'))
+
+ def test_avatar(self):
+ u = User(username='john', email='john@example.com')
+ self.assertEqual(u.avatar(128), ('https://www.gravatar.com/avatar/'
+ 'd4c74594d841139328695756648b6bd6'
+ '?d=identicon&s=128'))
+
+ def test_follow(self):
+ u1 = User(username='john', email='john@example.com')
+ u2 = User(username='susan', email='susan@example.com')
+ db.session.add(u1)
+ db.session.add(u2)
+ db.session.commit()
+ self.assertEqual(u1.followed.all(), [])
+ self.assertEqual(u1.followers.all(), [])
+
+ u1.follow(u2)
+ db.session.commit()
+ self.assertTrue(u1.is_following(u2))
+ self.assertEqual(u1.followed.count(), 1)
+ self.assertEqual(u1.followed.first().username, 'susan')
+ self.assertEqual(u2.followers.count(), 1)
+ self.assertEqual(u2.followers.first().username, 'john')
+
+ u1.unfollow(u2)
+ db.session.commit()
+ self.assertFalse(u1.is_following(u2))
+ self.assertEqual(u1.followed.count(), 0)
+ self.assertEqual(u2.followers.count(), 0)
+
+ def test_follow_posts(self):
+ # create four users
+ u1 = User(username='john', email='john@example.com')
+ u2 = User(username='susan', email='susan@example.com')
+ u3 = User(username='mary', email='mary@example.com')
+ u4 = User(username='david', email='david@example.com')
+ db.session.add_all([u1, u2, u3, u4])
+
+ # create four posts
+ now = datetime.utcnow()
+ p1 = Post(body="post from john", author=u1,
+ timestamp=now + timedelta(seconds=1))
+ p2 = Post(body="post from susan", author=u2,
+ timestamp=now + timedelta(seconds=4))
+ p3 = Post(body="post from mary", author=u3,
+ timestamp=now + timedelta(seconds=3))
+ p4 = Post(body="post from david", author=u4,
+ timestamp=now + timedelta(seconds=2))
+ db.session.add_all([p1, p2, p3, p4])
+ db.session.commit()
+
+ # setup the followers
+ u1.follow(u2) # john follows susan
+ u1.follow(u4) # john follows david
+ u2.follow(u3) # susan follows mary
+ u3.follow(u4) # mary follows david
+ db.session.commit()
+
+ # check the followed posts of each user
+ f1 = u1.followed_posts().all()
+ f2 = u2.followed_posts().all()
+ f3 = u3.followed_posts().all()
+ f4 = u4.followed_posts().all()
+ self.assertEqual(f1, [p2, p4, p1])
+ self.assertEqual(f2, [p2, p3])
+ self.assertEqual(f3, [p3, p4])
+ self.assertEqual(f4, [p4])
+
+
+if __name__ == '__main__':
+ unittest.main(verbosity=2)