DEV Community

anharu2394
anharu2394

Posted on

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アプリを作ってみてください!

Top comments (0)