#Kom igång med followers - Microblog

By . Latest revision .

I denna artikeln ska vi jobba igenom kapitel 8 i Miguel’s guide, så att vi kan följa andra användare och se deras inlägg på sin egna feed.

Vi kommer främst att jobba med SQLAlchemy ORM i python ramverket flask och pylint för att skriva enhetstester till de nya uppdateringarna.

#Förutsättningar

Du har läst och kollat igenom introduktion till devops appen och fått igång applikationen.

#Steg 1. Databas

Just nu består databasen två tabeller, User som håller koll på alla användare och Post som hanterar inläggen.
Det vi skall göra nu är att utöka databasen så att användare kan följa varandra. För det skapar vi en en associeringstabell som skall ha två värden, första är användarens user_id och den andra är id’t på personen den första användaren vill följa.

Vi börjar med att skapa den nya modellen followers i filen app/models.py och lägger vår kod ovanför klassen User.

followers = db.Table('followers',
    db.Column('follower_id', db.Integer, db.ForeignKey('user.id')),
    db.Column('followed_id', db.Integer, db.ForeignKey('user.id'))
)
# ..

Anledningen varför vi inte gjorde followers tabellen till en klass som de andra är att den endast skall hålla två stycken foreign keys. Den kommer alltså inte hålla någon annan information eller funktionalitet. Istället skall vi koppla den till User -modellen så att vi enkelt kan hålla koll på vem användaren följer. Detta kan kan göras med hjälp av db.relationship():

class User(UserMixin, db.Model):
    # ...
    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')

Den första parametern 'User' är strängnamnet på den främmande tabellens klassnamn och eftersom vi skapar en relation från en User till en annan, refererar vi den till sig själv.
Nästa argument secondary sätts till associeringstabell variabelnamn så att den vet vart datan skall sparas. Medans primaryjoin och secondaryjoin berättar hur kopplingen mellan User modellen kopplas till followers och lika så hur followers kopplas till User.
Den sista parametern backref definierar hur vi skall hämta datan när User.followed kallas.

Eftersom vi nu skapade en ny tabell samnt lade till en extra kollumn i User måste nu uppdatera våran databasmigration:

╰─ (venv) $ flask db migrate -m "followers"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'followers'
  Generating /home/moc/git/kurser/microblog/migrations/versions/ab124256c621_followers.py ... done

╰─ (venv) $ flask db upgrade
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 37f06a334dbf -> ab124256c621, followers

Nu så skall vår nya databas version vara genererad och satt som den aktiva versionen.

#Steg 2. Lägga till och ta bort “follows”

Vi använder oss utav SQLAlchemy som en typ av ORM (objekt-relationell mappning). Denna modul gör det lätt att hantera relationer mellan tabeller. Den agerar som att relationerna vore listor, exempelvis, om user1 vill börja följa en ny användare (user2), kan man bara kalla på list.append() för att lägga till den nya användaren:

user1.followed.append(user2)

En “unfollow” kan hanteras på ett liknande sätt:

user1.followed.remove(user2)

Även om det är lätt att lägga till och ta bort följare skall vi lägga till tre nya metoder i User modellen. Detta är mest för att vi kan återanvända koden men det kommer också att underlätta testerna som vi skall göra lite senare:

class User(UserMixin, db.Model):
    #...
    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

follow() och unfollow() använder samma kod som vi gick igenom tidigare, skillnaden är att vi lägger till en extra koll is_following() för att undvika dubbletter om relationen redan existerar. Samma logik kan appliceras när man skall göra en “unfollow”.

#Steg 3. Hämta inlägg från använade man följer

Nu är vi nästan helt klara, det vi behöver lägga till är att appen även skall skriva ut alla inlägg från användaren man följer. Så vi lägger till yttligare en metod followed_posts som löser problemet.

class User(UserMixin, db.Model):
    #...
    def followed_posts(self):
        return Post.query.join(
            followers, (followers.c.followed_id == Post.user_id)).filter(
                followers.c.try == self.id).order_by(
                    Post.timestamp.desc())

Queryn som returneras fungerar fast den hämtar inte personens egna inlägg. Det enklaste sättet skulle vara att användarna följde sig själva men det är kommer inte att hålla i längden. Så istället skapar vi en till query own som hämtar ut sina egna inlägg och returnerar en union båda.

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

#Steg 4. Lägg till enhetstester

Även om vi inte har gjort några större ändringar i koden vill vi se så försäkra oss om att allting fungerar, både nu och i framtiden när saker ändras. Vi använder oss utav modulen pytest för att skriva våra tester som i senare kursmoment, skall köras automatiskt när vi pushar upp vår kod.

# pylint: disable=redefined-outer-name
from datetime import datetime, timedelta
from unittest import mock
import pytest
from app.models import User, Post
from app import db

...

def test_follow(test_app): # pylint: disable=unused-argument
    """
    Test that follow appends new Users to followed.
    Test that unfollow removes the User from followed.
    """
    user1 = User(username='john', email='john@example.com')
    user2 = User(username='susan', email='susan@example.com')
    db.session.add(user1)
    db.session.add(user2)
    db.session.commit()
    assert user1.followed.all() == []

    user1.follow(user2)
    db.session.commit()

    assert user1.is_following(user2) is True
    assert user1.followed.count() == 1
    assert user1.followed.first().username == "susan"
    assert user2.followers.count() == 1
    assert user2.followers.first().username == "john"

    user1.unfollow(user2)
    db.session.commit()
    assert user1.is_following(user2) is not True
    assert user1.followed.count() == 0
    assert user1.followers.count() == 0

def test_follow_posts(test_app): # pylint: disable=unused-argument
    """
    Test that all personal and posts from followed users are shown.
    """
    # create four users
    user1 = User(username='john', email='john@example.com')
    user2 = User(username='susan', email='susan@example.com')
    user3 = User(username='mary', email='mary@example.com')
    user4 = User(username='david', email='david@example.com')
    db.session.add_all([user1, user2, user3, user4])

    # create four posts
    now = datetime.utcnow()
    post1 = Post(body="post from john", author=user1,
                 timestamp=now + timedelta(seconds=1))
    post2 = Post(body="post from susan", author=user2,
                 timestamp=now + timedelta(seconds=4))
    post3 = Post(body="post from mary", author=user3,
                 timestamp=now + timedelta(seconds=3))
    post4 = Post(body="post from david", author=user4,
                 timestamp=now + timedelta(seconds=2))
    db.session.add_all([post1, post2, post3, post4])
    db.session.commit()

    # setup the followers
    user1.follow(user2)  # john follows susan
    user1.follow(user4)  # john follows david
    user2.follow(user3)  # susan follows mary
    user3.follow(user4)  # mary follows david
    db.session.commit()

    # check the followed posts of each user
    follow1 = user1.followed_posts().all()
    follow2 = user2.followed_posts().all()
    follow3 = user3.followed_posts().all()
    follow4 = user4.followed_posts().all()
    assert follow1 == [post2, post4, post1]
    assert follow2 == [post2, post3]
    assert follow3 == [post3, post4]
    assert follow4 == [post4]

Utöver att vi skapar nya användare, kollar så att de kan göra follows/unfollows ser vi också till att testa om inläggen renderas på det sätt vi tänkte oss när de användaren följer lägger till nya poster.

Vi kan nu köra make test-unit för att kontrollera om våra nya tester går igenom:

╰─ (venv) $ make test-unit
find . -name '*.pyc' -exec rm -f {} +
find . -name '*.pyo' -exec rm -f {} +
find . -name '__pycache__' -exec rm -fr {} +
find . -name '.pytest_cache' -exec rm -fr {} +
rm -f .coverage
rm -rf tests/coverage_html
find . -name '*~' -exec rm -f {} +
find . -name '*.log*' -exec rm -fr {} +
---> Running all tests in tests/unit
==================================================== test session starts ====================================================
platform darwin -- Python 3.7.3, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/moc/git/kurser/microblog/tests/unit, configfile: ../../pytest.ini
collected 12 items

tests/unit/auth/forms/test_registration_form.py ....                                                                  [ 33%]
tests/unit/main/forms/test_edit_profile_form.py ..                                                                    [ 50%]
tests/unit/models/test_post.py .                                                                                      [ 58%]
tests/unit/models/test_user.py .....                                                                                  [100%]

==================================================== 12 passed in 0.69s =====================================================
/Library/Developer/CommandLineTools/usr/bin/make clean-py
find . -name '*.pyc' -exec rm -f {} +
find . -name '*.pyo' -exec rm -f {} +
find . -name '__pycache__' -exec rm -fr {} +
find . -name '.pytest_cache' -exec rm -fr {} +

#Steg 5. Integrera följare med applikationen

Funktionaliteten för backenden är klar och går igenom testerna, det som nu behövs göras är att lägga till två nya routes i app/main/routes.py som hanterar följande av användare:

"""
Contains routes for main purpose of app
app/main/routes.py
"""
from datetime import datetime
from flask import render_template, flash, redirect, url_for, request, current_app
from flask_login import current_user, login_required
from app import db
from app.main.forms import EditProfileForm, PostForm
from app.models import User, Post
from app.main import bp


# ...
@bp.route('/follow/<username>')
@login_required
def follow(username):
    """
    Follow a User
    """
    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('main.user', username=username))

@bp.route('/unfollow/<username>')
@login_required
def unfollow(username):
    """
    Unfollow a User
    """
    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('main.user', username=username))

Flask och SQLAlchemy hanterar det mesta, current_user är den inloggade användaren och username användarnamnet skickas med i urlen.

Båda routerna har samma logik, först kollar den om användaren existerar i databasen och sedan kollar den så att man inte försöker lägga till eller sig själv från listan.

Går dessa kontroller igenom committar vi ändringen i databasen, sätter en bekräftelse att ändringen har skett och redirectar oss till /user/<username>.

Vi behöver också uppdatera två routes i samma fil.

@bp.route('/', methods=['GET', 'POST'])
@bp.route('/index', methods=['GET', 'POST'])
@login_required 
def index():
    """
    Route for index page
    """
    form = PostForm()
    if form.validate_on_submit():
        post = Post(body=form.post.data, author=current_user)
        current_app.logger.debug(f"{post}")
        db.session.add(post)
        db.session.commit()
        flash('Your post is now live!')
        return redirect(url_for('main.index'))

    posts = current_user.followed_posts().all()
    return render_template("index.html", title='Home Page', form=form,
                           posts=posts)

# ...
@bp.route('/user/<username>')
@login_required
def user(username):
    """
    Route for user
    """
    user_ = User.query.filter_by(username=username).first_or_404()
    posts = user_.posts.all()
    return render_template('user.html', user=user_, posts=posts)

I index routen ändrar vi vilken data som hämtas, i detta fallet vill vi ladda in allt från followed_posts(), så att alla inlägg från de vi följer plus ens egna, visas i flödet.
Sedan ändrar vi även vad som laddas in när vi besöker en användares sida, då vi endast vill ladda in hens inlägg.

Det sista som återstår är nu att visa upp en länk som användaren kan klicka på för att antingen editera sin profil, följa eller avfölja användaren man kollar på.

<!-- app/templates/user.html -->
{% extends "base.html" %}

{% block app_content %}
    <table>
        <tr valign="top">
            <td><img src="{{ user.avatar(128) }}"></td>
            <td>
                <h1>User: {{ user.username }}</h1>
                {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
                {% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %}
                <p>{{ user.followers.count() }} followers, {{ user.followed.count() }} following.</p>
                {% if user == current_user %}
                <p><a href="{{ url_for('main.edit_profile') }}">Edit your profile</a></p>
                {% elif not current_user.is_following(user) %}
                <p><a href="{{ url_for('main.follow', username=user.username) }}">Follow</a></p>
                {% else %}
                <p><a href="{{ url_for('main.unfollow', username=user.username) }}">Unfollow</a></p>
                {% endif %}
            </td>
        </tr>
    </table>
    <hr>
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
{% endblock %}

Nu är allt klart, prova att starta applikationen och kolla så att allt fungerar. Det finns inget sätt att lista alla användare just nu, för att se kunna se en användare behöver du då gå in på deras url. E.g om vi skall titta på svens profil http://localhost:5000/user/sven.

#Sammanfattningsvis

I devops jobbar vi med att utveckla, testa, automatisera och driftsätta projekt. Även om vi bara har gjort lite av de två första delarna kommer ni snart att få smak på hur hela processen kan gå till.

Ni behöver inte kunna allt som står i artikeln men, håll i alla fall lite koll på hur appen fungerar. Kolla gärna igenom introduktionen till devops appen en gång till om något är oklart.

Hojta till i Discord om ni kör fast eller har andra funderingar. Annars är det bara att och kör på, lycka till!

#Revision history

  • 2021-10-29: (b, aar) Byte .format() till f-strings.
  • 2020-09-29: (A, moc) Skapad inför HT2020.

Document source.

Category: devops, flask, python, sql.