How to Make A Todo App with Flask + Hyperapp

anharu2394

anharu2394

Posted on May 6, 2018

How to Make A Todo App with Flask + Hyperapp

こんにちは、あんはるです。

Flask + HyperappでTodoアプリを作りました

Flaskとは?

Python製の軽量Webアプリケーションフレームワーク。RubyでいうSinatraみたいなもの。

Hyperappとは?

1 KBという超軽量のフロントエンドのフレームワーク。
QiitaのフロントエンドにHyperappが採用されたことから話題になる。

なぜ、Flask + Hyperappか。

Flaskは機械学習モデルをWebAPIにするのによく使われています。
今、機械学習もやっていてプロトタイプとして機械学習モデルをWebAPIにしてみようと思っているので、
Flaskを使う練習としてFlaskを使おうと思いました。

Hyperappは、HyperappでWebAPIからデータを取得したりする処理をしてみたかったので、Hyperappにしました。(普通にHyperappが好き)

こんな感じのTodoアプリを作った

todo_gif.gif

データベースと繋がっているので、ローディングしても、Todoのデータ、完了か未完了かは保持されます。
todo_gif_lo.gif

Github:
https://github.com/anharu2394/flask-hyperapp-todo_app

TodoアプリAPIの実装(バックエンド)

SQLAlchemyというORMでモデルを作る

from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy(api)
class Todo(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    value = db.Column(db.String(20), unique=True)
    completed = db.Column(db.Boolean)

    def __init__(self,value,completed):
        self.value = value
        self.completed = completed

    def __repr__(self):
        return '<Todo ' + str(self.id) + ':' + self.value + '>'
Enter fullscreen mode Exit fullscreen mode

APIはFlaskで。

import json
from flask import Flask, jsonify, request, url_for, abort, Response,render_template
from db import db


api = Flask(__name__)
api.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/test.db'
def createTodo(value):
    create_todo = Todo(value,False)
    db.session.add(create_todo) 
    try: 
        db.session.commit()
        return create_todo
    except:  
        print("this todo is already registered todo.")
        return {"error": "this todo is already registered todo."}

def deleteTodo(todo_id):
    try:
        todo = db.session.query(Todo).filter_by(id=todo_id).first()
        db.session.delete(todo)
        db.session.commit()
        return todo
    except:
        db.session.rollback()
        print("failed to delete this todo.")
        return {"error": "failed to delete this todo."}

def updateTodo(todo_id,completed):
    try:
        todo = db.session.query(Todo).filter_by(id=todo_id).first()
        todo.completed = completed
        db.session.add(todo)
        db.session.commit()
        return todo
    except:
        db.session.rollback()
        print("failed to update this todo.")
        return {"error": "failed to update this todo."}

def getTodo():
    return Todo.query.all()  


@api.route('/')
def index():
    return render_template("index.html")

@api.route('/api')
def api_index():
            return jsonify({'message': "This is the Todo api by Anharu."})

@api.route('/api/todos', methods=['GET'])
def todos():
    todos = []
    for todo in getTodo():
        todo = {"id": todo.id, "value": todo.value,"completed": todo.completed}
        todos.append(todo)

    return jsonify({"todos":todos})

@api.route('/api/todos', methods=['POST'])
def create():
    value = request.form["value"]
    create_todo = createTodo(value)
    if isinstance(create_todo,dict):
        return jsonify({"error": create_todo["error"]})
    else:
        return jsonify({"created_todo": create_todo.value})

@api.route('/api/todos/<int:todo_id>',methods=['PUT'])
def update_completed(todo_id):
    if request.form["completed"] == "true":
        completed = True
    else:
        completed = False
    print(completed)
    update_todo = updateTodo(todo_id,completed)
    if isinstance(update_todo,dict):
        return jsonify({"error": update_todo["error"]})
    else:
        return jsonify({"updated_todo": update_todo.value})

@api.route('/api/todos/<int:todo_id>', methods=['DELETE'])
def delete(todo_id):
    delete_todo = deleteTodo(todo_id)
    if isinstance(delete_todo,dict):
        return jsonify({"error": delete_todo["error"]})
    else:
        return jsonify({"deleted_todo": delete_todo.value})

@api.errorhandler(404)
def not_found(error):
    return jsonify({'error': 'Not found'})
if __name__ == '__main__':
    api.run(host='0.0.0.0', port=3333)
Enter fullscreen mode Exit fullscreen mode

サーバー起動

python main.py
Enter fullscreen mode Exit fullscreen mode

getTodo(全Todo取得)、createTodo(Todoを追加する)、updateTodo(Todoを編集する)、deleteTodo(Todoを消す)という4つの関数を作り、
ルーティングを指定して、各関数を実行し、それの結果をjsonで返すように実装します。
APIはこのような感じです。

path HTTP method 目的
/api GET なし
/api/todos GET 全Todoの一覧を返す
/api/todos POST Todoを追加する
/api/todos/:id PUT Todoを編集する
/api/todos/:id DELETE Todoを消す

/api/todosのレスポンス例

{
  "todos": [
    {
      "completed": false,
      "id": 1,
      "value": "todo1"
    },
    {
      "completed": false,
      "id": 2,
      "value": "todo2"
    },
    {
      "completed": false,
      "id": 3,
      "value": "todo3"
    },
    {
      "completed": false,
      "id": 4,
      "value": "todo4"
    },
    {
      "completed": false,
      "id": 5,
      "value": "todo5"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

フロントエンドの実装

ディレクトリ構成

todo_app
   ├-- main.py
   ├-- index.js
   ├-- index.css
   ├── node_modules
   ├── static
   ├── templates
   |      └── index.html
   ├── package.json
   ├── webpack.config.js
   └── yarn.lock
Enter fullscreen mode Exit fullscreen mode

必要なパッケージの追加

yarn init -y
Enter fullscreen mode Exit fullscreen mode
yarn add hyperapp
Enter fullscreen mode Exit fullscreen mode
yarn add webpack webpack-cli css-loader style-loader babel-loader babel-core babel-preset-env babel-preset-react babel-preset-es2015 babel-plugin-transform-react-jsx -D
Enter fullscreen mode Exit fullscreen mode

babelの設定

{
  "presets": ["es2015"],
  "plugins": [
    [
      "transform-react-jsx",
      {
        "pragma": "h"
      }
    ]
  ]
}
Enter fullscreen mode Exit fullscreen mode

webpackの設定

module.exports = {
  mode: 'development',
  entry: "./index.js",
  output: {
    filename: "bundle.js",
    path: __dirname + "/static"     
  },
  module: {
      rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: [
                ['env', {'modules': false}]
              ]
            }
          }
        ]
      },
      {
        test: /\.css$/,
        loaders: ['style-loader', 'css-loader?modules'],
      }
    ]
  }

}
Enter fullscreen mode Exit fullscreen mode

これで環境は整った。

メインのフロントを書いているindex.js

コードがごちゃごちゃしててすみません、、、

import { h, app } from "hyperapp"
import axios from "axios"
import styles from "./index.css"

const state = {
    todoValue: "",
    todos: [],
    is_got: false
}

const actions = {
    getTodo:  () => (state,actions) => {
        axios.get("/api/todos").then(res => {
            console.log(res.data)
            actions.setTodo(res.data.todos)
        })
    },
    setTodo: data => state => ({todos: data}),
    addTodo: todoValue => (state,actions) => {
        console.log(todoValue)
        var params = new URLSearchParams()
        params.append("value",todoValue)
        axios.post("/api/todos",params).then(resp => {
            console.log(resp.data)
         }).catch(error=>{
            console.log(error)
        }
        )
        actions.todoEnd()
        actions.getTodo()
    },
    onInput: value => state => {
        state.todoValue = value
    },
    deleteTodo: id => (state,actions) => {
        console.log(id)
        axios.delete("/api/todos/" + id).then(resp => {
            console.log(resp.data)
        }).catch(error => {
            console.log(error)
        })
        actions.getTodo()
    },
    checkTodo: e => {
        console.log(e)
        console.log(e.path[1].id)
        const id = e.path[1].id
        console.log("/api/todos/" + id)
        var params = new URLSearchParams()
        params.append("completed",e.target.checked)
        axios.put("/api/todos/" + id,params).then(resp => {
            console.log(resp.data)
        }).catch(error => {
            console.log(error)
        })

        if (e.target.checked == true){
            document.getElementById(id).style.opacity ="0.5"
            document.getElementById("button_" + id).style.display = "inline"
        }
        else{
            document.getElementById(id).style.opacity ="1"
            document.getElementById("button_" + id).style.display = "none" 
        }
    },
    todoEnd: () => state => ({todoValue:""})
}

const Todos = () => (state, actions) => (
    <div class={styles.todos}>
        <h1>Todoリスト</h1>
        <h2>Todoを追加</h2>
        <input type="text" value={state.todoValue} oninput={e => actions.onInput(e.target.value)} onkeydown={e => e.keyCode === 13 ? actions.addTodo(e.target.value) : '' }  />
        <p>{state.todos.length}個のTodo</p>
        <ul>
        {
         state.todos.map((todo) => {
            if (todo.completed){
                return (
                        <li class={styles.checked} id={ todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)} />{todo.value}<button class={styles.checked}id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
            else{
                return (
                        <li id={todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)}/>{todo.value}<button id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
        })
        }
        </ul>
    </div>
)
const view = (state, actions) => {
    if (state.is_got == false){
        actions.getTodo()
        actions.todoGot()
    }
    return (<Todos />) 
}

app(state, actions, view, document.body)
Enter fullscreen mode Exit fullscreen mode

CSS

body {
}
.todos {
    margin:auto;
}
ul{
  padding: 0;
  position: relative;
  width: 50%;
}

ul li {
  color: black;
  border-left: solid 8px orange;
  background: whitesmoke;
  margin-bottom: 5px;
  line-height: 1.5;
  border-radius: 0 15px 15px 0;
  padding: 0.5em;
  list-style-type: none!important;
}
li.checked {
    opacity: 0.5;
}
button {
    display: none;
}
button.checked {
    display: inline;
}
Enter fullscreen mode Exit fullscreen mode

HTML

<html>
  <head>
    <meta charset="utf-8">
    <title>The Todo App with Flask and Hyperapp</title>
  </head>
  <body>
    <script src="/static/bundle.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

webpackでビルドして、サーバー起動

yarn run webpack; python main.py
Enter fullscreen mode Exit fullscreen mode

 機能の仕組みの解説

Todo一覧を表示する機能

const Todos = () => (state, actions) => (
    <div class={styles.todos}>
        <h1>Todoリスト</h1>
        <h2>Todoを追加</h2>
        <input type="text" value={state.todoValue} oninput={e => actions.onInput(e.target.value)} onkeydown={e => e.keyCode === 13 ? actions.addTodo(e.target.value) : '' }  />
        <p>{state.todos.length}個のTodo</p>
        <ul>
        {
         state.todos.map((todo) => {
            if (todo.completed){
                return (
                        <li class={styles.checked} id={ todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)} />{todo.value}<button class={styles.checked}id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
            else{
                return (
                        <li id={todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)}/>{todo.value}<button id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
        })
        }
        </ul>
    </div>
)

const view = (state, actions) => {
    if (state.is_got == false){
        actions.getTodo()
        actions.todoGot()
    }
    return (<Todos />) 
}
Enter fullscreen mode Exit fullscreen mode
const state = {
    todoValue: "",
    todos: [],
    is_got: false
}
Enter fullscreen mode Exit fullscreen mode
const actions = {
    getTodo:  () => (state,actions) => {
        axios.get("/api/todos").then(res => {
            console.log(res.data)
            actions.setTodo(res.data.todos)
        }).catch(error => {
            console.log(error)
        })
    },
    setTodo: data => state => ({todos: data}),
    todoGot: () => state => ({is_got:true})
}

Enter fullscreen mode Exit fullscreen mode

actions.getTodo()を実行して、state.todosをセットし、その後Todosコンポーネントで表示します。
actions.getTodo()はaxiosでAPIにGETしていますが、fetchでもできます。


view の部分を

if (state.is_got == false){
    actions.getTodo()
    actions.todoGot()
}
Enter fullscreen mode Exit fullscreen mode

こうしてるのは、そのまま、

actions.getTodo()
Enter fullscreen mode Exit fullscreen mode

とすると、Stateが変更されるアクションなので、再レンダリングされ、また、actions.getTodo()が実行され、っと、無限に再レンダリングされてしまうので、is_gotというstateを作って、一回しか実行されないようにします。

Todoを追加する機能

<input type="text" value={state.todoValue} oninput={e => actions.onInput(e.target.value)} onkeydown={e => e.keyCode === 13 ? actions.addTodo(e.target.value) : '' }  />
Enter fullscreen mode Exit fullscreen mode
const state = {
    todoValue: ""
}
Enter fullscreen mode Exit fullscreen mode

oninput={e => actions.onInput(e.target.value)}

で、入力するやいなや、actions.onInputを実行させ、state.todoValueを更新しています。

const actions = {
    onInput: value => state => {
        state.todoValue = value
    }
}
Enter fullscreen mode Exit fullscreen mode

onkeydown={e => e.keyCode === 13 ? actions.addTodo(e.target.value) : '' }

Enterキーを押した時(Keyコードが13)に、actions.addTodo()を実行します。

const actions = {
    getTodo:  () => (state,actions) => {
        axios.get("/api/todos").then(res => {
            console.log(res.data)
            actions.setTodo(res.data.todos)
        })
    },
    addTodo: todoValue => (state,actions) => {
        console.log(todoValue)
        var params = new URLSearchParams()
        params.append("value",todoValue)
        axios.post("/api/todos",params).then(resp => {
            console.log(resp.data)
         }).catch(error=>{
            console.log(error)
        }
        )
        actions.todoEnd()
        actions.getTodo()
    },
    todoEnd: () => state => ({todoValue:""})
}
Enter fullscreen mode Exit fullscreen mode

actions.addTodo()では、

/api/todos
Enter fullscreen mode Exit fullscreen mode

にPOSTし、新しいTodoを作ります。
actions.todoEnd()でstate.todoValueを空白にさせ次のTodoを入力しやすいようにします。
actions.getTodo()を実行させ、追加されたTodoも取得し表示させます。

Todoの完了未完了を設定する機能

<input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)} />
Enter fullscreen mode Exit fullscreen mode

チェックボックスをチェックした時(clickした時、)にactions.checkTodo()を実行します。
eは、elementの略で、その時の要素のオブジェクトを返します。

const actions = {
    checkTodo: e => {
        console.log(e)
        console.log(e.path[1].id)
        const id = e.path[1].id
        console.log("/api/todos/" + id)
        var params = new URLSearchParams()
        params.append("completed",e.target.checked)
        axios.put("/api/todos/" + id,params).then(resp => {
            console.log(resp.data)
        }).catch(error => {
            console.log(error)
        })

        if (e.target.checked == true){
            document.getElementById(id).style.opacity ="0.5"
            document.getElementById("button_" + id).style.display = "inline"
        }
        else{
            document.getElementById(id).style.opacity ="1"
            document.getElementById("button_" + id).style.display = "none" 
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

e.path[1].idから、チェックされたTodoを見つけ、e.target.checkedで、完了か未完了かを、取得し、

/api/todos/1(id)
Enter fullscreen mode Exit fullscreen mode


 
へPUTします。

その後、完了のtodoは濃さをを薄くし消去ボタンを表示させ、未完了のtodoは濃さを正常にして、消去ボタンを見えなくします。

        <ul>
        {
         state.todos.map((todo) => {
            if (todo.completed){
                return (
                        <li class={styles.checked} id={ todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)} />{todo.value}<button class={styles.checked}id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
            else{
                return (
                        <li id={todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)}/>{todo.value}<button id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
        })
        }
        </ul>
Enter fullscreen mode Exit fullscreen mode

ローディングしてもそのままの状態を保持するため、完了か未完了かで条件分岐しています。

Todoを消す機能

<button id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button>
Enter fullscreen mode Exit fullscreen mode

clickした時、 actions.deleteTodo()を実行します。

const actions = {
    getTodo:  () => (state,actions) => {
        axios.get("/api/todos").then(res => {
            console.log(res.data)
            actions.setTodo(res.data.todos)
        })
    },
    deleteTodo: id => (state,actions) => {
        console.log(id)
        axios.delete("/api/todos/" + id).then(resp => {
            console.log(resp.data)
        }).catch(error => {
            console.log(error)
        })
        actions.getTodo()
    }
}
Enter fullscreen mode Exit fullscreen mode

actions.deleteTodo()では、引数のidのTodoを消去するため、

/api/todos
Enter fullscreen mode Exit fullscreen mode

へDELETEします。
そして、actions.getTodo()実行し、Todoの一覧を再取得しています。

ソースコード

Github:
https://github.com/anharu2394/flask-hyperapp-todo_app

感想

自分でAPIを書くこと(Railsだと自動でできたりする)、フロントのフレームワークでAPIを叩くことなかったのでとても楽しかったです。

FlaskではRailsのActiveRecordがない(MVCではない)ので、RailsでWebアプリを作るのとは違った感覚でした。

もちろんRails APIで書いた方が早い
ただ楽しい

Todoアプリのdbはテーブルがひとつしかないので、もう少し複雑なアプリもflask + Hyperappで作ってみたい。

Rails API + Hyperappもやってみたい

今、作りたい機械学習のモデルがあって、それをWebAPI化するのに、この経験を活かせると思います。

 ぜひ、Flask + Hyperappで簡単なWebアプリを作ってみてください!

💖 💪 🙅 🚩
anharu2394
anharu2394

Posted on May 6, 2018

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related