はじめに
どうも、SNSはTwitterしか利用しない男、
プラチナ☆みゆきである。
長らくPythonでAPI芸人をやっていたが今回からは本腰を入れてWeb開発を行っていくつもりだ。
その記念すべき第一回として、PythonのWebフレームワークFlaskを使って簡単なSNSを作っていきたいと思う。
作成したアプリは以下のようになった。
作成したアプリはこちらから
コード全文はこちら
テストユーザとして「白金御行」と「三宮かぐや」を用意してるのでお暇は方は連絡して頂いても良いと思う。
また、本記事は長いので1日で実装しようとせず、数日かけて試されることを推奨したい。
利用技術
言語:Python 3
Webフレームワーク:Flask
フロントエンド(HTML/CSS):BootStrap 5
フロントエンド(JS):Ajax
DB:PostgreSQL
インフラ:heroku
実施手順
STEP1. Flask基本機能の確認
STEP2. ログイン機能の実装
STEP3. ユーザ情報編集機能の実装
STEP4. ユーザ検索機能の実装
STEP5. 友達申請、削除機能の実装
STEP6. メッセージ送信機能の実装
STEP7. Ajaxを用いた既読機能の実装
STEP8. herokuへのデプロイ
STEP1. Flask基本機能の確認
まず、Flaskを触ったことがない人はFlaskとは何?というところから学習する必要があるが、本稿はその段階の説明はややスコープ外としているので、いきなり実装から入ることに抵抗感を覚える人はこの記事をまず読むと以降の理解がスムーズになると思われる。
FlaskのインストールとWebページの表示
はじめるにあたって、Flaskの基本機能を確認しながら全体像を把握していこうと思う。
まずは、簡単にFlaskでWebページを表示するところまでを実施していく。
任意のパスに以下のようなフォルダを作成しよう。
(any path)
├ flaskr/
| ├ templates/
| └ index.html
|
| ├ __init__.py
| └ views.py
|
└ setup.py
まず、Webアプリの指揮系統となるviews.pyを以下のように記述していく。
コードを動かすにはpipからFlaskを読み込む必要があるので、読み込んでいない人はまず読み込もう。
pip install flask
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def home():
return render_template('index.html')
Flaskアプリを作成する時は、Flask(__name__)と記述し、これが今回のアプリそのものを表す。
その下には、Webアプリのルーティングを記述していく。
@から始まるデコレータを記述してから関数を定義していく。
@app.routeの()が表すのはアプリのパスであり、()内のパスをURLに打ち込むと定義した関数が実行されるようになる。
ここでは、単純にindex.htmlをレンダリングするだけの関数を定義している。
<h1>Hello, Flask !</h1>
次に、レンダリングするHTMLを記述する。
今回はHello, Flask !を返すだけの至極簡単なもので、これを正しくブラウザがレンダリングするかを確認していく。
また、HTMLファイルはflaskrディレクトリの直下ではなく、templatesディレクトリの直下であることにも注意が必要だ。
from flaskr.app import app
様式的に一応__init__.pyを作成しているが、app.pyからappインスタンスを読み込むだけのファイルとなっている。
from flaskr import app
if __name__ == '__main__':
app.run(debug=True)
最後にsetup.py。
こちらがターミナルかインタプリタかで直接読み込むファイルとなる。
if __name__ == '__main__'というのは、ターミナルかインタプリタかで直接読み込まれた時のみ実行するという構文である。
開発用なので、debug引数はTrueにしている。
それでは、setup.pyがあるパスから実際に起動してみよう。
$ python setup.py
お使いのブラウザで上図のように表示されていればOKだ!
Formの作成
さて、以上のものでは静的ページにも程があるし、ユーザから反応を受け取れるようにFormを実装してみよう。
flaskr/ディレクトリ配下にforms.pyを作成する。
ここでFormの作成を簡単にするライブラリとしてwtformsを先に読み込もう。
$ pip install wtforms
from wtforms.form import Form
from wtforms.fields import IntegerField, StringField, SubmitField
class LikeAnimeForm(Form):
name = StringField('名前:')
like_anime = StringField('好きなアニメは:')
rate = IntegerField('点数をつけるなら:')
wtformsを用いてクラスを作成する。これはHTMLのformタグを表していて、クラス変数はHTMLのinputタグに対応している。
次にviews.pyでWebサーバ側の処理を記述していく。
from flask import Flask, render_template, request
from flaskr.forms import LikeAnimeForm
app = Flask(__name__)
@app.route('/', methods=['GET', 'POST'])
def home():
form = LikeAnimeForm(request.form)
name = like_anime = rate = None
if request.method == 'POST':
name = form.name.data
like_anime = form.like_anime.data
rate = form.rate.data
return render_template('index.html', form=form, name=name, like_anime=like_anime, rate=rate)
return render_template('index.html', form=form, name=name, like_anime=like_anime, rate=rate)
上記のコードを説明すると
- @app.routeの引数にmethods=['GET', 'POST']を追加している。これはユーザからフォームを介して変数を取得するので、HTTPメソッドとしてPOSTを許可するという意味だ。
- フォームはform = LikeAnimeForm(request.form)という形で記述している。forms.pyからLikeAnimeFormとflaskライブラリからrequestをimportする必要がある。requestというのは、ユーザから送信された変数を取得するメソッドであり、フォームからの場合はrequest.formというプロパティを取得することになる。
- if以下の行でそれぞれフォームから取得した値を変数として宣言している。ここで作成した変数をrender_templateの引数でそれぞれ再びindex.htmlに返している。
最後に、index.htmlを修正していく。
<h1>好きなアニメ</h1>
<form method='POST'>
{{form.name.label}} {{form.name()}}
<br>
{{form.like_anime.label}} {{form.like_anime()}}
<br>
{{form.rate.label}} {{form.rate()}}
<br>
{{form.submit()}}
</form>
{% if name %}
<p>{{name}}さんの好きなアニメは{{like_anime}}で、点数で言うと{{rate}}点くらいらしい</p>
{% endif %}
HTMLの中に見慣れない記号、否、Pythonで使うような記号が出てきていることがわかる。
{{}}や{% if %}などがそれだ。
これはFlaskがラップしているJinjaというライブラリの記法であり、HTML内で変数を利用した場合は変数を{{}}で囲み{{変数}}という形にし、HTML内でifやfor文を使いたい場合は{% if %}のように記述し、if文の終端として{% endif %}を付け加える必要がある。
普段Pythonしか使わない人は、Pythonは特定の処理のブロックをインデントで表すので終端の意識がないかもしれないが、JavaやGoと同じように{}で処理のブロックを決めていると思って頂ければ良いと思う。
コードの中身だが、formタグの中でforms.pyのフォームを変数として再利用してフォームをレンダリングしている。
そして、views.pyから受け取った変数を{{name}}のように再利用し、HTMLに表示する形になっている。
さて、それではsetup.pyのあるディレクトリからsetup.pyを実行しよう。
1度目は以下のように表示されるはずだ。
そして、このフォームにてきとうに値を入力すると、次のレンダリングの際はHTTP POSTメソッドを受け取って、ユーザがフォームで送信した値を表示することが可能になっているはずである。
STEP2. ログイン機能の実装
それでは、Flaskの使い方を簡単に確認したところで、本稿の主題であるSNSアプリを作成していくこととしよう。
まず、ディレクトリ内を以下のように構成する。
(any path)
├ flaskr/
| ├ templates/
| ├ _helpers.html
| ├ base.html
| ├ home.html
| ├ login.html
| └ register.html
|
| ├ __init__.py
| ├ views.py
| ├ forms.py
| └ models.py
|
└ setup.py
FlaskはMVTモデルのWebアプリケーションフレームワークであるが、templates/のディレクトリがTemplateに対応していて、views.py, forms.pyがView、models.pyがModelに対応している。
MVTモデルであるが、簡単に説明すると以下のようになる。
# | 役割 |
---|---|
M | DBの設計、操作を行う |
V | アプリの様々な内部処理を行う |
T | ユーザが実際に見るUIの役割を担う |
さて、STEP2ではアプリにログインするユーザ情報を格納するDBとログインを処理を実装していきたいと思う。
MVTのどこから作成していくかは好みだと思うが、まずはmodels.pyから作成していこうと思う。
""" DBのtable設計とCRUDメソッド群 """
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin, current_user
from flaskr.views import app
from flask_bcrypt import generate_password_hash, check_password_hash
from datetime import datetime
import os
DB_URI = 'postgresql://postgres:PASSWORD@localhost/flask_sns'
app.config['SQLALCHEMY_DATABASE_URI'] = DB_URI
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config["SECRET_KEY"] = 'SECRET'
db = SQLAlchemy(app)
login_manager = LoginManager(app)
@login_manager.user_loader
def load_user(user_id):
""" LoginManagerをDBに対して動作させるためのメソッド """
return User.query.get(user_id)
class User(db.Model, UserMixin):
""" ログインセッションを管理するUserテーブル """
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(32), index=True)
email = db.Column(db.String(32), index=True, unique=True)
password = db.Column(db.Text)
comment = db.Column(db.Text, default='')
picture_path = db.Column(db.Text, nullable=True)
is_active = db.Column(db.Boolean, default=True) # login_managerで必要
create_at = db.Column(db.DateTime, default=datetime.now) # datetime.now()では変になる
update_at = db.Column(db.DateTime, default=datetime.now)
def __init__(self, username, email, password):
""" ユーザ名、メール、パスワードが入力必須 """
self.username = username
self.email = email
self.password = generate_password_hash(password).decode('utf-8')
def check_password(self, password):
""" パスワードをチェックしてTrue/Falseを返す """
return check_password_hash(self.password, password)
def reset_password(self, password):
""" 再設定されたパスワードをDBにアップデート """
self.password = generate_password_hash(password).decode('utf-8')
@classmethod
def select_by_email(cls, email):
""" UserテーブルからemailでSELECTされたインスタンスを返す """
return cls.query.filter_by(email=email).first()
- まず最初のimport群の中で後に必要なもの含めすべて読み込んでしまっている。flask_sqlalchemyというのはORマッパー(ORM)というものであり、pythonからDBを操作できるライブラリである。flask_loginというのはログイン処理用のライブラリである。flask_bcryptというのが暗号化用のライブラリであり、パスワードのハッシュ暗号化の際に使うものだ。
- 次にDB_URIの部分であるがPostgreSQLの情報を記述していく。PASSWORDとなっている部分はご自身のパスワードを設定いただき、最後の/flask_snsの部分はDBの名前であり、今回はflask_snsというものにしている。
- app.configの部分であるが['SECRET_KEY']の部分だけご自身のものであれば、任意の文字列で問題ない。
- db, login_managerのインスタンスを作成する。
- @login_manager.user_loaderの部分は、flask_loginからlogin_managerインスタンスを使う際のおまじないのようなものなのでこのまま記述頂ければ問題ない。
- そして、最後にclass Userの部分でDB内のTABLEを宣言している。sql_alchemyを用いてテーブルを作成する時に必ずdb.Modelを引数とする必要があり、また、Userテーブルはユーザのログイン状態を保持する必要があるので、第二引数にUserMixinも継承している。
- idから始まるクラス変数はテーブルのカラムを表している。この辺はデータベースを触ったことがある人は馴染みやすいだろう。
- 次に、__init__の部分でインスタンス変数を宣言している。パスワードを平文のままDBに格納するのはよろしくないので、generate_password_hash()を用いてハッシュ暗号化してDBに格納するようにしている。
- 最後に、ユーザが登録時に格納したパスワードと、ログイン時に入力したパスワードが一致するか確認するcheck_passwordメソッドと、パスワード再設定用のreset_passwordメソッドを定義して終わりとしている。そして、クラスメソッドとしてemailでフィルターをかけたSELECT文を実行する処理を記述している。
さて、models.pyを作成したら一度DBとTABLEを作成してみよう。postgresqlをインストールしてる人はpsqlコマンドが使えると思うのでまずは対象のDBを作成する。私の場合は以下だ。
$ psql -U postgres
postgres=# create database flask_sns;
次に、setup.pyのあるディレクトリからpythonを直接たたいてテーブルを作成する。
$ python
>>> from flaskr.models import db
>>> db.create_all()
>>> exit()
dbをインポートしたらcreate_all()ですべてのテーブルを作成することができる。最後にもう一度postgresqlをのぞいてテーブルが作成されていたらOKだ!
$ psql -U postgres -d flask_sns
flask_sns=# \dt
リレーション一覧
スキーマ | 名前 | タイプ | 所有者
----------+--------------+----------+----------
public | user | テーブル | postgres
それでは、models.pyが完成したのでviews.pyを作り込んでいこう。
from flask import (
Flask, render_template, request, redirect, url_for, flash,
)
from flask_login import login_required, login_user, logout_user, current_user
from datetime import datetime
app = Flask(__name__)
from flaskr.models import db, User
from flaskr.forms import (
LoginForm, RegisterForm
)
@app.route('/', methods=['GET'])
def home():
return render_template('home.html')
@app.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm(request.form)
if request.method == 'POST' and form.validate():
email = form.email.data
password = form.password.data
user = User.select_by_email(email)
if user and user.check_password(password):
""" ユーザに対してログイン処理を施す """
login_user(user)
return redirect(url_for('home'))
elif user:
flash('パスワードが間違っています')
else:
flash('存在しないユーザです')
return render_template('login.html', form=form)
@app.route('/register', methods=['GET', 'POST'])
def register():
form = RegisterForm(request.form)
if request.method == 'POST' and form.validate():
username = form.username.data
email = form.email.data
password = form.password.data
user = User(username, email, password)
with db.session.begin(subtransactions=True):
db.session.add(user)
db.session.commit()
return redirect(url_for('login'))
return render_template('register.html', form=form)
- 例によって必要なモジュールをインポートしていく。flaskからインポートするredirectというのはHTTPステータス302の他のページにリダイレクトするモジュールである。url_forは指定したパスの関数を実行するものである。flask_loginから読み込むモジュールはユーザのログインセッションにまつわるものを表す。
- 次に、ルーティングの部分であるが、/直下のパスは単純にhome.htmlをレンダリングするだけのものである。
- /loginではformとしてforms.pyで作る予定のLoginFormクラスを使用し、POSTメソッドであった場合、つまり、ユーザがログインフォームを介して情報を送信してきた場合に、emailでフィルターしたSELECT文を実行し該当した場合、userインスタンスを生成する。ユーザが存在し、パスワードが間違っていなければlogin_user(user)でログイン処理をする。ログイン処理をするとユーザがログインしたブラウザではログインセッションを維持することができる。あとはelifとelseでパスワードが間違っている場合とそもそもemailでユーザがヒットしない場合とで分岐させている。
- 順番が前後しているが/registerでユーザ登録処理を実装している。formとしてforms.pyで作る予定のRegisterFormを使用し、ユーザからポストリクエストがありばform.username.data等の変数を作成し、Userインスタンス化している。今回のようにフォームにwtformsを利用している場合.dataをつけることを忘れることが多いので注意する必要がある。あとは、作成したuserをwith文のコンテキストマネージャを使って、DBに格納している。登録が完了すればlogin画面にリダイレクトし、完了しなければ再度register画面が表示されるというものだ。
それでは、views.pyでインポートしてformをforms.pyで定義していく。
from wtforms.form import Form
from wtforms.fields import (
IntegerField, StringField, TextField, TextAreaField, PasswordField,
HiddenField, SubmitField, FileField
)
from wtforms.validators import DataRequired, Email, EqualTo
from wtforms import ValidationError
from flaskr.views import User
class LoginForm(Form):
email = StringField('メールアドレス', validators=[DataRequired(), Email()])
password = PasswordField('パスワード', validators=[DataRequired()])
conf_password = PasswordField('確認用パスワード', validators=[DataRequired(), EqualTo('password', message='元のパスワードと一致しません')])
submit = SubmitField('ログイン')
def validate_password(self, field):
if len(field.data) < 4:
raise ValidationError('パスワードは4文字以上で!')
class RegisterForm(Form):
username = StringField('ユーザ名', validators=[DataRequired()])
email = StringField('メールアドレス', validators=[DataRequired(), Email()])
password = PasswordField('パスワード', validators=[DataRequired()])
conf_password = PasswordField('確認用パスワード', validators=[DataRequired(), EqualTo('password', message='元のパスワードと一致しません')])
submit = SubmitField('ユーザ登録')
def validate_email(self, field):
if User.select_by_email(field.data):
raise ValidationError('すでに登録されているメールアドレスです')
基本はSTEP1と変わらずに各フォームをクラスで定義して、クラス変数に即したFieldを設定してあげればよい。ただ、今回はvalidatorsという引数を追加しているのと各種validate用のメソッドを定義している。validators引数ではDataRequired, Email, EqualToをインポートしており、それぞれデータが入力されていないとエラーになるもの、Eメールの形式でないとエラーになるもの、任意の変数と同じ値でないとエラーになるものとなっている。
validate用のメソッドとして、パスワードを4文字以上に制限するものや(普通のWebサービスであれば少なくとも8文字以上が好ましい)、登録するメールアドレスがすでに登録されていないかチェックするものを定義している。
次に、Templateの方を作成していく。Jinjaには便利な技術があって、HTMLファイル内で共通する記述をテンプレート化することができる。本章ではhome.html, login.html, register.htmlを作成するがそのテンプレートとなるbase.htmlをまず作成したいと思う。
<!DOCTYPE html>
<html lang="ja">
<head>
<title>{% block title %} - FLASK_SNS{% endblock %}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x" crossorigin="anonymous">
</head>
<nav>
<ul class="nav">
<li class="nav-item"><a class="nav-link" href="{{url_for('home')}}">ホーム</a></li>
<li class="nav-item"><a class="nav-link" href="{{url_for('login')}}">ログイン</a></li>
<li class="nav-item"><a class="nav-link" href="{{url_for('register')}}">登録</a></li>
</ul>
</nav>
<body>
<!-- container -> row -> col -->
<div class="container">
<div class="row">
{% block container %}
{% endblock %}
</div>
</div>
</body>
</html>
{% block %}{% endblock %}という見慣れないものが出てきたと思うが、これはこれらの波括弧で囲まれた範囲が変更可能な部分で、それ以外をテンプレート化するものである。
このファイルであるととかのような共通部分はそのまま引き継がれることになる。
あと、bootstrapの最新系の5系をインポートしている。
そして、もう一つ補助用のhtmlファイルとして_helpers.htmlを作成する。
{% macro render_field(field) %}
<dt>
<!-- field.label field.**kwargs()を実行しているだけ -->
{{field.label}}
<dd>
{{field(**kwargs) | safe}}
<!-- wtformsのValidationErrorがあれば表示 -->
{% if field.errors %}
<ul class="errors">
{% for error in field.errors %}
<li>{{error}}</li>
{% endfor %}
</ul>
{% endif %}
</dd>
</dt>
{% endmacro %}
今度は{% macro %}{% endmacro %}というものが出てきたが、この範囲で関数を定義しているだけである。マクロという名前はC言語から引っ張ってきているかは分からないが、C言語と同様定義したmacroは外部ファイルからインポートできるようになる。
ちなみに、この関数は.label('ユーザ名'等のフォームの文言)の部分をいちいち書かないでいいように先に書いていることと、先のforms.pyのvalidate用のメソッドをfield.errorsの部分で実行してくれている。
では、上記の補助ファイルを駆使してhome.html, login.html, register.htmlファイルを作成していく。
{% extends "base.html" %}
{% block title%}
Home{{super()}}
{% endblock %}
{% block container %}
<h1>ホーム画面</h1>
{% if current_user.is_authenticated %}
<p>ユーザ名:{{current_user.username}}</p>
{% else %}
<p>ログインするのじゃ</p>
{% endif %}
{% endblock %}
{% extends "base.html" %}
{% from "_helpers.html" import render_field %}
{% block title%}
Login{{super()}}
{% endblock %}
{% block container %}
<h1>ログイン画面</h1>
<!-- flashを実行する -->
{% for message in get_flashed_messages() %}
<p>※{{message}}</p>
{% endfor %}
<form method='POST'>
{{render_field(form.email)}}
{{render_field(form.password)}}
{{render_field(form.conf_password)}}
{{form.submit()}}
</form>
{% endblock %}
{% extends "base.html" %}
{% from "_helpers.html" import render_field %}
{% block title%}
Register{{super()}}
{% endblock %}
{% block container %}
<h1>ユーザ登録画面</h1>
<!-- flashを実行する -->
{% for message in get_flashed_messages() %}
<p>※{{message}}</p>
{% endfor %}
<form method='POST'>
{{render_field(form.username)}}
{{render_field(form.email)}}
{{render_field(form.password)}}
{{render_field(form.conf_password)}}
{{form.submit()}}
</form>
{% endblock %}
それではお待ちかねの実行タイムである。
ターミナルからpython setup.pyで実行してみよう。
上記のようにホーム画面が表示されたらいったんは上手く読み込まれている。
次に登録画面に移ってみよう。
パスワードを入力していなかったりするとこのようにエラーが返ってくる。これはforms.pyで設定したvalidatorsがうまく作用しているということである。
他にもパスワードを4文字未満にしたり、確認用のパスワードを間違えて見たりすればすべてエラーになるので是非試してみてほしい。
そして、登録した情報でログインして以下のようなホーム画面にリダイレクトされればSTEP2は成功である、おめでとう!
ユーザ名:hogeと返している部分であるが、home.htmlの{% if current_user.is_authenticated %}の部分でユーザがログインしているかチェックしている。
current_userというのはflask_loginのモジュールであり、flaskによってレンダリングされたHTMLファイルであれば自由にflaskのモジュールを利用することが可能であるのだ。
STEP3. ユーザ情報編集機能の実装
さて、ログイン機能を実装したところで、STEP3ではパスワード再設定、ログアウト、ユーザ情報編集機能を追加していこう。
まず、ディレクトリについてだが少々複雑になってきたがSTEP3終了時では以下のようになる想定である。
(any path)
├ flaskr/
| ├ templates/
| ├ _helpers.html
| ├ base.html
| ├ home.html
| ├ forgot_password.html
| ├ setting.html
| ├ login.html
| └ register.html
|
| ├ static/
| ├ css/
| └ style.css
|
| └ user_images/
|
| ├ __init__.py
| ├ views.py
| ├ forms.py
| └ models.py
|
└ setup.py
では、早速views.pyに関数を加えよう。
今回からは紙幅の観点からも追加分だけを記載していく。
@app.route('/forgot_password', methods=['GET', 'POST'])
def forgot_password():
""" 要はパスワードをアップグレードしたい """
form = LoginForm(request.form)
user = None
if request.method == 'POST':
email = form.email.data
user = User.select_by_email(email)
if form.password.data:
with db.session.begin(subtransactions=True):
user.reset_password(form.password.data)
db.session.commit()
return redirect(url_for('login'))
return render_template('forgot_password.html', form=form, user=user)
return render_template('forgot_password.html', form=form, user=user)
@app.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('home'))
@app.route('/setting', methods=['GET', 'POST'])
@login_required
def setting():
form = SettingForm(request.form)
user_id = current_user.get_id()
if request.method == 'POST':
user = User.select_by_id(user_id)
with db.session.begin(subtransactions=True):
user.username = form.username.data
user.email = form.email.data
user.update_at = datetime.now()
if form.comment.data:
user.comment = form.comment.data
# fileの中身を読込
file = request.files[form.picture_path.name].read()
if file:
file_name = user_id + '_' + str(int(datetime.now().timestamp())) + '.jpg'
picture_path = 'flaskr/static/user_images/' + file_name
# picture_pathの箱にfileの中身を書き込む
open(picture_path, 'wb').write(file)
user.picture_path = 'user_images/' + file_name
db.session.commit()
return redirect(url_for('home'))
return render_template('setting.html', form=form)
- まず/forgot_passwordに関してだが、formはLoginFormと同じものを読み込む、そして、すでにユーザが登録されていることが前提なのでmodels.pyのUserクラスのselect_by_email()によってuserが存在したら、passwordカラムをUPDATEする処理を記述している。正しく、パスワードが再設定されたらそのままログイン画面にリダイレクトさせている。
- 次に/logoutだが、まずデコレータに@login_requiredを付与している。これはユーザがログイン状態かをチェックしてくれるものになる。以降もログインしていないと実行させてはいけない処理についてはすべてこれを付ける必要がある。中身はlogout_user()を実行してログイン状態を破棄するだけなので簡単である。
- そして、/settingであるが、こちらがユーザ情報編集画面を表すものとなる。models.pyでusernameやpasswordのほかに、commentやpicture_pathのカラムを作成したがこれらを編集するためのものだ。formとして次に作成する予定のSettingFormを使用する。そして、ログイン中のuserのidをcurrent_user.get_id()で定義し、Userクラスに後でselect_by_id()メソッドを追加し、idをもとにSELECTされたuserインスタンスを生成している。このuserインスタンスにおいて、ユーザ名、パスワード、コメントとあとアイコン用の画像をアップロードできるようにしている。画像ファイルに関してはユーザからアップロードされた画像をバイナリファイルで読み込んでそれを新設したpicture_pathにバイナリで書き込むという作業をしている。これでアップロードされたユーザの画像は逐次flaskr/static/user_images/ディレクトリに保存されていく。
あと最後にforms.pyからSettingFormをインポートしておいてほしい。
では次にforms.pyを修正していく。
class SettingForm(Form):
username = StringField('ユーザ名', validators=[DataRequired()])
email = StringField('メールアドレス', validators=[DataRequired(), Email('メールアドレスでお願いマッスル')])
comment = TextAreaField('一言コメント')
picture_path = FileField('画像ファイルアップロード')
submit = SubmitField('更新')
以上のようにusername, email, comment, picture_pathをUPDATEできるフォームを作成した。
次に、models.pyも少し修正する。Userクラスの末尾に以下を追記しよう。
@classmethod
def select_by_id(cls, id):
""" UserテーブルからidでSELECTされたインスタンスを返す """
return cls.query.get(id)
それでは、Templateの方を記述していこう。
まずはパスワード再設定用にforgot_password.htmlを作成していく。
{% extends "base.html" %}
{% from "_helpers.html" import render_field %}
{% block title%}
Register{{super()}}
{% endblock %}
{% block container %}
<h1>パスワード再登録画面</h1>
<!-- flashを実行する -->
{% for message in get_flashed_messages() %}
<p>※{{message}}</p>
{% endfor %}
{% if user %}
<form method='POST'>
{{render_field(form.email)}}
{{render_field(form.password)}}
{{render_field(form.conf_password)}}
{{form.submit()}}
</form>
{% else %}
<form method='POST'>
{{render_field(form.email)}}
{{form.submit()}}
</form>
{% endif %}
{% endblock %}
if文でuserが存在する時に再設定フォームを返し、存在しない時はもう一度メールアドレスを投入するように返している。
次に、ユーザ情報編集用のsetting.htmlを作成していく。
{% extends "base.html" %}
{% from "_helpers.html" import render_field %}
{% block title%}
Setting{{super()}}
{% endblock %}
{% block container %}
<h1>アカウント設定画面</h1>
<!-- flashを実行する -->
{% for message in get_flashed_messages() %}
<p>※{{message}}</p>
{% endfor %}
<form method='POST' enctype="multipart/form-data">
{{render_field(form.username, value=current_user.username)}}
{{render_field(form.email, value=current_user.email)}}
{{render_field(form.comment)}}
{{render_field(form.picture_path)}}
{{form.submit()}}
</form>
{% endblock %}
ここで注意すべきは画像ファイルをformタグからアップロードする場合はenctype="multipart/form-data"を指定することである。これを指定しないとエラーになるので注意が必要だ。
あとは、_helpers.htmlとbase.htmlとhome.htmlとlogin.htmlに軽微な修正を施す。
{% macro validate_picture(user, class) %}
{% if user.picture_path %}
<img class="{{class}}" src="{{url_for('static', filename=user.picture_path)}}">
{% else %}
<img class="{{class}}" src="{{url_for('static', filename='user_images/profile_icon.png')}}">
{% endif %}
{% endmacro %}
_helpers.htmlにvalidate_picture関数を追記した。これはユーザ画像が設定されていたらユーザ画像を、設定されていない場合はprofile_icon.pngというデフォルトの画像を返すようなものである。
<!DOCTYPE html>
<html lang="ja">
<head>
<title>{% block title %} - FLASK_SNS{% endblock %}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x" crossorigin="anonymous">
<link rel="stylesheet" href="{{url_for('static', filename='css/style.css')}}">
</head>
<nav>
<ul class="nav">
{% if current_user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="{{url_for('home')}}">ホーム</a></li>
<li class="nav-item"><a class="nav-link" href="{{url_for('logout')}}">ログアウト</a></li>
<li class="nav-item"><a class="nav-link" href="{{url_for('setting')}}">アカウント情報編集</a></li>
{% else %}
<li class="nav-item"><a class="nav-link" href="{{url_for('login')}}">ログイン</a></li>
<li class="nav-item"><a class="nav-link" href="{{url_for('register')}}">登録</a></li>
{% endif %}
</ul>
</nav>
<body>
<!-- container -> row -> col -->
<div class="container">
<div class="row">
{% block container %}
{% endblock %}
</div>
</div>
</body>
</html>
base.htmlの全文を掲載したが、修正点はheadタグ内にstyle.cssを追記したのと、ul class="nav"タグ内の2点である。
{% extends "b.html" %}
{% from "_helpers.html" import validate_picture %}
{% block title%}
Home{{super()}}
{% endblock %}
{% block container %}
<h1>ホーム画面</h1>
{% if current_user.is_authenticated %}
{{validate_picture(current_user, 'image-big')}}
<p>ユーザ名:{{current_user.username}}</p>
<p>一言コメント:{{current_user.comment}}</p>
{% else %}
<p>ログインするのじゃ</p>
{% endif %}
{% endblock %}
追加部分だけ抜粋しにくかったので、現状のhome.html全文を掲載している。
<a href="{{url_for('forgot_password')}}">パスワードを忘れた場合はこちら</a>
formタグのすぐ下に上記の文を挿入してパスワードを再設定できるようにする。
最後に、static/css/style.cssを作成して画像の大きさを修正するファイルを記述する。
.image-big {
width: 200px;
height: 200px;
}
それでは、お待ちかね実行していこう!
先ほどログインした状態が続いていれば上記のように表示される。これは設定したデフォルト画像によるものなので、デフォルト画像を設定していない人は画像は表示されないかもしれない。
そして、上のナビゲーションバーからログアウトを行うと以前の簡素なログインを促すホーム画面に遷移するか確認してみてほしい。
また、ログイン画面に移るとパスワード再設定画面が表示されて、きちんと遷移し、登録されたメールアドレスなら再設定フォームに、登録されていないメールアドレスならリダイレクトされることを確認してみてほしい。
そして、最後にユーザ情報編集画面から情報を編集するとホーム画面が編集された情報に更新されていることを確認すればSTEP3は終了だ!
STEP4. ユーザ検索機能の実装
さて、ユーザ単体のログイン・情報編集ができたところで登録されているユーザの検索機能を実装していこうと思う。
まずはviews.pyから加筆していこう。
以下のように/user_searchを追加する。新たにUserSearchFormというフォームを作成して、Userクラスにselect_by_usernameメソッドを追加する。
@app.route('/user_search', methods=['GET', 'POST'])
@login_required
def user_search():
form = UserSearchForm(request.form)
users = None
if request.method == 'POST' and form.validate():
users = User.select_by_username(form.username.data)
if users:
return render_template('user_search.html', form=form, users=users)
flash('ユーザが存在しません')
return render_template('user_search.html', form=form, users=users)
実施していることはユーザから送られたフォームをもとに、ユーザ名でfilterしてUserテーブルからusersインスタンスを返すというものになる。
では、forms.pyとmodels.pyに必要なものを追記していく。
class UserSearchForm(Form):
username = StringField('ユーザ名', validators=[DataRequired()])
submit = SubmitField('ユーザ検索')
@classmethod
def select_by_username(cls, username):
return cls.query.filter(
cls.username.like(f'%{username}%'), # 両方向部分一致検索
cls.id != int(current_user.get_id()),
).all()
forms.pyにはユーザ名を入力するだけのUserSearchFormを、models.pyのUesrクラス配下にはユーザ名を含むuserインスタンスを返すすべて返すクラスメソッドを追記した。
f'%{username}%'の%が任意の文字列を表すので前方、後方ともに部分一致検索をすることが可能となる。
では、Templateからuser_search.htmlを作成して少しhome.html, style.cssを修正しよう。
{% extends "base.html" %}
{% from "_helpers.html" import render_field %}
{% from "_helpers.html" import validate_picture %}
{% block title%}
User Search{{super()}}
{% endblock %}
{% block container %}
<h1>ユーザ検索画面</h1>
<!-- flashを実行する -->
{% for message in get_flashed_messages() %}
<p>※{{message}}</p>
{% endfor %}
<form method='POST'>
{{render_field(form.username)}}
{{form.submit()}}
</form>
{% if users %}
<table class="table table-striped">
<tr>
<th scope="col">#</th>
<th scope="col">ユーザ名</th>
<th scope="col">ユーザ画像</th>
<th scope="col">コメント</th>
</tr>
{% for user in users %}
<tr>
<td>{{loop.index}}</td>
<td>{{user.username}}</td>
<td>
{{validate_picture(user, 'image-small')}}
</td>
<td>{{user.comment}}</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endblock %}
<ul class="nav">
{% if current_user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="{{url_for('home')}}">ホーム</a></li>
<li class="nav-item"><a class="nav-link" href="{{url_for('logout')}}">ログアウト</a></li>
<li class="nav-item"><a class="nav-link" href="{{url_for('setting')}}">アカウント情報編集</a></li>
<li class="nav-item"><a class="nav-link" href="{{url_for('user_search')}}">ユーザ検索</a></li>
{% else %}
.image-small {
width: 100px;
height: 100px;
}
.image-mini {
width: 50px;
height: 50px;
}
user_search.htmlで検索されたユーザの情報を表示し、home.htmlのナビゲーションバーにユーザ検索タブを追加し、cssに画像サイズを追加した。
ホーム画面からユーザ検索画面を飛んで、てきとうに入力するとユーザが検索されてきたらOKだ!
今回は試験的にhoge, hege, hugeというユーザを追加し、「h」で検索して全員が部分一致で検索されてきたら成功しているということである。
STEP5. 友達申請、削除機能の実装
友達検索ができたら次は友達を追加したいと思う。
しかし、友達を追加するには一工夫が必要だ。
なぜなら、任意のユーザ間に対して友達関係というステータスは[無関係、申請中、友達]という3つの状態を保持する必要があるからだ。
この友達関係のデータを保存しておくためのテーブルを作るところからSTEP5ははじめることとする。
テーブルが増えてきて外部結合してくると少し複雑になるがゆっくりと実装していけば理解できると思う。
では、models.pyから新たにテーブルを追加していくところからはじめたいと思う。
from sqlalchemy import and_, or_, desc
class UserConnect(db.Model):
""" Userの友達状態を記録するテーブル """
id = db.Column(db.Integer, primary_key=True)
from_user_id = db.Column(db.Integer, db.ForeignKey('user.id')) # user.idを外部キーとする
to_user_id = db.Column(db.Integer, db.ForeignKey('user.id')) # user.idを外部キーとする
status = db.Column(db.Integer, default=0)
create_at = db.Column(db.DateTime, default=datetime.now) # datetime.now()では変になる
update_at = db.Column(db.DateTime, default=datetime.now)
def __init__(self, from_user_id, to_user_id, status=0):
self.from_user_id = from_user_id
self.to_user_id = to_user_id
self.status = status
@classmethod
def select_connect(cls, from_user_id, to_user_id):
""" fromとtoを指定してSELECTされた友達関係を返す """
return cls.query.filter_by(
from_user_id = from_user_id,
to_user_id = to_user_id
).first()
@classmethod
def select_id(cls, id1, id2):
""" 1対の友達関係をSELECTして友達関係を返す """
return cls.query.filter(
or_(
and_(
UserConnect.from_user_id == id1, # Class.が必要
UserConnect.to_user_id == id2, # filter_byと違って== になる
),
and_(
UserConnect.from_user_id == id2,
UserConnect.to_user_id == id1,
),
),
).first()
- まずカラムについてであるが主キーを設定するidをはじめとして、Userテーブルのidを外部キーとするfrom_user_id, to_user_idを定義する。これは友達申請の方向を記録するために必要である。次にstatusをINTEGERで定義している。これは{無関係:0, 申請中:1, 友達:2}の3つの値に変化していくことを想定している。最後に、create_at, update_atを日付型として定義している。
- from_user_id, to_user_id, status=0を引数としてインスタンスを作成できるようにしている。
- 次に、from_user_id, to_user_idを指定して友達関係をSELECTしてくるメソッドをselect_connectとして定義している。
- 最後に、select_connectの友達申請方向を考慮しないSELECT文をselect_idとして定義している。
では次にviews.pyを追記していく。
@app.route('/user_connect', methods=['POST'])
@login_required
def user_connect():
form = ConnectForm(request.form)
if form.connect_status.data == 'apply':
from_user_id = current_user.get_id()
to_user_id = form.to_user_id.data
connect = UserConnect(from_user_id, to_user_id, status=1)
with db.session.begin(subtransactions=True):
db.session.add(connect)
db.session.commit()
elif form.connect_status.data == 'approve':
from_user_id = form.to_user_id.data
to_user_id = current_user.get_id()
connect = UserConnect.select_connect(from_user_id, to_user_id)
with db.session.begin(subtransactions=True):
connect.status = 2
db.session.commit()
return redirect(url_for('home'))
@app.route('/delete_connect', methods=['POST'])
@login_required
def delete_connect():
id = request.form['id']
connect = UserConnect.select_id(id, current_user.get_id())
with db.session.begin(subtransactions=True):
db.session.delete(connect)
db.session.commit()
return redirect(url_for('home'))
/user_connectで友達申請と友達承認処理を行い、/delete_connectで友達解消を行う。あと、友達解消ってなんだよ、悲しいな。
/user_connectではConnectFormを使って友達関係のto_user_idとconnect_statusを取得している。ConnectFormについては後に定義するが、connect_statusがapplyのものは友達申請を表し新たにUserConnectインスタンスを作成する処理を施し、approveのものは友達承認を表しstatusを2に、つまり友達承認し友達関係となる処理を施すこととする。
あと、これは私の座右の銘の一つであるが**「すぐに役立つものはすぐに役立たなくなる」**
つまり、すぐになれる友達関係などまるで泡沫のように脆く儚い関係だと思うのだ。親友は大切にしような。
閑話休題
次は/delete_connectについてである。友達解消法であるが、これは先ほどmodels.pyで定義した任意の二者間の友達関係を返すselect_id()によって友達関係を取得し、これをDELETEする処理である。これはホーム画面の友達一覧からボタンで実行できるようにしたいと思う。
では次にforms.pyでConnectFormを作成していきたいと思う。
class ConnectForm(Form):
to_user_id = HiddenField()
connect_status = HiddenField()
submit = SubmitField()
HiddenFieldという怪しいフィールドが出てきたがこれはユーザが何も入力せずとも暗黙的に値を返したい時に用いるフィールドである。具体的な使用法はTemplateの方で確認したいと思う。
さて、友達申請行為は友達検索画面から行いたいわけだが、現状のviews.pyの処理では友達検索結果に友達関係まで値を渡すのが難しい。そこで、models.pyのUserクラスに友達関係を結合させたuserインスタンスを返すメソッドを追記してやろう。
Userクラスのselect_by_usernameメソッドを以下のように書き換える。
from sqlalchemy.orm import aliased
@classmethod
def select_by_username(cls, username):
""" UserConnectと外部結合させた上で、UserテーブルからusernameでSELECTされたインスタンスを返す """
user_connect1 = aliased(UserConnect) # UserConnectと紐づけられたクエリ
user_connect2 = aliased(UserConnect)
return cls.query.filter(
cls.username.like(f'%{username}%'), # 両方向部分一致検索
cls.id != int(current_user.get_id()),
).outerjoin( # UserConnectと外部結合
user_connect1,
and_( # fromが自分
user_connect1.from_user_id == current_user.get_id(),
user_connect1.to_user_id == cls.id,
)
).outerjoin(
user_connect2,
and_( # fromが相手
user_connect2.from_user_id == cls.id,
user_connect2.to_user_id == current_user.get_id(),
)
).with_entities(
cls.id, cls.username, cls.picture_path, cls.comment,
user_connect1.status.label('joined_status_from_currentuser'),
user_connect2.status.label('joined_status_from_user'),
).all()
まず、user_connect1 = aliased(UserConnect)の部分でUserConnectのエイリアスを紐づける変数を定義する。次に.outerjoin()の部分で上のエイリアスを持ち、ログインしているユーザが友達関係に含まれる友達関係を両方向ともに取得、最後に.with_entitiesの部分で取得する変数を定義している。ここでは[id, username, picture_path, comment, joined_status_from_currentuser, joined_status_from_user]の変数を取得しているようにしている。
つまり、ユーザ検索画面で検索されたユーザ情報とあわせてそのユーザとの友達関係まで取得するメソッドを記述したわけだ。
あわせてviews.pyも少し修正する。
/user_searchのところを以下のように書き換える。
@app.route('/user_search', methods=['GET', 'POST'])
@login_required
def user_search():
form = UserSearchForm(request.form)
connect_form = ConnectForm()
users = None
if request.method == 'POST' and form.validate():
users = User.select_by_username(form.username.data)
if users:
return render_template('user_search.html', form=form, connect_form=connect_form, users=users)
flash('ユーザが存在しません')
return render_template('user_search.html', form=form, connect_form=connect_form, users=users)
あと、views.pyにmodels.pyからUserConnect、forms.pyからConnectFormを忘れずに読み込んでおこう。
では最後に、user_search.htmlを修正する。
{% extends "base.html" %}
{% from "_helpers.html" import render_field %}
{% from "_helpers.html" import validate_picture %}
{% block title%}
User Search{{super()}}
{% endblock %}
{% block container %}
<h1>ユーザ検索画面</h1>
<!-- flashを実行する -->
{% for message in get_flashed_messages() %}
<p>※{{message}}</p>
{% endfor %}
<form method='POST'>
{{render_field(form.username)}}
{{form.submit()}}
</form>
{% if users %}
<table class="table table-striped">
<tr>
<th scope="col">#</th>
<th scope="col">ユーザ名</th>
<th scope="col">ユーザ画像</th>
<th scope="col">コメント</th>
<th scope="col">友達申請</th>
</tr>
{% for user in users %}
<tr>
<td>{{loop.index}}</td>
<td>{{user.username}}</td>
<td>
{{validate_picture(user, 'image-small')}}
</td>
<td>{{user.comment}}</td>
<td>
{% if (user.joined_status_from_currentuser == 2) or (user.joined_status_from_user == 2) %}
<p>友達です</p>
{% elif user.joined_status_from_currentuser == 1 %}
<p>友達申請中</p>
{% elif user.joined_status_from_user == 1 %}
<form method="POST" action="{{url_for('user_connect')}}">
{{connect_form.to_user_id(value=user.id)}}
{{connect_form.connect_status(value='approve')}}
{{connect_form.submit(class='btn btn-info', value='友達承認する')}}
</form>
{% else %}
<form method="POST" action="{{url_for('user_connect')}}">
{{connect_form.to_user_id(value=user.id)}}
{{connect_form.connect_status(value='apply')}}
{{connect_form.submit(class='btn btn-info', value='友達申請する')}}
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endblock %}
友達申請や友達承認をするときにフォームのHiddenFieldからconnect_statusとto_user_idが暗黙的に返されているのがわかるだろう。このようにユーザの情報は欲しいが、ユーザに入力させるものではない場合にHiddenFieldというのは利用される。
では、お待ちかね実行タイムとしよう。
例によってpython setup.pyでローカルサーバを起動して、友達検索画面で友達を検索してみよう。
以下のようなボタンが表示されていればOKだ!
おかわいいこと!!!!!
かぐや様とは現在友達関係にないので友達申請するボタンが表示されていることがわかる。
友達申請ボタンを押してもう一度友達を検索してみると友達申請中にステータスが変わっているはずだ。
さて、今のままではいちいち友達検索しないと誰が友達で誰から承認待ちなのかがわからず使い物にならないため、ホーム画面に友達一覧を作成していこう。
views.pyのhome部分を以下のように書き換えよう。
@app.route('/', methods=['GET'])
def home():
connect_form = ConnectForm()
friends = requested_friends = None
if current_user.is_authenticated:
friends = User.select_friends()
requested_friends = User.select_requested_friends()
return render_template('home.html',
friends=friends, requested_friends=requested_friends, connect_form=connect_form)
friends, requested_friendsで友達のユーザと承認待ちのユーザを取り出して、home.htmlに渡すようにする。
次に、select_friends()やselect_requested_friends()をmodels.pyのUserクラス内、select_by_usernameの下辺りに追記していこう。
@classmethod
def select_friends(cls):
""" UserConnectを紐づけて、current_userとstatus==2のUserインスタンスを返す """
return cls.query.join(
UserConnect,
or_(
and_(
UserConnect.from_user_id == current_user.get_id(),
UserConnect.to_user_id == cls.id, # 返す予定のユーザid
UserConnect.status == 2,
),
and_(
UserConnect.from_user_id == cls.id,
UserConnect.to_user_id == current_user.get_id(), # 返す予定のユーザid
UserConnect.status == 2,
),
),
).all()
@classmethod
def select_requested_friends(cls):
""" UserConnectを紐づけて、current_userをtoとしてstatus==1のUserインスタンスを返す """
return cls.query.join(
UserConnect,
and_(
UserConnect.from_user_id == cls.id,
UserConnect.to_user_id == current_user.get_id(),
UserConnect.status == 1,
),
).all()
こちらもUserクラスのメソッドではるが、cls.query.join()によって実際はUserConnectの条件によってuserをSELECTしてきている。
select_friendsがログインユーザと友達関係にあるユーザを取得するメソッドで、select_requested_friendsが承認待ちにしているユーザを取得するメソッドである。
では、最後にhome.htmlを修正していく。
{% extends "base.html" %}
{% from "_helpers.html" import validate_picture %}
{% block title%}
Home{{super()}}
{% endblock %}
{% block container %}
<h1>ホーム画面</h1>
{% if current_user.is_authenticated %}
{{validate_picture(current_user, 'image-big')}}
<p>ユーザ名:{{current_user.username}}</p>
<p>一言コメント:{{current_user.comment}}</p>
{% if friends %}
<h2>友達一覧</h2>
<div class="table-responsive">
<table class="table table-striped">
<tr>
<th>#</th>
<th>友達名</th>
<th>友達画像</th>
<th>コメント</th>
<th>メッセージ</th>
<th>友達解除</th>
</tr>
{% for friend in friends %}
<tr>
<td>{{loop.index}}</td>
<td>{{friend.username}}</td>
<td>{{validate_picture(friend, 'image-small')}}</td>
<td>{{friend.comment}}</td>
<td><p>メッセージを送る</p></td>
<td>
<form method="POST" action="{{url_for('delete_connect')}}">
<button type="submit" class="btn btn-warning" name="id" value="{{friend.id}}">友達解除</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% if requested_friends %}
<h2>承認待ちユーザ</h2>
<div class="table-responsive">
<table class="table table-striped">
<tr>
<th>#</th>
<th>ユーザ名</th>
<th>ユーザ画像</th>
<th>コメント</th>
<th>友達承認</th>
<th>友達棄却</th>
</tr>
{% for requested_friend in requested_friends %}
<tr>
<td>{{loop.index}}</td>
<td>{{requested_friend.username}}</td>
<td>{{validate_picture(requested_friend, 'image-small')}}</td>
<td>{{requested_friend.comment}}</td>
<td>
<form method="POST" action="{{url_for('user_connect')}}">
{{connect_form.to_user_id(value=requested_friend.id)}}
{{connect_form.connect_status(value='approve')}}
{{connect_form.submit(class='btn btn-info', value='友達承認する')}}
</form>
</td>
<td>
<form method="POST" action="{{url_for('delete_connect')}}">
<button type="submit" class="btn btn-warning" name="id" value="{{requested_friend.id}}">友達を棄却</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% else %}
<p>ログインするのじゃ</p>
{% endif %}
{% endblock %}
friends, requested_friendsの両変数をもとにホーム画面に友達一覧を表示している。
また、connect_formのHiddenFieldを使って、友達承認、友達棄却、友達解除等ができるようにした。
それでは、実際にホーム画面を確認していこう。
こんな感じで表示されていればOKだ!
STEP6. メッセージ送信機能の実装
それでは、いよいよ終盤メッセージ機能を実装していく。
先ほど、ホーム画面の友達一覧から「メッセージを送る」という箇所を設けたがここから友達とメッセージできるようにしていきたいと思う。
メッセージも記録が必要なデータなので、テーブルを新設していくところから始めよう。
class Message(db.Model):
""" メッセージを記録するテーブル """
id = db.Column(db.Integer, primary_key=True)
from_user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
to_user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
message = db.Column(db.Text)
is_read = db.Column(db.Boolean, default=False)
create_at = db.Column(db.DateTime, default=datetime.now)
update_at = db.Column(db.DateTime, default=datetime.now)
def __init__(self, from_user_id, to_user_id, message):
self.from_user_id = from_user_id
self.to_user_id = to_user_id
self.message = message
@classmethod
def select_messages(cls, friend_id):
return cls.query.filter(
or_(
and_(
Message.from_user_id == current_user.get_id(),
Message.to_user_id == friend_id,
),
and_(
Message.from_user_id == friend_id,
Message.to_user_id == current_user.get_id(),
),
),
).all()
models.pyにMessageテーブルを追加した。
idを主キーとして、from_user_id, to_user_id, create_at, update_atはUserConnectテーブルと同様に設定していく。あとはメッセージを保持したいのでmessage、STEP7で既読状態を管理したいので[未読, 既読]のBooleanなカラムを定義した。
次に、インスタンス作成メソッドとメッセージ相手のidをもとにメッセージをSELECTするselect_messagesメソッドを定義した。
次に、views.pyも追記していく。
@app.route('/message/<to_user_id>', methods=['GET', 'POST'])
@login_required
def message(to_user_id): # Homeからurl_forで受け取る
is_friend = UserConnect.is_friend(to_user_id)
if not is_friend:
# 友達じゃなったらホームに返す
return redirect(url_for('home'))
form = MessageForm()
from_user_id = current_user.get_id()
friend = User.select_by_id(to_user_id)
messages = Message.select_messages(to_user_id)
if request.method == 'POST':
""" 投稿があればDBをアップグレードして更新 """
message = request.form['message']
new_message = Message(from_user_id, to_user_id, message)
with db.session.begin(subtransactions=True):
db.session.add(new_message)
db.session.commit()
return redirect(url_for('message', to_user_id=friend.id))
return render_template('message.html', form=form, friend=friend, messages=messages)
まず、UserConnectテーブルで自分と相手が友達状態か確認するメソッドを利用している。これは友達ではないユーザとはメッセージできないようにするために重要である。次に、メッセージ送信のメッセージフォームを定義している。そこからPOSTメソッドであれば新しいメッセージをDBに格納し、そうでなければメッセージフォームと友達のuserインスタンスとメッセージをテンプレートに返している。
あと、models.pyからMessageクラス、forms.pyからMessageFormを忘れずに読み込んでおこう。
それでは、models.pyにis_friendメソッドを、forms.pyにMessageFormを作成していこう。
@classmethod
def is_friend(cls, to_user_id):
result = cls.query.filter(
or_(
and_(
UserConnect.from_user_id == current_user.get_id(),
UserConnect.to_user_id == to_user_id,
UserConnect.status == 2,
),
and_(
UserConnect.from_user_id == to_user_id,
UserConnect.to_user_id == current_user.get_id(),
UserConnect.status == 2,
),
),
).first()
if result:
return True
else:
return False
UserConnectテーブルにログインユーザと相手のユーザが友達関係か確認し、友達関係であればTrue、否であればFalseを返すクラスメソッドを作成した。
class MessageForm(Form):
to_user_id = HiddenField()
message = TextAreaField('メッセージを入力', validators=[DataRequired()], default='')
submit = SubmitField('送信する')
HiddenFieldにメソッド相手のid、あとはメッセージフィールドを持たせたフォームを作成した。
それでは、Templateの方を作成していこう。
メッセージ画面のmessage.htmlを作りこんでいく。
{% extends "base.html" %}
{% from "_helpers.html" import render_field %}
{% from "_helpers.html" import validate_picture %}
{% block title %}
Message{{super()}}
{% endblock %}
{% block container %}
<h1>メッセージ画面</h1>
{% for message in messages %}
{% if message.from_user_id == (current_user.get_id()|int) %}
<!-- 自分側 -->
<div class="col-md-2">
{{validate_picture(current_user, 'image-mini')}}
<p>{{current_user.username}}</p>
</div>
<div class="speech-bubble-self col-md-4">
{{message.message|urlize(target=true)}}
<p></p>
<p>{{message.create_at.strftime('%H:%M')}}</p>
</div>
{% else %}
<!-- 相手側 -->
<div class="col-md-6"></div>
<div class="speech-bubble-dest col-md-4">
{{message.message|urlize(target=true)}}
<p></p>
<p>{{message.create_at.strftime('%H:%M')}}</p>
</div>
<div class="col-md-2">
{{validate_picture(friend, 'image-mini')}}
<p>{{friend.username}}</p>
</div>
{% endif %}
{% endfor %}
<form id="message-form" method="POST" action="{{url_for('message', to_user_id=friend.id)}}">
{{form.to_user_id(value=friend.id)}}
{{render_field(form.message, cols="50", rows="5")}}
{{form.submit()}}
</form>
{% endblock %}
少し煩雑そうに見えるが、自分側と相手側でユーザ名、ユーザアイコン、メッセージ、送信時間を表示しているだけなのでそんなに大層なことはしていない。
{% if message.from_user_id == (current_user.get_id()|int) %}の部分で取得したメッセージの自分のものだけを抜き出している。|intの部分はpythonでいうとint()メソッドを用いるのと同義である。Jinjaではこのような記法になるのだ。あとは
そして、メッセージ部分をclass="speech-bubble-selfのようにスタイリングしている。
最後に、メッセージフォームを表示したら終わりである。
では、メッセージのスタイリングをstyle.cssに加筆して、home.htmlを少し修正していこう。
.image-big {
width: 200px;
height: 200px;
}
.image-small {
width: 100px;
height: 100px;
}
.image-mini {
width: 50px;
height: 50px;
}
.speech-bubble-self {
position: relative;
background: #dedede;
border-radius: .4em;
margin-bottom: 20px;
}
.speech-bubble-self:after {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 0;
height: 0;
border: 10px solid transparent;
border-right-color: #dedede;
border-left: 0;
border-bottom: 0;
margin-top: -10px;
margin-left: -10px;
}
.speech-bubble-dest {
position: relative;
background: #05c4d6;
border-radius: .4em;
margin-bottom: 20px;
}
.speech-bubble-dest:after {
content: '';
position: absolute;
right: 0;
top: 50%;
width: 0;
height: 0;
border: 10px solid transparent;
border-left-color: #05c4d6;
border-right: 0;
border-bottom: 0;
margin-top: -10px;
margin-right: -10px;
}
これで吹き出しのようなスタイリングをすることが可能になった。
<td><a href="{{url_for('message', to_user_id=friend.id)}}" class="btn btn-primary" role="button">メッセージを送る</a></td>
あと、先pタグでメッセージを送るだけだった部分を上記のようにボタンに変更したら終了だ。
では、実際に試してみよう。
ホーム画面の友達一覧からメッセージを押して実際にメッセージ交換ができればOKだ!
STEP7. Ajaxを用いた既読機能の実装
さて、現状でもメッセージはできるのだが自分で更新しないとメッセージを読み込めないのではSNSではなく2000年代のメールなので、Ajaxで自動読み込みと既読機能を追加してアプリ構築については終わりにしたいと思う。
ではまずmessage.htmlの{% block container %}の下にAjaxのメッセージ自動スクリプトを追記していく。
<script>
// Ajaxで5秒間に未読メッセージを取得する
$(function(){
timer = setInterval("get_unread_messages()", 5000);
// 初期表示で画面の一番下にいく
var scroll = (document.scrollingElement || document.body);
scroll.scrollTop = scroll.scrollHeight;
});
user_id = "{{friend.id}}";
function get_unread_messages(){
// /message_ajaxと対応させながら未読メッセージを取得
$.getJSON("/message_ajax", {
user_id: user_id
},
function(data){
$('#message-form').before(data['data']);
});
};
</script>
Ajaxについての詳細な説明は行わないが簡単に説明すると、5秒おきに新しいメッセージを取得する関数を記述し、メッセージが更新されれば一番下の画面にスクロールされるようにし、views.pyからJSON形式で送られたメッセージを読み込むようにしている。
それではviews.pyにAjaxの部分を追記していく。
from flask import jsonify
@app.route('/message_ajax', methods=['GET'])
@login_required
def message_ajax():
user_id = request.args.get('user_id', -1, type=int)
user = User.select_by_id(user_id)
unread_messages = Message.select_unread_messages(user_id)
# 相手メッセージの既読フラグを更新する
if unread_messages:
with db.session.begin(subtransactions=True):
for unread_message in unread_messages:
unread_message.is_read = True
db.session.commit()
return jsonify(data=make_message_format(user, unread_messages))
Messageテーブルに未読メッセージを取得するselect_unread_messages()メソッドを追加し、そこで得た未読メッセージをAjaxで読み込めるようにJSON形式にしてmessage.htmlに返している。
では、models.pyのMessageクラスにメソッドを追加する。
@classmethod
def select_unread_messages(cls, friend_id):
return cls.query.filter_by(
from_user_id = friend_id,
to_user_id = current_user.get_id(),
is_read = False,
).all()
あと、staticやtemplatesと同じ階層のディレクトリni
utils/フォルダを作りajax_format.pyを作っていく。
from flask import url_for
from flask_login import current_user
from jinja2.utils import urlize
def make_message_format(user, messages):
message_tag = ''
for message in messages:
message_tag += '<div class="col-md-6"></div>'
message_tag += '<div class="speech-bubble-dest col-md-4">'
message_tag += f'<p>{urlize(message.message, target=True)}</p>'
message_tag += f'<p>{message.create_at.strftime("%H:%M")}</p></div>'
message_tag += '<div class="col-md-2">'
if user.picture_path:
message_tag += f'<img class="image-mini" src="{url_for("static", filename=user.picture_path)}">'
else:
message_tag += f'<img class="image-mini" src="{url_for("static", filename="user_images/profile_icon.png")}">'
message_tag += f'<p>{user.username}</p></div>'
return message_tag
これは今message.htmlでHTML形式で記述しているものを一度python形式で記述し直す必要があるために追加したものだ。やっていることはHTMLと変わらない。
では、views.pyにこれを読み込むのとあと既読機能を追記していく。
from flaskr.utils.ajax_format import make_message_format
@app.route('/message/<to_user_id>', methods=['GET', 'POST'])
@login_required
def message(to_user_id): # Homeからurl_forで受け取る
is_friend = UserConnect.is_friend(to_user_id)
if not is_friend:
# 友達じゃなったらホームに返す
return redirect(url_for('home'))
form = MessageForm()
from_user_id = current_user.get_id()
friend = User.select_by_id(to_user_id)
messages = Message.select_messages(to_user_id)
### ここを追加
# 相手メッセージの既読フラグを更新する
read_messages = [message for message in messages if message.from_user_id == friend.id]
if read_messages:
with db.session.begin(subtransactions=True):
for read_message in read_messages:
read_message.is_read = True
db.session.commit()
### ここまで
if request.method == 'POST':
""" 投稿があればDBをアップグレードして更新 """
message = request.form['message']
new_message = Message(from_user_id, to_user_id, message)
with db.session.begin(subtransactions=True):
db.session.add(new_message)
db.session.commit()
return redirect(url_for('message', to_user_id=friend.id))
return render_template('m.html', form=form, friend=friend, messages=messages)
/messageの真ん中の方で取得したメッセージを既読化するコードを書いている。
では、既読部分をmessage.htmlに追記し、あと、base.htmlにAjaxのスクリプトを読み込もう。
{% if message.from_user_id == (current_user.get_id()|int) %}
<!-- 自分側 -->
<div class="col-md-2">
{{validate_picture(current_user, 'image-mini')}}
<p>{{current_user.username}}</p>
</div>
<div class="speech-bubble-self col-md-4">
{{message.message|urlize(target=true)}}
<p></p>
<p>{{message.create_at.strftime('%H:%M')}}</p>
</div>
{% if message.is_read %}
<div class="col-md-2">
<p>既読</p>
</div>
<div class="col-md-4"></div>
{% else %}
<div class="col-md-6"></div>
{% endif %}
{% else %}
自分側のメッセージ部分に既読文字を追加した。
それでは、最後にbase.htmlのheadタグにAjaxを読み込むスクリプトを追加しよう。
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
これでローカルサーバを再び起動すれば、問題なく新しいメッセージを読み込んで既読がつくはずだ!
STEP8. herokuへのデプロイ
では、最後に作ったアプリをherokuに公開しよう。
herokuへのログイン等はこちらを確認いただきたい。
まず、herokuはDBとして通常SQLite3をインストールするが、今回はPostgreSQLを使いたいのでこちらからアドオンをインストールする。
あと、herokuからPostgreSQLを読み込むためにmodels.pyのURIの部分を以下のように修正する必要がある。
DB_URI = os.environ.get('DATABASE_URL').replace("://", "ql://", 1) or 'postgresql://postgres:PASSWORD@localhost/flask_sns'
app.config['SQLALCHEMY_DATABASE_URI'] = DB_URI
herokuにアプリをアップロードするには少し作法が必要になる。
まずはクラウソサーバでアプリのファイルが動作するようにWSGIライブラリであるgunicornを読み込もう。
pip install gunicorn
次に、依存パッケージを管理するrequirements.txtを作成しよう。
$ pip freeze > requirements.txt
私の場合は以下のようになった。
alembic==1.6.5
bcrypt==3.2.0
cffi==1.14.5
click==8.0.1
colorama==0.4.4
dnspython==2.1.0
email-validator==1.1.3
Flask==2.0.1
Flask-Bcrypt==0.7.1
Flask-Login==0.5.0
Flask-Migrate==3.0.1
Flask-SQLAlchemy==2.5.1
Flask-WTF==0.15.1
greenlet==1.1.0
gunicorn==20.1.0
idna==3.2
itsdangerous==2.0.1
Jinja2==3.0.1
Mako==1.1.4
MarkupSafe==2.0.1
psycopg2==2.9.1
pycparser==2.20
python-dateutil==2.8.1
python-editor==1.0.4
six==1.16.0
SQLAlchemy==1.4.17
Werkzeug==2.0.1
WTForms==2.3.3
次に、heroku用のProcfileを作成する。Pは絶対大文字なので注意だ。
web: gunicorn setup:app
あとはgitをadd, commitしてherokuにloginしてappをcreateしてherokuにpushしよう!!!(呪文)
$ git add .
$ git commit -m "first heroku"
$ heroku login
$ heroku create flask-sns
$ git push heroku master
あとは、heroku上でPostgreSQLを初期化する。
heroku run python
>>> from flaskr.views import app
>>> from flaskr.models import db
>>> db.create_all()
>>> exit()
あとは実際にWebページを確認すれば終了だ!
おわりに
長々とここまでお読み頂いた方は本当にありがとうございます。
全てのコードはここから
作成したアプリはこちら。
テストユーザとして「白金御行」と「三宮かぐや」を用意してるのでお暇は方は連絡して頂いても良いと思う。
しかし、まことに残念なことながらherokuはFreeプランなので画像ファイルがherokuサーバが落ちると消えてしまうのだ……
herokuのFreeプランは30分以上アクセスがないと一度落ちるので、まあ画像を保持することは難しい笑
そんなこともあって、次回はインフラにAWSやDockerを起用してみたいと思う。