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) %} +

+

+ {{ form.hidden_tag() }} + {{ form.submit(value='Follow') }} +
+

+ {% else %} +

+

+ {{ form.hidden_tag() }} + {{ form.submit(value='Unfollow') }} +
+

{% 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)