Python + Flask を使ったWebアプリ作成⑥(ブログ投稿作成・更新・削除編)

下記記事の続きで、Python Flask を使ったブログサービスの実装を進めていきます。





今回はブログ投稿の作成、更新、削除機能を追加します。


*環境

  • MacOS
  • Python 3.6.3
  • Flask 1.0.2


*参考



*ブログ投稿機能を追加

ナビゲーションバーにブログ投稿画面へ遷移するためのリンクとして「New Post」を追加します。
<a class="nav-item nav-link" href="{{ url_for('new_post') }}">New Post</a>の1行を Navbar のタグに追加します。
<template/layout.html>
<!DOCTYPE html>  
<html lang="en">  
<head>  
    <!-- Required meta tags -->  
  <meta charset="utf-8">  
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">  
  
    <!-- Bootstrap CSS -->  
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css" integrity="sha384-GJzZqFGwb1QTTN6wy59ffF1BuGJpLSa9DkKMp0DgiMDm4iYMj70gZWKYbI706tWS" crossorigin="anonymous">  
    <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='main.css') }}">  
  
    <meta charset="UTF-8">  
    {% if title %}  
    <title>Flask Blog - {{ title }}</title>  
    {% else %}  
    <title>Flask Blog</title>  
    {% endif %}  
</head>  
<body>  
    <header class="site-header">  
        <nav class="navbar navbar-expand-md navbar-dark bg-steel fixed-top">  
            <div class="container">  
                <a class="navbar-brand mr-4" href="/">Flask Blog</a>  
                <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarToggle" aria-controls="navbarToggle" aria-expanded="false" aria-label="Toggle navigation">  
                    <span class="navbar-toggler-icon"></span>  
                </button>  
                <div class="collapse navbar-collapse" id="navbarToggle">  
                    <div class="navbar-nav mr-auto">  
                        <a class="nav-item nav-link" href="{{ url_for('home') }}">Home</a>  
                        <a class="nav-item nav-link" href="{{ url_for('about') }}">About</a>  
                    </div>  
                    <!-- Navbar Right Side -->  
  <div class="navbar-nav">  
                        {% if current_user.is_authenticated %}  
                            <!-- 下記の1行を追加 -->
                            <a class="nav-item nav-link" href="{{ url_for('new_post') }}">New Post</a>  
                            <a class="nav-item nav-link" href="{{ url_for('account') }}">Account</a>  
                            <a class="nav-item nav-link" href="{{ url_for('logout') }}">Logout</a>  
                        {% else %}  
                            <a class="nav-item nav-link" href="{{ url_for('login') }}">Login</a>  
                            <a class="nav-item nav-link" href="{{ url_for('register') }}">Register</a>  
                        {% endif %}  
                    </div>  
                </div>  
            </div>  
        </nav>  
    </header>  
    <main role="main" class="container">  
        <div class="row">  
            <div class="col-md-8">  
                {% with messages = get_flashed_messages(with_categories=true) %}  
                  {% if messages %}  
                    {% for category, message in messages %}  
                      <div class="alert alert-{{ category }}">  
                          {{ message }}  
                      </div>  
                    {% endfor %}  
                  {% endif %}  
                {% endwith %}  
                {% block content %}{% endblock %}  
            </div>  
            <div class="col-md-4">  
                <div class="content-section">  
                    <h3>Our Sidebar</h3>  
                    <p class='text-muted'>You can put any information here you'd like.  
                    <ul class="list-group">  
                        <li class="list-group-item list-group-item-light">Latest Posts</li>  
                        <li class="list-group-item list-group-item-light">Announcements</li>  
                        <li class="list-group-item list-group-item-light">Calendars</li>  
                        <li class="list-group-item list-group-item-light">etc</li>  
                    </ul>  
                    </p>  
                </div>  
            </div>  
        </div>  
    </main>  
    <!-- Optional JavaScript -->  
 <!-- jQuery first, then Popper.js, then Bootstrap JS -->  <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>  
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.6/umd/popper.min.js" integrity="sha384-wHAiFfRlMFy6i5SRaxvfOCifBUQy1xHdJ/yoi7FRNXMRBu5WHdZYu1hA6ZOblgut" crossorigin="anonymous"></script>  
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js" integrity="sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k" crossorigin="anonymous"></script>  
</body>  
</html>

今まで投稿内容をスタブにして固定データを表示するようにしていましたが、投稿した内容をDBに保存して表示できるように修正します。
投稿内容を保存するための Form を追加します。
<forms.py>
from flask_wtf import FlaskForm  
from flask_wtf.file import FileField, FileAllowed  
from flask_login import current_user  
from wtforms import StringField, PasswordField, SubmitField, BooleanField, TextAreaField  
from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError  
from flaskblog.models import User  
  
  
class RegistrationForm(FlaskForm):  
    username = StringField('Username',  
                           validators=[DataRequired(), Length(min=2, max=20)])  
    email = StringField('Email',  
                        validators=[DataRequired(), Email()])  
    password = PasswordField('Password', validators=[DataRequired()])  
    confirm_password = PasswordField('Confirm Password',  
                                     validators=[DataRequired(), EqualTo('password')])  
    submit = SubmitField('Sign Up')  
  
    def validate_username(self, username):  
        user = User.query.filter_by(username=username.data).first()  
        if user:  
            raise ValidationError('That username is taken. Please choose a different one.')  
  
    def validate_email(self, email):  
        email = User.query.filter_by(email=email.data).first()  
        if email:  
            raise ValidationError('That email is taken. Please choose a different one.')  
  
  
class LoginForm(FlaskForm):  
    email = StringField('Email',  
                        validators=[DataRequired(), Email()])  
    password = PasswordField('Password', validators=[DataRequired()])  
    remember = BooleanField('Remember me')  
    submit = SubmitField('Login')  
  
  
class UpdateAccountForm(FlaskForm):  
    username = StringField('Username',  
                           validators=[DataRequired(), Length(min=2, max=20)])  
    email = StringField('Email',  
                        validators=[DataRequired(), Email()])  
    picture = FileField('Update Profile Picture', validators=[FileAllowed(['jpg', 'png'])])  
    submit = SubmitField('Update')  
  
    def validate_username(self, username):  
        if username.data != current_user.username:  
            user = User.query.filter_by(username=username.data).first()  
            if user:  
                raise ValidationError('That username is taken. Please choose a different one.')  
  
    def validate_email(self, email):  
        if email.data != current_user.email:  
            email = User.query.filter_by(email=email.data).first()  
            if email:  
                raise ValidationError('That email is taken. Please choose a different one.')  
  
# ----- ↓追加 -----
class PostForm(FlaskForm):  
    title = StringField('Title', validators=[DataRequired()])  
    content = TextAreaField('Content', validators=[DataRequired()])  
    submit = SubmitField('Post')


ブログを投稿するAPIを追加します。
New Post のリンクをクリックしたときと、投稿内容を入力して保存するときに同一のAPIを呼び出し、入力があるかどうかで処理を分岐させています。
<routes.py>
import os  
import secrets  
from PIL import Image  
from flask import render_template, url_for, flash, redirect, request
from flaskblog import app, db, bcrypt  
from flaskblog.forms import RegistrationForm, LoginForm, UpdateAccountForm, PostForm   <-- 追加
from flaskblog.models import User, Post  
from flask_login import login_user, current_user, logout_user, login_required

...

@app.route('/post/new', methods=['GET', 'POST'])  
@login_required  
def new_post():  
    # 新規投稿ページから入力されたときDBに保存
    form = PostForm()  
    if form.validate_on_submit():  
        post = Post(title=form.title.data, content=form.content.data, author=current_user)  
        db.session.add(post)  
        db.session.commit()  
        flash('Your post has been created!', 'success')  
        return redirect(url_for('home'))  
    # New Postのリンクをクリックしたとき入力ページに遷移
    return render_template('create_post.html', title='New Post',  
                           form=form, legend='New Post')  


新規に投稿するためのページを作成します。
create_post.htmlを新規作成します。
<create_post.html>
{% extends "layout.html" %}  
{% block content %}  
<div class="content-section">  
    <form method="POST" action="">  
        {{ form.hidden_tag() }}  
        <fieldset class="form-group">  
            <legend class="border-bottom md-4">{{ legend }}</legend>  
            <div class="form-group">  
                {{ form.title.label(class="form-control-label") }}  
                {% if form.title.errors %}  
                {{ form.title(class="form-control form-control-lg is-invalid") }}  
                <div class="invalid-feedback">  
                    {% for error in form.title.errors %}  
                    <span>{{ error }}</span>  
                    {% endfor %}  
                </div>  
                {% else %}  
                {{ form.title(class="form-control form-control-lg") }}  
                {% endif %}  
            </div>  
            <div class="form-group">  
                {{ form.content.label(class="form-control-label") }}  
                {% if form.content.errors %}  
                {{ form.content(class="form-control form-control-lg is-invalid") }}  
                <div class="invalid-feedback">  
                    {% for error in form.content.errors %}  
                    <span>{{ error }}</span>  
                    {% endfor %}  
                </div>  
                {% else %}  
                {{ form.content(class="form-control form-control-lg") }}  
                {% endif %}  
            </div>  
        </fieldset>  
        <div class="form-group">  
            {{ form.submit(class="btn btn-outline-info") }}  
        </div>  
    </form>  
</div>  
{% endblock content %}

サーバーを起動します。
$ python run.py

ナビゲーションバーの「New Post」をクリックし、入力フォームに投稿したいタイトルと内容を入力して「Post」ボタンを押すと投稿することができます。
(投稿後はホーム画面に遷移し、登録成功した旨のメッセージが表示されます)


*投稿内容の表示

投稿したタイトルをクリックすると、内容詳細を表示できるようにします。
ホーム画面の記事のタイトルをリンク表示にし、クリックすると詳細画面に遷移するようにします。

HTMLファイルでタイトル表示に使われているhref="#"になっている部分を
href="{{ url_for('post', post_id=post.id) }}"に修正します。
<template/home.html>
{% extends "layout.html" %}  
{% block content %}  
    {% for post in posts %}  
    <article class="media content-section">  
        <div class="media-body">  
            <div class="article-metadata">  
                <a class="mr-2" href="#">{{ post.author }}</a>  
                <small class="text-muted">{{ post.date_posted }}</small>
            </div>  
            <!-- 修正 -->
            <h2><a class="article-title" href="{{ url_for('post', post_id=post.id) }}">{{ post.title }}</a></h2>  
            <p class="article-content">{{ post.content }}</p> 
        </div>  
    </article>  
{% endfor %}  
{% endblock content %}


指定した投稿情報を取得するAPIを追加します。
<route.py>
...

@app.route('/post/new', methods=['GET', 'POST'])  
@login_required  
def new_post():  
    form = PostForm()  
    if form.validate_on_submit():  
        post = Post(title=form.title.data, content=form.content.data, author=current_user)  
        db.session.add(post)  
        db.session.commit()  
        flash('Your post has been created!', 'success')  
        return redirect(url_for('home'))  
    return render_template('create_post.html', title='New Post',  
                           form=form, legend='New Post')  
  
# ---- ↓追加 ----
@app.route('/post/<int:post_id>')  
def post(post_id):  
    post = Post.query.get_or_404(post_id)  
    return render_template('post.html', title=post.title, post=post)

投稿情報を表示するためのページを新規作成します。
<template/post.html>
{% extends "layout.html" %}  
{% block content %}  
    <article class="media content-section">  
        <img class="rounded-circle article-img" src="{{ url_for('static', filename='profile_pics/' + post.author.image_file) }}">  
        <div class="media-body">  
            <div class="article-metadata">  
                <a class="mr-2" href="#">{{ post.author.username }}</a>  
                <small class="text-muted">{{ post.date_posted.strftime('%Y-%m-%d') }}</small>  
            </div>  
            <h2 class="article-title">{{ post.title }}</h2>  
            <p class="article-content">{{ post.content }}</p>  
        </div>  
    </article>  
{% endblock content %}

ここまでを確認します。
サーバーを再起動して、ホーム画面から投稿内容のタイトルをクリックすると投稿内容が表示されます。


*更新機能を追加

先ほどの投稿内容の確認画面から、内容を更新できるようにします。
画面にUpdate ボタンを追加します。
<template/post.html>
{% extends "layout.html" %}  
{% block content %}  
    <article class="media content-section">  
        <img class="rounded-circle article-img" src="{{ url_for('static', filename='profile_pics/' + post.author.image_file) }}">  
        <div class="media-body">  
            <div class="article-metadata">  
                <a class="mr-2" href="#">{{ post.author.username }}</a>  
                <small class="text-muted">{{ post.date_posted.strftime('%Y-%m-%d') }}</small>  
                <!-- ↓追加ここから -->
                {% if post.author == current_user %}  
                    <div>  
                        <a class="btn btn-secondary btn-sm mt-1 mb-1" href="{{ url_for('update_post', post_id=post.id) }}">Update</a>  
                    </div>  
                {% endif %}  
                <!-- ↑追加ここまで -->
            </div>  
            <h2 class="article-title">{{ post.title }}</h2>  
            <p class="article-content">{{ post.content }}</p>  
        </div>  
    </article>  
{% endblock content %}

更新するためのAPIを追加します。
更新画面から「Update」ボタンを押して更新する処理と、更新後のデータを取得する処理を同一のAPIで行うようにしています。
入力情報があるか、GETリクエストかで分岐させています。
また、投稿した本人でないと更新できないようにします。
画面で制御しますが、万が一本人でないユーザーだった場合は 403 エラーを返却するようにしています。
<route.py>
import os  
import secrets  
from PIL import Image  
from flask import render_template, url_for, flash, redirect, request, abort     <-- 追加
from flaskblog import app, db, bcrypt  
from flaskblog.forms import RegistrationForm, LoginForm, UpdateAccountForm, PostForm  
from flaskblog.models import User, Post  
from flask_login import login_user, current_user, logout_user, login_required

...

@app.route('/post/<int:post_id>')  
def post(post_id):  
    post = Post.query.get_or_404(post_id)  
    return render_template('post.html', title=post.title, post=post)  
  

# ----- 追加 ----- 
@app.route('/post/<int:post_id>/update', methods=['GET', 'POST'])  
@login_required  
def update_post(post_id):  
    post = Post.query.get_or_404(post_id)  
    if post.author != current_user:  
        abort(403)  
    form = PostForm()  
    if form.validate_on_submit():  
        post.title = form.title.data  
        post.content = form.content.data  
        db.session.commit()  
        flash('Your post has been update!', 'success')  
        return redirect(url_for('post', post_id=post.id))  
    elif request.method == 'GET':  
        form.title.data = post.title  
        form.content.data = post.content  
    return render_template('create_post.html', title='Update Post',  
                           form=form, legend='Update Post')



*削除機能を追加

削除するためのAPIを追加します。
<route.py>
...

@app.route('/post/<int:post_id>/delete', methods=['GET', 'POST'])  
@login_required  
def delete_post(post_id):  
    post = Post.query.get_or_404(post_id)  
    if post.author != current_user:  
        abort(403)  
    db.session.delete(post)  
    db.session.commit()  
    flash('Your post has been deleted!', 'success')  
    return redirect(url_for('home'))


画面にDelete ボタンを追加します。
削除は元に戻すことができないため、念のため削除の意思を再確認します。
再確認はモーダルウィンドウで行い、本当に削除する場合にdelete_post()のAPIを呼び出して削除処理を行います。

モーダルウィンドウの実装はBootstrapを使います。
Bootstrap 公式サイトからコピーして貼り付け、変数などを今回の実装に合わせて修正します。
<template/post.html>
{% extends "layout.html" %}  
{% block content %}  
    <article class="media content-section">  
        <img class="rounded-circle article-img" src="{{ url_for('static', filename='profile_pics/' + post.author.image_file) }}">  
        <div class="media-body">  
            <div class="article-metadata">  
                <a class="mr-2" href="#">{{ post.author.username }}</a>  
                <small class="text-muted">{{ post.date_posted.strftime('%Y-%m-%d') }}</small>  
                {% if post.author == current_user %}  
                    <div>  
                        <a class="btn btn-secondary btn-sm mt-1 mb-1" href="{{ url_for('update_post', post_id=post.id) }}">Update</a>  
                        <!-- ↓追加 -->
                        <button type="button" class="btn btn-danger btn-sm m-1" data-toggle="modal" data-target="#deleteModal">Delete</button>  
                    </div>  
                {% endif %}  
            </div>  
            <h2 class="article-title">{{ post.title }}</h2>  
            <p class="article-content">{{ post.content }}</p>  
        </div>  
    </article>  
    <!-- ↓追加ここから -->
    <div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel" aria-hidden="true">  
        <div class="modal-dialog" role="document">  
            <div class="modal-content">  
                <div class="modal-header">  
                    <h5 class="modal-title" id="deleteModalLabel">Delete Post?</h5>  
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">  
                        <span aria-hidden="true">&times;</span>  
                    </button>  
                </div>  
                <div class="modal-footer">  
                    <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>  
                    <form action="{{ url_for('delete_post', post_id=post.id) }}" method="POST">  
                        <input class="btn btn-danger" type="submit" value="Delete">  
                    </form>  
                </div>  
            </div>  
        </div>  
    </div>
    <!-- ↑追加ここまで -->
{% endblock content %}


*ホーム画面を修正

投稿されたブログに日付と作成アカウント名を表示させます。
<template/home.html>
{% extends "layout.html" %}  
{% block content %}  
    {% for post in posts %}  
    <article class="media content-section"> 
        <!-- アカウントの画像を追加 --> 
        <img class="rounded-circle article-img" src="{{ url_for('static', filename='profile_pics/' + post.author.image_file) }}">  
        <div class="media-body">  
            <div class="article-metadata">  
                <!-- ユーザー名と日付に修正 -->
                <a class="mr-2" href="#">{{ post.author.username }}</a>  
                <small class="text-muted">{{ post.date_posted.strftime('%Y-%m-%d') }}</small>  
            </div>  
            <h2><a class="article-title" href="{{ url_for('post', post_id=post.id) }}">{{ post.title }}</a></h2>  
            <p class="article-content">{{ post.content }}</p>  
        </div>  
    </article>  
{% endfor %}  
{% endblock content %}


*確認

サーバーを再起動してホーム画面を表示します。












ナビゲーションバーの「New Post」をクリックします。













Title と Content に適当な文字を入力します。














「Post」ボタンを押下します。












ホーム画面に遷移し、登録が成功した旨のメッセージと追加した投稿内容が表示されます。














投稿した内容のタイトルをクリックすると、詳細画面が表示されます。
投稿した本人であれば、「Update」と「Delete」ボタンが表示されます。













「Update」ボタンを押下すると、投稿した内容を修正することができます。













内容を修正して「Post」ボタンを押下します。














再度、詳細画面が表示され、更新が成功した旨のメッセージと更新内容が反映された内容が表示されます。













「Delete」ボタンを押下すると、確認ダイアログが表示されます。













確認ダイアログで「Delete」ボタンを押下すると、ホーム画面に遷移し、削除が成功した旨のメッセージが表示され、削除したデータが表示されなくなります。














*所感

更新や削除の基本的な実装方法を、改めて深く理解できました。
動画では似ている画面のHTMLファイルをコピーしたり実装を進めていたので、効率的な実装方法としても非常に参考になりました。


Previous
Next Post »

人気の投稿