Chapter 8: Followers (v0.8)

This commit is contained in:
Miguel Grinberg 2017-09-16 00:05:17 -07:00
parent 9ab5c4a3b3
commit 4314a44fba
No known key found for this signature in database
GPG Key ID: 36848B262DF5F06C
6 changed files with 229 additions and 2 deletions

View File

@ -46,3 +46,7 @@ class EditProfileForm(FlaskForm):
user = User.query.filter_by(username=self.username.data).first() user = User.query.filter_by(username=self.username.data).first()
if user is not None: if user is not None:
raise ValidationError('Please use a different username.') raise ValidationError('Please use a different username.')
class EmptyForm(FlaskForm):
submit = SubmitField('Submit')

View File

@ -5,6 +5,15 @@ from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
followers = db.Table(
'followers',
db.Column('follower_id', db.Integer, db.ForeignKey('user.id'),
primary_key=True),
db.Column('followed_id', db.Integer, db.ForeignKey('user.id'),
primary_key=True)
)
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), index=True, unique=True) username = db.Column(db.String(64), index=True, unique=True)
@ -13,6 +22,16 @@ class User(UserMixin, db.Model):
posts = db.relationship('Post', back_populates='author', lazy='dynamic') posts = db.relationship('Post', back_populates='author', lazy='dynamic')
about_me = db.Column(db.String(140)) about_me = db.Column(db.String(140))
last_seen = db.Column(db.DateTime, default=datetime.utcnow) last_seen = db.Column(db.DateTime, default=datetime.utcnow)
following = db.relationship(
'User', secondary=followers,
primaryjoin=(followers.c.follower_id == id),
secondaryjoin=(followers.c.followed_id == id),
lazy='dynamic', back_populates='followers')
followers = db.relationship(
'User', secondary=followers,
primaryjoin=(followers.c.followed_id == id),
secondaryjoin=(followers.c.follower_id == id),
lazy='dynamic', back_populates='following')
def __repr__(self): def __repr__(self):
return '<User {}>'.format(self.username) return '<User {}>'.format(self.username)
@ -27,6 +46,24 @@ class User(UserMixin, db.Model):
digest = md5(self.email.lower().encode('utf-8')).hexdigest() digest = md5(self.email.lower().encode('utf-8')).hexdigest()
return f'https://www.gravatar.com/avatar/{digest}?d=identicon&s={size}' return f'https://www.gravatar.com/avatar/{digest}?d=identicon&s={size}'
def follow(self, user):
if not self.is_following(user):
self.following.append(user)
def unfollow(self, user):
if self.is_following(user):
self.following.remove(user)
def is_following(self, user):
return user in self.following
def following_posts(self):
following = 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 following.union(own).order_by(Post.timestamp.desc())
@login.user_loader @login.user_loader
def load_user(id): def load_user(id):

View File

@ -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 flask_login import login_user, logout_user, current_user, login_required
from werkzeug.urls import url_parse from werkzeug.urls import url_parse
from app import app, db 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 from app.models import User
@ -78,7 +78,8 @@ def user(username):
{'author': user, 'body': 'Test post #1'}, {'author': user, 'body': 'Test post #1'},
{'author': user, 'body': 'Test post #2'} {'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']) @app.route('/edit_profile', methods=['GET', 'POST'])
@ -96,3 +97,43 @@ def edit_profile():
form.about_me.data = current_user.about_me form.about_me.data = current_user.about_me
return render_template('edit_profile.html', title='Edit Profile', return render_template('edit_profile.html', title='Edit Profile',
form=form) form=form)
@app.route('/follow/<username>', 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(f'User {username} not found.')
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(f'You are following {username}!')
return redirect(url_for('user', username=username))
else:
return redirect(url_for('index'))
@app.route('/unfollow/<username>', 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(f'User {username} not found.')
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(f'You are not following {username}.')
return redirect(url_for('user', username=username))
else:
return redirect(url_for('index'))

View File

@ -8,8 +8,23 @@
<h1>User: {{ user.username }}</h1> <h1>User: {{ user.username }}</h1>
{% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %} {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
{% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %} {% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %}
<p>{{ user.followers.count() }} followers, {{ user.following.count() }} following.</p>
{% if user == current_user %} {% if user == current_user %}
<p><a href="{{ url_for('edit_profile') }}">Edit your profile</a></p> <p><a href="{{ url_for('edit_profile') }}">Edit your profile</a></p>
{% elif not current_user.is_following(user) %}
<p>
<form action="{{ url_for('follow', username=user.username) }}" method="post">
{{ form.hidden_tag() }}
{{ form.submit(value='Follow') }}
</form>
</p>
{% else %}
<p>
<form action="{{ url_for('unfollow', username=user.username) }}" method="post">
{{ form.hidden_tag() }}
{{ form.submit(value='Unfollow') }}
</form>
</p>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -0,0 +1,34 @@
"""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=False),
sa.Column('followed_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['followed_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['follower_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('follower_id', 'followed_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('followers')
# ### end Alembic commands ###

96
tests.py Executable file
View File

@ -0,0 +1,96 @@
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.following.all(), [])
self.assertEqual(u1.followers.all(), [])
u1.follow(u2)
db.session.commit()
self.assertTrue(u1.is_following(u2))
self.assertEqual(u1.following.count(), 1)
self.assertEqual(u1.following.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.following.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 following posts of each user
f1 = u1.following_posts().all()
f2 = u2.following_posts().all()
f3 = u3.following_posts().all()
f4 = u4.following_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)