想ひ出のへっぽこBlogⅡ from35

~ 自身の備忘録および学習促進のためにブログります。~

想ひ出20: GCP/Flask1.0/ローカルからCloudStorageにアップロードしてみる

f:id:moqrin3:20181201092259p:plain
GCP CloudStorage

おはようございます、moqrinです。

前々回に、GCEにデプロイしたFlaskのチュートリアルをCloudSQLと接続しました。
今度はCloudStorageを使ってローカルからUploadしてみようぜ、というお話です。
なお、基本的に過去の記事からの延長なのでご留意下さい。

やること

1. モデルを修正してマイグレーション
2. CloudStorageを作成してallUsersで閲覧OKにする
3. サービスアカウントキーを作成して手元に保存
4. 環境変数にサービスアカウントの情報を設定する
5. configにプロジェクトIDを設定
6. storage.pyを作成
7. Blueprintを修正
8. 画面フォームを修正
9. 確認

1. モデルを修正してマイグレーション

テーブルに画像保存するURLと画像名のColumnを追加します。

# app/models.py

from app import db, login_manager
from flask_login import UserMixin

class User(UserMixin, db.Model):

    __tablename__ = 'user'

    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(60), nullable=False, unique=True)
    password = db.Column(db.String(128), nullable=False)


@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

class Post(db.Model):

    __tablename__ = 'post'

    id = db.Column(db.Integer, primary_key=True)
    author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    created = db.Column(db.TIMESTAMP, nullable=False)
    title = db.Column(db.String(200), nullable=False)
    body = db.Column(db.String(200), nullable=False)
    """
    追記
    """
    file_url = db.Column(db.TEXT(), nullable=False)
    filename = db.Column(db.String(200), nullable=False)

下記コマンドでマイグレーション対応完了です。
すでにテーブルがある場合はテーブル削除してから実行します。

flask db init
flask db migrate
flask db upgrade

2. CloudStorageを作成してallUsersで閲覧OKにする

Storageを作成して、allUsersで閲覧OKのメンバー追加します。
設定参考

3. サービスアカウントキーを作成して手元に保存

GCP外のアプリケーションからGoogle Cloud Storage API を有効化してアクセスするためには、サービスアカウントキーが必要となります。
Storageの読み書きできる権限で作成して、お手元にダウンロードします。

Cloud Storage Client Libraries

4. 環境変数にサービスアカウントキーを設定する

アクセス許可してもらうためにサービスアカウントキーをOSに設定しておきます。

export GOOGLE_APPLICATION_CREDENTIALS=path/to/ServiceAccountJsonKey

5. configにStorageが作成されたプロジェクトIDを設定

手っ取り早くこんな感じで設定しています。

# instance/config.py
PROJECT_ID = 'moqrin-storage'

6. storage.pyを作成

ほぼチュートリアルそのままで利用しています。

# app/storage.py

from __future__ import absolute_import

import os
os.getenv("GOOGLE_APPLICATION_CREDENTIALS")

import datetime

from google.cloud import storage
import six
from werkzeug import secure_filename
from werkzeug.exceptions import BadRequest

from app import ALLOWED_EXTENSIONS, PROJECT_ID, CLOUD_STORAGE_BUCKET


def _get_storage_client():
    return storage.Client(
        project=PROJECT_ID)

def _check_extension(filename, allowed_extensions):
    if ('.' not in filename or
            filename.split('.').pop().lower() not in allowed_extensions):
        raise BadRequest(
            "{0} has an invalid name or extension".format(filename))


def _safe_filename(filename):

    filename = secure_filename(filename)
    date = datetime.datetime.utcnow().strftime("%Y-%m-%d-%H%M%S")
    basename, extension = filename.rsplit('.', 1)
    return "{0}-{1}.{2}".format(basename, date, extension)


def upload_file(file_stream, filename, content_type):

    _check_extension(filename, ALLOWED_EXTENSIONS)
    filename = _safe_filename(filename)

    client = _get_storage_client()
    bucket = client.bucket(CLOUD_STORAGE_BUCKET)

    blob = bucket.blob(filename)

    blob.upload_from_string(
        file_stream,
        content_type=content_type)

    url = blob.public_url

    if isinstance(url, six.binary_type):
        url = url.decode('utf-8')

    print(url)

    return url

7. Blueprintを修正

下記の公式ドキュメントを参考にアップロード機能を実装していきます。
追記の部分を記載していきます。元はこちらです。

参考: Flask - Uploading Files

# app/__init__.py
...

ALLOWED_EXTENSIONS = set(['txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'])
PROJECT_ID = PROJECT_ID
CLOUD_STORAGE_BUCKET = 'YOUR-BUCKET-NAME'

...

def create_app(test_config=None):

...

    return app
# app/blog.py

from flask import (
    Blueprint, flash, g, redirect, render_template, request, url_for
)

from app import storage
from . import db, ALLOWED_EXTENSIONS
...

bp = Blueprint('blog', __name__)


def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

def upload_image_file(file):

    if not file:
        return None

    public_url = storage.upload_file(
        file.read(),
        file.filename,
        file.content_type
    )

    return public_url

...

@bp.route('/create', methods=('GET', 'POST'))
@login_required
def create():
    if request.method == 'POST':
        title = request.form['title']
        body = request.form['body']
        error = None

        if not title:
            error = 'Title is required.'

        if 'img_file' not in request.files:
            flash('No file part')
            return redirect(request.url)
        file = request.files['img_file']

        image_url = upload_image_file(request.files.get('img_file'))

        if error is not None:
            flash(error)
        else:
            post = Post(title=title,
                        body=body,
                        author_id=g.user.id,
                        file_url=image_url,
                        filename=file.filename
                        )
...                        

@bp.route('/<int:id>/update', methods=('GET', 'POST'))
@login_required
def update(id):
    post = Post.query.get_or_404(id)

    if request.method == 'POST':
        title = request.form['title']
        body = request.form['body']
        error = None

        if not title:
            error = 'Title is required.'

        if error is not None:
            flash(error)

        if 'img_file' in request.files:
            file = request.files['img_file']

            if file and allowed_file(file.filename):
                image_url = upload_image_file(request.files.get('img_file'))
            post.id = id
            post.title = title
            post.body = body
            post.file_url = image_url
            post.filename = file.filename
            db.session.commit()
            flash('Successfully edited the post.')

        else:
            post.id = id
            post.title = title
            post.body = body
            db.session.commit()
...               

8. 画面フォームを修正

画像表示およびアップロードができるように修正します。

# app/templates/blog/index.html

{% extends 'base.html' %}

{% block header %}
<h1>{% block title %}Posts{% endblock %}</h1>
{% if current_user.is_authenticated %}
<a class="action" href="{{ url_for('blog.create') }}">New</a>
{% endif %}
{% endblock %}

{% block content %}
{% for post in posts %}
<article class="post">
    <header>
        <div>
            <h1>{{ post.title }}</h1>
            <div class="about">by {{ post.username }} on {{ post.created.strftime('%Y-%m-%d') }}</div>
        </div>
        <a class="action" href="{{ url_for('blog.update', id=post.id) }}">Edit</a>
    </header>
    <p class="body">{{ post.body }}</p>
    <img src="{{ post.file_url }}" width="400" height="200">
</article>
{% if not loop.last %}
<hr>
{% endif %}
{% endfor %}
{% endblock %}
# app/templates/blog/create.html

{% extends 'base.html' %}

{% block header %}
<h1>{% block title %}New Post{% endblock %}</h1>
{% endblock %}

{% block content %}
<form method="POST" enctype="multipart/form-data">
    <label for="title">Title</label>
    <input name="title" id="title" value="{{ request.form['title'] }}" required>
    <label for="body">Body</label>
    <textarea name="body" id="body">{{ request.form['body'] }}</textarea>
    <input type="file" id="img_file" name="img_file">
    <input type="submit" value="Save">
</form>
{% endblock %}
# app/templates/blog/update.html

{% extends 'base.html' %}

{% block header %}
<h1>{% block title %}Edit "{{ post['title'] }}"{% endblock %}</h1>
{% endblock %}

{% block content %}
<form method="post" enctype="multipart/form-data">
    <label for="title">Title</label>
    <input name="title" id="title"
           value="{{ request.form['title'] or post['title'] }}" required>
    <label for="body">Body</label>
    <textarea name="body" id="body">{{ request.form['body'] or post['body'] }}</textarea>
    <img src="{{ post.file_url }}" width="400" height="200">
    <input type="file" id="img_file" name="img_file">
    <input type="submit" value="Save">
</form>
<hr>
<form action="{{ url_for('blog.delete', id=post['id']) }}" method="post">
    <input class="danger" type="submit" value="Delete" onclick="return confirm('Are you sure?');">
</form>
{% endblock %}

9. 確認

Google CloudのAPI Client libraryをインストール

pip install gcloud

起動。

export FLASK_APP=run
export FLASK_ENV=development
flask run

表示を確認して喜びます。

f:id:moqrin3:20181201163743p:plain

わーい。 でも...やっぱり何だか動作おせーー。。。w

参考:

Python での Cloud Storage の使用
Python Client for Google Cloud Storage