【WEBアプリ開発】書籍のレビューアプリ

## はじめに
私は小さい時から読書が好きで、書店に通うのが好きです。
Pythonの勉強を進めている中でも多くの書籍を読んでいますが、この本のここの部分はすごく参考になったし、わかりやすかったから、覚えておきたい！というような体験があったので、実際に自分が使えるようにアプリを作成してみます。
今回の作成にあたり、python周りを特に詳細に書き出し、備忘録として記載します。

## 要件定義
**アプリ名**　Book-Port
自分の読んだ本たちが集まる場（＝港）として、たまったレビューが宝物になるように。
**フロントエンド**　vue.js、javascript
**サーバーサイド**　Python、flask
**DB** XAMPP(MariaDB)
**画面構成**
１　ホーム画面
２　レビュー投稿画面（本のタイトル、著者名、評価（1~5）、コメント）
３　レビュー投稿一覧

## DBについて
:black_nib:　XAMPP：
X＝クロスプラットフォーム：どのOSのどの使用にも対応可能
A＝Apache（アパッチ）：世界50%以上のシェアがあり、高性能なWEBサーバーソフトウェア（≒ex　IIS、nginx）、大規模開発に向いている
M＝MariaDB：MySQL～派生している
P＝PHP
P＝Perl
導入メリット：クラウドサーバーにもインストールして使用ができ、簡単に実行環境を整えることができます。
※導入の際、内容ソフトウェアが一部最新でない場合があります。

:black_nib:　MySQL
1995年～
特徴：リレーショナルデータベース、クライアントサーバーモデル
SQL＝Structured Query Language
SQLは言語だが、MySQLはDBなので注意



## コード一覧
### app.py
```python:app.py
# モジュールのインポート（OS、flask、DB操作、CORS設定）
import os
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
from flask_cors import CORS

#アプリの核作成
app = Flask(__name__)

# セキュリティ設定
CORS(app, resources={r"/*": {"origins": "http://localhost:5173"}})

# DB設定
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:@localhost:3306/review_db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)

# アプリコンテキストの設定
with app.app_context():
# データベーステーブルの作成
    db.create_all()

# テーブル作成
class Reviews(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(100), nullable=False)
    author = db.Column(db.String(100), nullable=False)
    rating = db.Column(db.Integer, nullable=False)
    comment = db.Column(db.Text, nullable=False)

@app.route('/reviews', methods=['POST'])
def create_review():
    data = request.get_json()
    new_review = Reviews(
        title=data['title'],
        author=data['author'],
        rating=data['rating'],
        comment=data['comment']
    )
    db.session.add(new_review)
    db.session.commit()
    return jsonify({"message": "レビュー投稿成功！"}), 201

@app.route('/reviews', methods=['GET'])
def get_reviews():
    reviews = Reviews.query.all()
    reviews_list = [
        {'title': review.title, 'author': review.author, 'rating': review.rating, 'comment': review.comment}
        for review in reviews
    ]
    return jsonify(reviews_list), 200

if __name__ == '__main__':
    app.run(debug=True, host="0.0.0.0", port=5000)

```
**import os**　
＋OSモジュール
WindowsやmacOS、Linuxなど異なるOS間でも同じ動作が可能。
（モジュール＝.pyで書かれたファイル）

import>> モジュールの全体を利用
from>>>  モジュール内の特定の関数やクラスのみを利用

**Flask__拡張機能**
:point_up_tone1:flask_sqlalchemy 
＋Python上でのデータベース操作
ORM (Object Relational Mapper)ツール。

ORM
＝プログラミング言語を使用し、DB内のデータを「オブジェクト」として扱える仕組み。
通常、DB操作にはSQL文を書く必要があるが、ORMを使うとSQLを書かずに、プログラミング言語のコードだけでデータの追加、更新、削除など可能。

:point_up_tone1:flask_cors 
＋FlaskアプリのCORS（Cross-Origin Resource Sharing）の設定・管理frontend⇔backendが異なるオリジンにある場合、この拡張機能が必要になる。

**オリジン**
スキーム（プロトコル）、ホスト、ポートから構成。URLの一部分。

オリジンが同一である条件：
・URLのホスト(FQDN; Fully Qualified Domain Name)が一致している
・スキーム(プロトコル)が一致している
・ポート番号が一致している

FQDN⇔IPアドレス
FQDNは文字列表記（IPアドレスの代わり、人間から見て理解しやすい）、IPアドレスは数字表記

＋request
クライアントから送信されたHTTPリクエストデータ（フォームデータ、JSONなど）にアクセスするためのオブジェクト。
＋jsonify
Pythonの辞書やリストをJSON形式のレスポンスに変換するための関数。API応答でよく使用される。

```terminal:text
CORS(app, resources={r"/*": {"origins": "http://localhost:5173"}})
```
今回の場合だと、下記の通り。
許可する下位アドレス：/*
許可するポート番号：5173
指定することで、特定ドメインやポート番号のみを許可し、セキュリティを上げる。
```terminal:text
with app.app_context():
```
手動でアプリケーションコンテキストを作成

コンテキスト：特定の操作や処理を実行するために必要な情報や環境を提供する枠組み、実行中のコードがアクセスするための設定や状態

・アプリケーションコンテキスト
Flaskアプリケーションの設定、データベース接続、およびその他のグローバルリソースにアクセスするための環境を提供
・リクエストコンテキスト
特定のクライアントリクエストに関連する情報（リクエストデータ、セッション情報、ユーザー情報など）にアクセスするための環境を提供
### requirement.txt
```terminal:requirement.txt
Flask==2.3.3
Flask-SQLAlchemy==3.0.5
PyMySQL==1.1.0
Flask-Cors==4.0.0
```

Pythonの開発環境で使われている、パッケージの名前とバージョンの一覧が記載されている。
チームでの開発等、開発環境を整える際に便利。
記載内容：パッケージ名、バージョン指定、依存関係
依存環境使用時は必ずアクティベート後に実行

**使用コード**
```powershell
pip freeze > requirements.txt
pip install -r requirements.txt
```

### vueでのデザインコード
```vuejs:app.vue
<template>
  <div id="app-container">
    <header class="app-header">
      <nav>
        <router-link to="/" class="nav-link">レビュー一覧へ</router-link>
        <router-link to="/new" class="nav-link">レビュー投稿ページへ</router-link>
      </nav>
    </header>
    <main class="app-main">
      <router-view></router-view> </main>
  </div>
</template>

<script>
export default {
  name: 'App',
};
</script>

<style>
/* アプリケーション全体に適用されるスタイル */
body {
  margin: 0;
  font-family: Arial, sans-serif;
  /* background.jpg.png を背景として設定 */
  background-image: url('./assets/background.jpg.png'); /* assetsディレクトリ内の画像を読み込む */
  background-size: cover; /* 画面全体に画像を広げる */
  background-position: center; /* 画像を中央に配置 */
  background-attachment: fixed; /* スクロールしても背景を固定 */
  min-height: 100vh; /* 画面の高さ全体に背景を適用 */
  display: flex; /* flexboxでコンテンツを中央寄せしやすくする */
  flex-direction: column;
}

#app-container {
  flex-grow: 1; /* コンテナが利用可能なスペースを全て占める */
  display: flex;
  flex-direction: column;
  align-items: center; /* 水平方向中央 */
  justify-content: center; /* 垂直方向中央 */
  padding: 20px;
  box-sizing: border-box; /* パディングを幅に含める */
}

.app-header {
  width: 100%;
  max-width: 800px; /* コンテンツの最大幅を制限 */
  text-align: center;
  margin-bottom: 30px;
  padding: 15px;
  background-color: rgba(255, 255, 255, 0.6); /* ヘッダーの背景を半透明に */
  border-radius: 8px;
  box-shadow: 0 2px 5px rgba(0,0,0,0.2);
  backdrop-filter: blur(5px); /* 背景のぼかし効果 */
  -webkit-backdrop-filter: blur(5px); /* Safari対応 */
}

.app-header nav {
  display: flex;
  justify-content: center;
  gap: 20px; /* リンク間のスペース */
}

.nav-link {
  text-decoration: none;
  color: #3e271a; /* 地図の色合いに合わせた文字色 */
  font-weight: bold;
  font-size: 1.2em;
  padding: 8px 15px;
  border-radius: 5px;
  transition: background-color 0.3s ease, color 0.3s ease;
  font-family: 'Times New Roman', serif;
}

.nav-link:hover {
  background-color: #e0b35a; /* ホバー時の背景色 */
  color: white;
}

.app-main {
  width: 100%;
  max-width: 800px; /* コンテンツの最大幅を制限 */
  background-color: rgba(255, 255, 255, 0.5); /* メインコンテンツエリアの背景を半透明に */
  border-radius: 10px;
  box-shadow: 0 4px 15px rgba(0,0,0,0.3);
  padding: 30px;
  box-sizing: border-box;
}

/* その他の共通スタイル */
</style>
```
```vuejs_ReviewList.vue
<template>
<div class="review-list-container">
<h1>投稿されたレビュー一覧</h1>
<p v-if="reviews.length === 0" class="no-reviews-message">まだレビューがありません。</p>
<div v-else class="reviews-grid">
    <div v-for="review in reviews" :key="review.id" class="review-card">
    <h3>{{ review.title }}</h3>
    <p><strong>著者:</strong> {{ review.author }}</p>
    <p><strong>評価:</strong> {{ review.rating }} / 5</p>
    <p class="comment">{{ review.comment }}</p>
    </div>
</div>
</div>
</template>

<script>
import axios from 'axios'; // axiosをインポート

export default {
name: 'ReviewList',
data() {
return {
    reviews: []
};
},
created() {
this.fetchReviews();
},
methods: {
fetchReviews() {
    axios.get('/reviews') // ★ baseURLが設定されているため相対パスでOK
    .then(response => {
        this.reviews = response.data;
    })
    .catch(error => {
        console.error("レビューの取得失敗：", error);
        alert("レビューの取得に失敗しました。"); // ユーザーへのフィードバック
    });
}
}
};
</script>

<style scoped>
/* ここにReviewList.vue固有のスタイルを記述します */
.review-list-container {
padding: 20px;
text-align: center;
color: #5a3d2b; /* 地図の色合いに合わせた文字色 */
}

h1 {
font-family: 'Times New Roman', serif; /* 古地図に合うフォント */
font-size: 2.5em;
margin-bottom: 30px;
text-shadow: 1px 1px 2px rgba(0,0,0,0.2);
}

.no-reviews-message {
font-style: italic;
color: #7b624f;
margin-top: 50px;
}

.reviews-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); /* レスポンシブなグリッド */
gap: 20px;
max-width: 1200px;
margin: 0 auto;
}

.review-card {
background-color: rgba(255, 255, 255, 0.7); /* 半透明の白背景 */
border: 2px solid #a08c75; /* 地図の枠のようなボーダー */
border-radius: 8px;
padding: 20px;
box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.3); /* 立体感 */
text-align: left;
transition: transform 0.2s ease-in-out;
font-family: 'Georgia', serif; /* 古い紙のようなフォント */
}

.review-card:hover {
transform: translateY(-5px); /* ホバー時の浮き上がり効果 */
}

.review-card h3 {
color: #3e271a;
margin-bottom: 10px;
font-size: 1.5em;
}

.review-card strong {
color: #6b4d3f;
}

.review-card .comment {
margin-top: 15px;
line-height: 1.6;
font-size: 0.95em;
color: #4e362a;
}
</style>
```
```vuejs:ReviewForm.vue
<template>
<div class="review-form-container">
<h1>レビュー投稿</h1>
<form @submit.prevent="submitReview" class="review-form">
    <div class="form-group">
    <label for="title">本のタイトル:</label>
    <input type="text" id="title" v-model="title" required>
    </div>
    <div class="form-group">
    <label for="author">著者名:</label>
    <input type="text" id="author" v-model="author" required>
    </div>
    <div class="form-group">
    <label for="rating">評価 (1-5):</label>
    <input type="number" id="rating" v-model.number="rating" min="1" max="5" required>
    </div>
    <div class="form-group">
    <label for="comment">コメント:</label>
    <textarea id="comment" v-model="comment" rows="5" required></textarea>
    </div>
    <button type="submit" class="submit-button">投稿する</button>
</form>
<p v-if="message" :class="messageType">{{ message }}</p>
</div>
</template>

<script>
import axios from 'axios'; // axiosをインポート

export default {
name: 'ReviewForm',
data() {
return {
    title: '',
    author: '',
    rating: 1, // 初期値
    comment: '',
    message: '',
    messageType: ''
};
},
methods: {
validateForm() {
    if (!this.title || !this.author || !this.comment) {
    this.message = "全ての項目を入力してください。";
    this.messageType = "error-message";
    return false;
    }
    if (this.rating < 1 || this.rating > 5) {
    this.message = "評価は1から5の間で入力してください。";
    this.messageType = "error-message";
    return false;
    }
    this.message = ""; // エラーメッセージをクリア
    return true;
},
submitReview() {
    if (!this.validateForm()) {
    return;
    }

    axios.post('/reviews', { // ★ baseURLが設定されているため相対パスでOK
    title: this.title,
    author: this.author,
    rating: this.rating,
    comment: this.comment
    }, {
    headers: {
        'Content-Type': 'application/json'
    }
    })
    .then((response) => {
    this.message = "レビュー投稿成功！";
    this.messageType = "success-message";
    this.resetForm(); // フォームをリセット
    console.log(response.data);
    })
    .catch((error) => {
    this.message = "データ送信に失敗しました。もう一度お試しください。";
    this.messageType = "error-message";
    console.error("レビュー投稿失敗：", error);
    });
},
resetForm() {
    this.title = '';
    this.author = '';
    this.rating = 1;
    this.comment = '';
}
}
};
</script>

<style scoped>
/* ここにReviewForm.vue固有のスタイルを記述します */
.review-form-container {
padding: 20px;
text-align: center;
color: #5a3d2b;
max-width: 600px;
margin: 0 auto; /* 中央寄せ */
}

h1 {
font-family: 'Times New Roman', serif;
font-size: 2.5em;
margin-bottom: 30px;
text-shadow: 1px 1px 2px rgba(0,0,0,0.2);
}

.review-form {
background-color: rgba(255, 255, 255, 0.7);
border: 2px solid #a08c75;
border-radius: 8px;
padding: 30px;
box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.3);
text-align: left;
}

.form-group {
margin-bottom: 20px;
}

.form-group label {
display: block;
margin-bottom: 8px;
font-weight: bold;
color: #3e271a;
font-family: 'Georgia', serif;
}

.form-group input[type="text"],
.form-group input[type="number"],
.form-group textarea {
width: calc(100% - 20px); /* パディング分を考慮 */
padding: 10px;
border: 1px solid #c0b09f;
border-radius: 4px;
background-color: #fcf8f0; /* 少しクリーム色 */
font-family: 'Georgia', serif;
font-size: 1em;
color: #333;
}

.form-group textarea {
resize: vertical; /* 縦方向のみリサイズ可能に */
}

.submit-button {
background-color: #e0b35a; /* 金色っぽいボタン */
color: white;
padding: 12px 25px;
border: none;
border-radius: 5px;
font-size: 1.1em;
cursor: pointer;
transition: background-color 0.3s ease;
box-shadow: 2px 2px 5px rgba(0,0,0,0.2);
font-family: 'Times New Roman', serif;
font-weight: bold;
}

.submit-button:hover {
background-color: #d09c40;
}

.success-message {
color: green;
font-weight: bold;
margin-top: 20px;
}

.error-message {
color: red;
font-weight: bold;
margin-top: 20px;
}
</style>
```

### デザインはこんな感じ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4101204/e253a135-2257-43dc-8516-f5f7feb975aa.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4101204/a7225195-5f47-4c11-a5ac-64ffabee6911.png)


### index.js
```javascript:index.js
import { createRouter, createWebHistory } from 'vue-router';
import ReviewList from '../views/ReviewList.vue'; // レビュー一覧ページのコンポーネント
import ReviewForm from '../views/ReviewForm.vue'; // レビュー投稿ページのコンポーネント

const routes = [
{
    path: '/',
    name: 'ReviewList',
    component: ReviewList
},
{
    path: '/new', // レビュー投稿ページのパス
    name: 'ReviewForm',
    component: ReviewForm
}
];

const router = createRouter({
history: createWebHistory(),
routes
});

export default router;
```
### main.js
```javascript:main.js
import { createApp } from 'vue';
import App from './App.vue';
import axios from 'axios';
import router from './router'; // Vue Routerをインポート

// バックエンドのURLを直接指定
axios.defaults.baseURL = 'http://localhost:5000'; // Flaskアプリのポート

const app = createApp(App)
app.config.globalProperties.$axios = axios // Vueインスタンス全体でAxiosを利用できるようにする
app.use(router) // routerを使う
app.mount('#app');
```
**axios**
JavaScriptで使えるHTTPクライアントライブラリ
APIからデータを取得する際に使用。
FetchAPIとの対比でよく使用される。

## おわりに
現状、このようなかたちで進めています。
テストでは、レビュー投稿～投稿確認まで問題なく確認ができました。
今後の課題としては、フロントエンド側のボタン配置などを少し整えたり、誤った投稿の削除ができるようにする、ログイン機能の実装等ができればと思っています。
完成できてよかった！

参考：
https://note.com/junyaaa/n/n9eab953c73c9
https://dtnavi.tcdigital.jp/cat_system/language_054/
https://zenn.dev/mo_ri_regen/articles/same-origin-policy
https://zenn.dev/chae_rryontop/articles/2f4e0e713bbb1a
https://zenn.dev/myuki/books/02fe236c7bc377/viewer/d2422a
https://envader.plus/course/8/scenario/1073
https://apidog.com/jp/blog/axios-introduction/
https://rakuraku-engineer.com/posts/axios/
https://envader.plus/article/478