Python / Flaskでしりとり機能を持たせた「しりとり掲示板」を作ってみた

# 対象
- PythonとFlaskの基礎はなんとなく学習したから、何か成果物を作ってみたい
- ただの掲示板ではなく、何か機能を付け加えた掲示板を作ってみたい

# 実装環境
- Windows 10 Home 64bit
- Python 3.9.6

# ライブラリ
- Flask==2.0.2
- Flask-SQLAlchemy==2.5.1
- Jinja2==3.0.3
- SQLAlchemy==1.4.35
- sqlite3

# ディレクトリ構成
flask_bbs
┝ app.py
┝ templates
│ ┝ index.html
│ ┝ layout.html
│ ┝ result.html
│ ┝ delete.html
│ ┝ error.html
# app.pyの作成
## 必要なライブラリのインポート
```python:app.py
import re
from flask import Flask, request, render_template
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import validates

app = Flask(__name__)

SQLALCHEMY_TRACK_MODIFICATIONS = False
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

# SQLAlchemyでデータベースに接続する
db_uri = 'sqlite:///test.db'
app.config['SQLALCHEMY_DATABASE_URI'] = db_uri
db = SQLAlchemy(app)
```
しりとり機能を持たせるために、正規表現ライブラリreをインポートします。
また、validationが必要になるので、sqlalchemy.ormからvalidatesを利用しましょう。
## テーブルの作成
```python:app.py
class Comment(db.Model):
    """[テーブルの定義を行うクラス]
    Arguments:
        db {[Class]} -- [ライブラリで用意されているクラス]
    """
    __tablename__ = 'Comment'

    id_ = db.Column(db.Integer, primary_key=True, autoincrement=True)
    pub_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
    name = db.Column(db.Text())
    comment = db.Column(db.Text())

    def __init__(self, pub_date, name, comment):
        """[テーブルの各カラムを定義する]
        [Argument]
            id_ -- 投稿番号(プライマリキーなので、自動で挿入される)
            pub_date -- 投稿日時
            name -- 投稿者名
            comment -- 投稿内容
        """

        self.pub_date = pub_date
        self.name = name
        self.comment = comment

    @validates('name')
    def validate_name(self, key, name):
        if len(name) <= 1:
            raise ValueError("名前を2文字以上で入力してください")
        
        name_start = re.compile(r'^[a-zA-Z].*').search(name)
        name_end = re.compile(r'.*[a-zA-Z]$').search(name)
        if name_start == None or name_end == None:
            raise ValueError("名前の先頭と末尾は半角アルファベットで入力してください")
        
        last_name = Comment.query.order_by(Comment.id_.desc()).first()
        if last_name != None:
            if str(name[0]) != str(last_name.name)[-1]:
                raise ValueError("前の投稿の名前の末尾の文字=名前の先頭文字になるよう入力してください") 
        return name


    @validates('comment')
    def validate_comment(self, key, comment):
        if len(comment) <= 1:
            raise ValueError("投稿内容を2文字以上で入力してください")
        comment_start = re.compile(r'^[あ-わ].*').search(comment)
        comment_end = re.compile(r'.*[あ-わ]$').search(comment)
        if comment_start == None or comment_end == None:
            raise ValueError("投稿内容の先頭と末尾は、「ん」と小文字以外のひらがなで入力してください")

        last_comment = Comment.query.order_by(Comment.id_.desc()).first()
        if last_comment != None:
            if str(comment[0]) != str(last_comment.comment)[-1]:
                raise ValueError("前の投稿内容の末尾の文字=投稿内容の先頭文字になるよう入力してください") 

        return comment


try:
    db.create_all()
except Exception as e:
    print(e.args)
    pass
```
名前は2文字以上の半角アルファベット、投稿内容は2文字以上の全角ひらがなという制限をつけました（正確には先頭と末尾のみ制限をつけています）。
また、前回の投稿を参照するために、降順に並べたときの最初の一行を
Comment.query.order_by(Comment.id_.desc()).first()で取り出しています。
そこにif last_name(last_comment) != None:の条件をつけることで、最初の投稿にはしりとりの制限をつけないようにしています。
## ルーティングの設定
```python:app.py
@app.route("/")
def index():
    # テーブルから投稿データをSELECT文で引っ張ってくる
    text = Comment.query.all()
    return render_template("index.html", lines=text)


@app.route("/result", methods=["POST"])
def result():
    # 現在時刻　投稿者名　投稿内容を取得
    date = datetime.now()
    name = request.form["name"]
    comment = request.form["comment_data"]
    # テーブルに格納するデータを定義する
    comment_data = Comment(pub_date=date, name=name, comment=comment)
    # テーブルにINSERTする
    db.session.add(comment_data)
    # テーブルへの変更内容を保存
    db.session.commit()
    return render_template("result.html", comment=comment, name=name, now=date)

@app.route('/delete', methods=["POST"])
def all_delete():
    DleteDataset = db.session.query(Comment).all()
    for DleteData in DleteDataset:
        db.session.delete(DleteData)
        db.session.commit()

    date = datetime.now()
    comment_ = 'しりとり'
    name_ = 'start'
    start_data = Comment(pub_date=date, name=name_, comment=comment_)
    db.session.add(start_data)
    db.session.commit()

    return render_template('delete.html')

@app.errorhandler(500)
def valueerror(error):
    return render_template('error.html'), 500

if __name__ == "__main__":
    # app.run(debug=True)にpython app.pyではなく、flask run --debugger --reload で起動すると、自動リロードが走る
    app.run(debug=True)
```
4つのhtmlファイルを用意し、ルートを振り分けています。
ValueErrorはerrorhandlerを使い、error.htmlを表示するよう設定しています。
deleteを実行すると、名前がstart、投稿内容がしりとりの投稿が自動で生成されるようにしています。
このapp.pyを実行する際は、python app.pyではなく flask run --debugger --reloadで起動すると、ファイルの編集等がしやすいと思います。
# htmlファイルの用意
```index.html
{% extends 'layout.html' %}
{% block content %}
<h1>しりとり掲示板へようこそ！</h1>
    <form action="/result" method="post">
        <input type="hidden" name="comment_box">
        <label for="comment_">投稿</label>
        <textarea name="comment_data" rows="6" cols="100"></textarea>
        <p></p>
        <label for="name">名前</label>
        <input type="text" name="name">
        <button type="submit">送信する</button>
    </form>
    <h2>投稿一覧</h2>
    <form method="post" action="">
        <table class="table">
            <thead class="thead-dark">
                <tr>
                    <th>ID</th>
                    <th>日付</th>
                    <th>名前</th>
                    <th>投稿内容</th>
                    </tr>
                </thead>
            <tbody>
                {% for th in lines | reverse %}
                <tr>
                    <td>{{ th.id_ }}</td>
                    <td>{{ th.pub_date }}</td>
                    <td>{{ th.name }}</td>
                    <td>{{ th.comment }}</td>
                    </tr>
                {% endfor %}
                </tbody>
            </table>
        </form>
    <p></p>
    <form action="/delete" method="post">
        <input type="hidden" name="comment_box">
        <label for="comment_">データ削除ボタン</label>
        <p></p>
        <button type="submit">データを全て削除する</button>
    </form>

{% endblock %}
```
```layout.html
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <title>しりとり掲示板</title>

        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">

        <!-- <style>body {padding: 10px;}</style> -->
    </head>
    <body>
        {% block content %}
        {% endblock %}
        <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.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
        </body>
</html>
```
```result.html
{% extends "layout.html" %}
{% block content %}
    <h1>コメントを書き込みました</h1>
    <br>
    <form action="/" method="get">
        <button type="submit">戻る</button>
    </form>
{% endblock %}
```
```delete.html
{% extends "layout.html" %}
{% block content %}
    <h1>コメントを全て削除しました</h1>
    <br>
    <form action="/" method="get">
        <button type="submit">戻る</button>
    </form>
{% endblock %}
```
```error.html
{% extends "layout.html" %}
{% block content %}
    <h1>入力エラー</h1>
    <h2>入力ルールをもう一度ご確認ください</h2>
    <br>
    <form action="/" method="get">
        <button type="submit">戻る</button>
    </form>
{% endblock %}
```
実際に作成するときは、cp templates/result.html templates/delete.html などのコマンドを用いて、コピーして作成すると効率が上がります。
# 実際に遊んでみた
![しりとり掲示板new.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2381497/23b77b82-276a-f336-a4f0-5b8be685c5bc.gif)
# 感想
しりとり機能を付け加えるのは、そこまで難しくないことがわかりました。
validatesを使いこなせば、他にも色々な制限を設けることが簡単にできます。
ぜひvalidationで遊んでみてください。