Skip to content

Commit 606e01e

Browse files
committed
chapter 08 - followers
1 parent f912da6 commit 606e01e

13 files changed

+1019
-26
lines changed

app.db

0 Bytes
Binary file not shown.

app/forms.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,7 @@ def validate_username(self, username):
4343
if username.data != self.original_username:
4444
user = User.query.filter_by(username=username.data).first()
4545
if user is not None:
46-
raise ValidationError('That username is taken. You will have to try a different one.')
46+
raise ValidationError('That username is taken. You will have to try a different one.')
47+
48+
class EmptyForm(FlaskForm):
49+
submit = SubmitField('Submit')

app/models.py

+86-21
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,110 @@
1-
from datetime import datetime
1+
from datetime import datetime, timezone
2+
from hashlib import md5
23
from typing import Optional
34
import sqlalchemy as sa
45
import sqlalchemy.orm as so
5-
from werkzeug.security import generate_password_hash, check_password_hash
66
from flask_login import UserMixin
7+
from werkzeug.security import generate_password_hash, check_password_hash
78
from app import db, login
8-
from hashlib import md5
9+
10+
11+
followers = sa.Table(
12+
'followers',
13+
db.metadata,
14+
sa.Column('follower_id', sa.Integer, sa.ForeignKey('user.id'),
15+
primary_key=True),
16+
sa.Column('followed_id', sa.Integer, sa.ForeignKey('user.id'),
17+
primary_key=True)
18+
)
919

1020

1121
class User(UserMixin, db.Model):
12-
id: so.Mapped[int] =so.mapped_column(primary_key=True)
13-
username: so.Mapped[str] = so.mapped_column(sa.String(64), index=True, unique=True)
14-
email = db.Column(db.String(120), index=True, unique=True)
15-
password_hash = db.Column(db.String(128))
16-
posts = db.relationship('Post', backref='author', lazy='dynamic')
17-
about_me = db.Column(db.String(140))
18-
last_seen = db.Column(db.DateTime, default=datetime.utcnow)
19-
22+
id: so.Mapped[int] = so.mapped_column(primary_key=True)
23+
username: so.Mapped[str] = so.mapped_column(sa.String(64), index=True,
24+
unique=True)
25+
email: so.Mapped[str] = so.mapped_column(sa.String(120), index=True,
26+
unique=True)
27+
password_hash: so.Mapped[Optional[str]] = so.mapped_column(sa.String(256))
28+
about_me: so.Mapped[Optional[str]] = so.mapped_column(sa.String(140))
29+
last_seen: so.Mapped[Optional[datetime]] = so.mapped_column(
30+
default=lambda: datetime.now(timezone.utc))
31+
32+
posts: so.WriteOnlyMapped['Post'] = so.relationship(
33+
back_populates='author')
34+
following: so.WriteOnlyMapped['User'] = so.relationship(
35+
secondary=followers, primaryjoin=(followers.c.follower_id == id),
36+
secondaryjoin=(followers.c.followed_id == id),
37+
back_populates='followers')
38+
followers: so.WriteOnlyMapped['User'] = so.relationship(
39+
secondary=followers, primaryjoin=(followers.c.followed_id == id),
40+
secondaryjoin=(followers.c.follower_id == id),
41+
back_populates='following')
42+
2043
def __repr__(self):
2144
return '<User {}>'.format(self.username)
22-
45+
2346
def set_password(self, password):
2447
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256', salt_length=16)
25-
48+
2649
def check_password(self, password):
2750
return check_password_hash(self.password_hash, password)
2851

2952
def avatar(self, size):
3053
digest = md5(self.email.lower().encode('utf-8')).hexdigest()
31-
return 'https://www.gravatar.com/avatar/{}?d=robohash&s={}'.format(digest, size)
54+
return f'https://www.gravatar.com/avatar/{digest}?d=robohash&s={size}'
55+
56+
def follow(self, user):
57+
if not self.is_following(user):
58+
self.following.add(user)
59+
60+
def unfollow(self, user):
61+
if self.is_following(user):
62+
self.following.remove(user)
63+
64+
def is_following(self, user):
65+
query = self.following.select().where(User.id == user.id)
66+
return db.session.scalar(query) is not None
67+
68+
def followers_count(self):
69+
query = sa.select(sa.func.count()).select_from(
70+
self.followers.select().subquery())
71+
return db.session.scalar(query)
72+
73+
def following_count(self):
74+
query = sa.select(sa.func.count()).select_from(
75+
self.following.select().subquery())
76+
return db.session.scalar(query)
77+
78+
def following_posts(self):
79+
Author = so.aliased(User)
80+
Follower = so.aliased(User)
81+
return (
82+
sa.select(Post)
83+
.join(Post.author.of_type(Author))
84+
.join(Author.followers.of_type(Follower), isouter=True)
85+
.where(sa.or_(
86+
Follower.id == self.id,
87+
Author.id == self.id,
88+
))
89+
.group_by(Post)
90+
.order_by(Post.timestamp.desc())
91+
)
92+
3293

33-
3494
@login.user_loader
3595
def load_user(id):
36-
return User.query.get(int(id))
96+
return db.session.get(User, int(id))
97+
3798

3899
class Post(db.Model):
39-
id = db.Column(db.Integer, primary_key=True)
40-
body = db.Column(db.String(140))
41-
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
42-
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
43-
100+
id: so.Mapped[int] = so.mapped_column(primary_key=True)
101+
body: so.Mapped[str] = so.mapped_column(sa.String(140))
102+
timestamp: so.Mapped[datetime] = so.mapped_column(
103+
index=True, default=lambda: datetime.now(timezone.utc))
104+
user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id),
105+
index=True)
106+
107+
author: so.Mapped[User] = so.relationship(back_populates='posts')
108+
44109
def __repr__(self):
45110
return '<Post {}>'.format(self.body)

app/routes.py

+46-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from datetime import datetime
22
from app import app, db
3+
import sqlalchemy as sa
34
from flask import render_template, flash, redirect, url_for, request
45
from urllib.parse import urlsplit
56
from flask_login import login_user, current_user, logout_user, login_required
6-
from app.forms import LoginForm, RegistrationForm, EditProfileForm
7+
from app.forms import LoginForm, RegistrationForm, EditProfileForm, EmptyForm
78
from app.models import User
89

910
@app.before_request
@@ -74,7 +75,8 @@ def user(username):
7475
{'author': user, 'body': 'Test post #1'},
7576
{'author': user, 'body': 'Test post #2'}
7677
]
77-
return render_template('user.html', user=user, posts=posts)
78+
form = EmptyForm()
79+
return render_template('user.html', user=user, posts=posts, form=form)
7880

7981
@app.route('/edit_profile', methods=['GET', 'POST'])
8082
@login_required
@@ -89,4 +91,45 @@ def edit_profile():
8991
elif request.method == 'GET':
9092
form.username.data = current_user.username
9193
form.about_me.data = current_user.about_me
92-
return render_template('edit_profile.html', title='Edit Profile', form=form)
94+
return render_template('edit_profile.html', title='Edit Profile', form=form)
95+
96+
@app.route('/follow/<username>', methods=['POST'])
97+
@login_required
98+
def follow(username):
99+
form = EmptyForm()
100+
if form.validate_on_submit():
101+
user = db.session.scalar(
102+
sa.select(User).where(User.username == username))
103+
if user is None:
104+
flash(f'User {username} not found.')
105+
return redirect(url_for('index'))
106+
if user == current_user:
107+
flash('You cannot follow yourself!')
108+
return redirect(url_for('user', username=username))
109+
current_user.follow(user)
110+
db.session.commit()
111+
flash(f'You are following {username}!')
112+
return redirect(url_for('user', username=username))
113+
else:
114+
return redirect(url_for('index'))
115+
116+
117+
@app.route('/unfollow/<username>', methods=['POST'])
118+
@login_required
119+
def unfollow(username):
120+
form = EmptyForm()
121+
if form.validate_on_submit():
122+
user = db.session.scalar(
123+
sa.select(User).where(User.username == username))
124+
if user is None:
125+
flash(f'User {username} not found.')
126+
return redirect(url_for('index'))
127+
if user == current_user:
128+
flash('You cannot unfollow yourself!')
129+
return redirect(url_for('user', username=username))
130+
current_user.unfollow(user)
131+
db.session.commit()
132+
flash(f'You are not following {username}.')
133+
return redirect(url_for('user', username=username))
134+
else:
135+
return redirect(url_for('index'))

app/templates/user.html

+9-1
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,16 @@
88
<h1>Woodwinder: {{ user.username }}</h1>
99
{% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
1010
{% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %}
11+
<p>{{ user.followers_count() }} followers, {{ user.following_count() }} following.</p>
1112
{% if user == current_user %}
12-
<p><a href="{{ url_for('edit_profile') }}">Edit your profile</a></p>
13+
<p><a href="{{ url_for('edit_profile') }}">Edit your profile</a></p>
14+
{% elif not current_user.is_following(user) %}
15+
<p>
16+
<form action="{{ url_for('follow', username=user.username) }}" method="post">
17+
{{ form.hidden_tag() }}
18+
{{ form.submit(value='Follow') }}
19+
</form>
20+
</p>
1321
{% endif %}
1422
</td>
1523
</tr>

logs/woodwind.log.1

+127
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
2023-12-11 12:20:02,579 ERROR: Exception on /user/etta [GET] [in /Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py:825]
2+
Traceback (most recent call last):
3+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 1455, in wsgi_app
4+
response = self.full_dispatch_request()
5+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 869, in full_dispatch_request
6+
rv = self.handle_user_exception(e)
7+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 867, in full_dispatch_request
8+
rv = self.dispatch_request()
9+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 852, in dispatch_request
10+
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
11+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask_login/utils.py", line 290, in decorated_view
12+
return current_app.ensure_sync(func)(*args, **kwargs)
13+
File "/Users/dandam/dev/woodwind/app/routes.py", line 77, in user
14+
return render_template('user.html', user=user, posts=posts)
15+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/templating.py", line 152, in render_template
16+
return _render(app, template, context)
17+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/templating.py", line 133, in _render
18+
rv = template.render(context)
19+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/jinja2/environment.py", line 1301, in render
20+
self.environment.handle_exception()
21+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/jinja2/environment.py", line 936, in handle_exception
22+
raise rewrite_traceback_stack(source=source)
23+
File "/Users/dandam/dev/woodwind/app/templates/user.html", line 1, in top-level template code
24+
{% extends "base.html" %}
25+
File "/Users/dandam/dev/woodwind/app/templates/base.html", line 30, in top-level template code
26+
{% block content %}{% endblock %}
27+
File "/Users/dandam/dev/woodwind/app/templates/user.html", line 17, in block 'content'
28+
{{ form.hidden_tag() }}
29+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/jinja2/environment.py", line 485, in getattr
30+
return getattr(obj, attribute)
31+
jinja2.exceptions.UndefinedError: 'form' is undefined
32+
2023-12-11 12:20:02,788 ERROR: Exception on /user/etta [GET] [in /Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py:825]
33+
Traceback (most recent call last):
34+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 1455, in wsgi_app
35+
response = self.full_dispatch_request()
36+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 869, in full_dispatch_request
37+
rv = self.handle_user_exception(e)
38+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 867, in full_dispatch_request
39+
rv = self.dispatch_request()
40+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 852, in dispatch_request
41+
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
42+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask_login/utils.py", line 290, in decorated_view
43+
return current_app.ensure_sync(func)(*args, **kwargs)
44+
File "/Users/dandam/dev/woodwind/app/routes.py", line 77, in user
45+
return render_template('user.html', user=user, posts=posts)
46+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/templating.py", line 152, in render_template
47+
return _render(app, template, context)
48+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/templating.py", line 133, in _render
49+
rv = template.render(context)
50+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/jinja2/environment.py", line 1301, in render
51+
self.environment.handle_exception()
52+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/jinja2/environment.py", line 936, in handle_exception
53+
raise rewrite_traceback_stack(source=source)
54+
File "/Users/dandam/dev/woodwind/app/templates/user.html", line 1, in top-level template code
55+
{% extends "base.html" %}
56+
File "/Users/dandam/dev/woodwind/app/templates/base.html", line 30, in top-level template code
57+
{% block content %}{% endblock %}
58+
File "/Users/dandam/dev/woodwind/app/templates/user.html", line 17, in block 'content'
59+
{{ form.hidden_tag() }}
60+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/jinja2/environment.py", line 485, in getattr
61+
return getattr(obj, attribute)
62+
jinja2.exceptions.UndefinedError: 'form' is undefined
63+
2023-12-11 12:20:22,958 INFO: Woodwind startup [in /Users/dandam/dev/woodwind/app/__init__.py:42]
64+
2023-12-11 12:20:28,271 ERROR: Exception on /user/etta [GET] [in /Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py:825]
65+
Traceback (most recent call last):
66+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 1455, in wsgi_app
67+
response = self.full_dispatch_request()
68+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 869, in full_dispatch_request
69+
rv = self.handle_user_exception(e)
70+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 867, in full_dispatch_request
71+
rv = self.dispatch_request()
72+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 852, in dispatch_request
73+
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
74+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask_login/utils.py", line 290, in decorated_view
75+
return current_app.ensure_sync(func)(*args, **kwargs)
76+
File "/Users/dandam/dev/woodwind/app/routes.py", line 77, in user
77+
return render_template('user.html', user=user, posts=posts)
78+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/templating.py", line 152, in render_template
79+
return _render(app, template, context)
80+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/templating.py", line 133, in _render
81+
rv = template.render(context)
82+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/jinja2/environment.py", line 1301, in render
83+
self.environment.handle_exception()
84+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/jinja2/environment.py", line 936, in handle_exception
85+
raise rewrite_traceback_stack(source=source)
86+
File "/Users/dandam/dev/woodwind/app/templates/user.html", line 1, in top-level template code
87+
{% extends "base.html" %}
88+
File "/Users/dandam/dev/woodwind/app/templates/base.html", line 30, in top-level template code
89+
{% block content %}{% endblock %}
90+
File "/Users/dandam/dev/woodwind/app/templates/user.html", line 17, in block 'content'
91+
{{ form.hidden_tag() }}
92+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/jinja2/environment.py", line 485, in getattr
93+
return getattr(obj, attribute)
94+
jinja2.exceptions.UndefinedError: 'form' is undefined
95+
2023-12-11 12:21:14,996 INFO: Woodwind startup [in /Users/dandam/dev/woodwind/app/__init__.py:42]
96+
2023-12-11 12:22:22,941 INFO: Woodwind startup [in /Users/dandam/dev/woodwind/app/__init__.py:42]
97+
2023-12-11 12:22:25,818 ERROR: Exception on /user/etta [GET] [in /Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py:825]
98+
Traceback (most recent call last):
99+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 1455, in wsgi_app
100+
response = self.full_dispatch_request()
101+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 869, in full_dispatch_request
102+
rv = self.handle_user_exception(e)
103+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 867, in full_dispatch_request
104+
rv = self.dispatch_request()
105+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/app.py", line 852, in dispatch_request
106+
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
107+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask_login/utils.py", line 290, in decorated_view
108+
return current_app.ensure_sync(func)(*args, **kwargs)
109+
File "/Users/dandam/dev/woodwind/app/routes.py", line 77, in user
110+
return render_template('user.html', user=user, posts=posts)
111+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/templating.py", line 152, in render_template
112+
return _render(app, template, context)
113+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/flask/templating.py", line 133, in _render
114+
rv = template.render(context)
115+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/jinja2/environment.py", line 1301, in render
116+
self.environment.handle_exception()
117+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/jinja2/environment.py", line 936, in handle_exception
118+
raise rewrite_traceback_stack(source=source)
119+
File "/Users/dandam/dev/woodwind/app/templates/user.html", line 1, in top-level template code
120+
{% extends "base.html" %}
121+
File "/Users/dandam/dev/woodwind/app/templates/base.html", line 30, in top-level template code
122+
{% block content %}{% endblock %}
123+
File "/Users/dandam/dev/woodwind/app/templates/user.html", line 17, in block 'content'
124+
{{ form.hidden_tag() }}
125+
File "/Users/dandam/dev/woodwind/venv/lib/python3.9/site-packages/jinja2/environment.py", line 485, in getattr
126+
return getattr(obj, attribute)
127+
jinja2.exceptions.UndefinedError: 'form' is undefined

0 commit comments

Comments
 (0)