QGIS Server と QGIS Web Client 2 (QWC2) を中核とする統合的 Web GIS エコ・システムである qwc-services によって Web GIS を構築する作業記録の第4回です。
第1回では、qwc-docker
によって qwc-services
をインストールし、MapCache
とも連携させて、QGIS のプロジェクトを Web 地図として表示するところまでやりました。
第2回では、ユーザの認証と権限管理について調べて、地図やレイヤを閲覧できるユーザを限定したり、ビューワの機能を使用できるユーザを限定したりする方法を確認しました。
第3回では、qwc-services によって実現される「編集」関連の機能に不可欠なデータベース環境である PostGIS
を使えるようにしました。
今回は Python
のマイクロ・ウェブ・フレームワークである Flask
を使って QWC2
ビューワとは独立したページを作成し、Web GIS 全体としての使い勝手を向上させたいと思います。
1. Docker コンテナで Flask を動かす
まず、Flask
のためのディレクトリ qwc-docker/qwc-flask
を作成します。
mkdir qwc-docker/qwc-flask
以下の内容で qwc-docker/qwc-flask/Dokerfile
を作成します。
FROM python:3.9
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["gunicorn", "-b", "0.0.0.0:8081", "app:app"]
同じディレクトリに requirements.txt
も作成します。
flask
requests
werkzeug
PyJWT
gunicorn
sudo dnf install python3
sudo dnf install python3-pip
qwc-docker/docker-compose.yml
に qwc-flask
のエントリを追加します。
...
qwc-api-gateway:
image: nginx:1.27
ports:
# NOTE: The port the qwc application runs on. You can choose another port instead of 8088.
- "8088:80"
volumes:
- ./api-gateway/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
...
+ qwc-flask:
+ build:
+ context: ./qwc-flask
+ container_name: qwc-flask
+ environment:
+ <<: *qwc-service-variables
+ ports:
+ - "8081:8081"
+ volumes:
+ - ./qwc-flask:/app
+ - ./volumes/config/default/permissions.json:/app/permissions.json:ro
Docker コンテナを起動します。このとき --build
コマンドによって qwc-flask
のイメージがビルドされます。
qwc-flask
の Docker
イメージはレディーメイドのものではありませんので、構成を変更するたびにビルドする必要があります。
docker compose up --build -d
2. ディレクトリとファイルの構成
qwc-docker/qwc-flask
ディレクトリの構成は以下のようにします。
qwc-docker
+--- qwc-flask
+--- Dockerfile
+--- requirements.txt
+--- permissions.json
+--- app.py
+--- templates
+--- base.html
+--- index.html
+--- manual.html
+--- static
+--- assets
+--- css
+--- img
+--- webfonts
-
Dockerfile
...Docker
イメージをビルドし実行するための設定 -
requirements.txt
...Dockerfile
から参照される依存パッケージのリスト -
permissions.txt
...QWC2 / QWC Services
のユーザ権限情報-
qwc-docker/volumes/config/default/permissions.json
をボリューム・マウントしたもの -
QWC Admin
の "Generate Configuration" ボタンによってQWC2
の構成情報が再作成されるたびに更新される -
Flask
によるページでユーザ権限管理のために参照する
-
-
templates
...HTML
テンプレート-
base.html
... 各ページ共通の枠組み -
index.html
... トップ・ページ -
manual.html
... 操作マニュアルのページ - ... 必要に応じて追加
-
-
static
... 静的なリソースを保持するディレクトリ
3. app.py
app.py
がメインの Python
スクリプトです。
以下のような内容です。
# -*- coding: utf-8 -*-
from flask import Flask, request, jsonify, render_template, url_for
import requests
import jwt
import json
import os
from werkzeug.middleware.proxy_fix import ProxyFix
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_prefix=1)
PERMISSIONS_FILE = "/app/permissions.json"
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "dev-secret")
JWT_ACCESS_COOKIE_NAME = os.environ.get("JWT_ACCESS_COOKIE_NAME", "access_token_cookie")
def get_username():
token = request.cookies.get(JWT_ACCESS_COOKIE_NAME) # ブラウザの Cookie から JWT を取得
if not token:
return "guest"
try:
decoded = jwt.decode(token, JWT_SECRET_KEY, algorithms=["HS256"])
return decoded.get("qwc_identity",{}).get("username", "guest")
except jwt.exceptions.InvalidTokenError as e:
print("JWT 解析失敗:", e)
return "guest"
def get_user_roles(username):
try:
with open(PERMISSIONS_FILE) as f:
data = json.load(f)
except Exception as e:
print("権限設定ファイル読込失敗:", e)
return []
roles = set()
# ユーザーを探す
user_entry = next((user for user in data.get("users", []) if user["name"] == username), None)
if not user_entry:
return []
# 1. 直接割り当てられたロール
roles.update(user_entry.get("roles", []))
# 2. 所属グループ経由のロール
user_groups = user_entry.get("groups", [])
for group in user_groups:
group_entry = next((g for g in data.get("groups", []) if g["name"] == group), None)
if group_entry:
roles.update(group_entry.get("roles", []))
return sorted(roles)
@app.route('/')
def index():
username = get_username()
roles = get_user_roles(username)
return render_template('index.html', username=username, roles=roles)
@app.route('/manual')
def manual():
username = get_username()
return render_template('manual.html', username=username)
3-1. nginx によるリバース・プロキシへの対応
from werkzeug.middleware.proxy_fix import ProxyFix
...
app.wsgi_app = ProxyFix(app.wsgi_app, x_prefix=1)
この部分は、nginx
によるリバース・プロキシによって、http://localhost:8081/
が https://webgis.mydomain/home
など、/
以外のパスに接続されることを考慮した対策です。
この対策の有無によって、例えば、url_for('static', filename='assets/img/logo.png')
が返すパスが異なってきます。
対策 | 結果 |
---|---|
無 | '/static/assets/img/logo.png' |
有 | '/home/static/assets/img/logo.png' |
3-1-1. nginx の設定
nginx
のリバース・プロキシの設定は次のようにします。
# flask
location /home/ {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Prefix /home;
proxy_pass http://localhost:8081/;
proxy_redirect off;
}
3-2. 権限管理用データ
PERMISSIONS_FILE = "/app/permissions.json"
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "dev-secret")
JWT_ACCESS_COOKIE_NAME = os.environ.get("JWT_ACCESS_COOKIE_NAME", "access_token_cookie")
この部分は、権限管理用のデータを設定している所です。
-
PERMISSION_FILE
.../app/permissions.json
は、qwc-docker/volumes/config/default/permissions.json
がボリューム・マウントされたもの- ユーザ、グループ、そして、ロールの一覧とその関係を保持している
- QWC Admin の "Generate Configuration" ボタンによって QWC2 の構成情報が再作成されるたびに更新される
-
JWT_SECRET_KEY
...JWT
秘密鍵を環境変数から読み出そうとしている-
docker-compose.yml
の冒頭に記載される内容がqwc-flask
でも共有される設定 - その結果、クッキーをエンコードして発行するサービスとクッキーをデコードして読み込むサービスの間で秘密鍵を共有できる
- 内容は
.env
ファイルから読み出される
-
-
JWT_ACCESS_COOKIE_NAME
... デフォルト値は"access_token_cookie"
- 他のサービスでもこのデフォルト値が使われている
3-3. ユーザ名の取得
def get_username():
token = request.cookies.get(JWT_ACCESS_COOKIE_NAME) # ブラウザの Cookie から JWT を取得
if not token:
return "guest"
try:
decoded = jwt.decode(token, JWT_SECRET_KEY, algorithms=["HS256"])
return decoded.get("qwc_identity",{}).get("username", "guest")
except jwt.exceptions.InvalidTokenError as e:
print("JWT 解析失敗:", e)
return "guest"
ブラウザのクッキーから JWT
(JSON Web Token
) を読み出して、ユーザ名を取得しています。
ログインしていない場合や、JWT
のデコードに失敗した場合は、"guest"
を返します。
3-4. ユーザのロールを取得
def get_user_roles(username):
try:
with open(PERMISSIONS_FILE) as f:
data = json.load(f)
except Exception as e:
print("権限設定ファイル読込失敗:", e)
return []
roles = set()
# ユーザーを探す
user_entry = next((user for user in data.get("users", []) if user["name"] == username), None)
if not user_entry:
return []
# 1. 直接割り当てられたロール
roles.update(user_entry.get("roles", []))
# 2. 所属グループ経由のロール
user_groups = user_entry.get("groups", [])
for group in user_groups:
group_entry = next((g for g in data.get("groups", []) if g["name"] == group), None)
if group_entry:
roles.update(group_entry.get("roles", []))
return sorted(roles)
PERMISSIONS_FILE
を読んで、ユーザに割り当てられているロールを返しています。
ユーザは、直接にロールを割り当てられることもあれば、所属グループ経由でロールを割り当てられることもあります。また、一人のユーザに複数のロールが割り当てられることもあります。上記の関数はそれらの場合も想定して、ユーザに割り当てられたロールを漏れなく重複なくリストアップします。
3-5. index ルート
@app.route('/')
def index():
username = get_username()
roles = get_user_roles(username)
return render_template('index.html', username=username, roles=roles)
ユーザ名とロールを取得して index.html
テンプレートをレンダリングします。
3-6. manual ルート
@app.route('/manual')
def manulal():
username = get_username()
roles = get_user_roles(username)
return render_template('manual.html', username=username, roles=roles)
ユーザ名とロールを取得して manual.html
テンプレートをレンダリングします。
3-7. base.html テンプレート
base.html
テンプレートは、index.html
および manual.html
から呼び出される共通の枠組みです。
<html lang="ja" class="h100"">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, minimal-ui">
<meta name="HandheldFriendly" content="true">
<meta name="mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='assets/img/app_icon.png') }}">
<link rel="apple-touch-icon" sizes="72x72" href="{{ url_for('static', filename='assets/img/app_icon_72.png') }}">
<link rel="apple-touch-icon" sizes="114x114" href="{{ url_for('static', filename='assets/img/app_icon_114.png') }}">
<link rel="apple-touch-icon" sizes="144x144" href="{{ url_for('static', filename='assets/img/app_icon_144.png') }}">
<link rel="icon" href="{{ url_for('static', filename='assets/img/favicon.ico') }}">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" ... >
<link href="{{ url_for('static', filename='assets/css/site.css')}}" rel="stylesheet">
<link href="{{ url_for('static', filename='assets/css/all.min.css')}}" rel="stylesheet">
{% block title %}{% endblock %}
</head>
<body class="d-flex flex-column h-100 overflow-y-scroll">
<header class="no-print">
<nav id="w27" class="navbar navbar-expand-lg fixed-top"
style="border-bottom:1px solid #CCCCCC; background-color:white">
<div class="container">
<a class="navbar-brand" href="{{url_for('index')}}"><img
src="{{ url_for('static', filename='assets/img/logo.svg') }}" height="46" alt=""
style="margin-top:-20px; margin-bottom:-16px"> 岩座神地理情報システム</a>
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#w27-collapse"
aria-controls="w27-collapse" aria-expanded="false" aria-label="Toggle navigation"><span
class="navbar-toggler-icon"></span></button>
<div id="w27-collapse" class="collapse navbar-collapse">
<ul id="w28" class="navbar-nav me-auto nav">
<li class="nav-item"><a class="nav-link" href="{{ url_for('index') }}"><i class="fa-solid fa-home"></i> HOME</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('manual') }}"><i class="fa-solid fa-circle-question"></i> マニュアル</a></li>
{% if 'admin' in roles %}
<li class="nav-item"><a class="nav-link" href="/qwc_admin"><i class="fa-solid fa-wrench"></i>QWC Admin</a></li>
<li class="nav-item"><a class="nav-link" href="/pga"><i class="fa-solid fa-wrench"></i> pgAdmin4</a></li>
{% endif %}
</ul>
<ul id="w29" class="navbar-nav nav">
<li class="nav-item" style="margin-top:8px; margin-right:8px"><i class="fa-solid fa-user"></i>{{username}}</li>
{% if username == 'guest' %}
<li class="nav-item"><a class="nav-link" href="/auth/login?url={{url_for('index')}}"><i class="fa-solid fa-right-to-bracket"></i> ログイン</a></li>
{% else %}
<li class="nav-item"><a class="nav-link" href="/auth/logout?url={{url_for('index')}}"><i class="fa-solid fa-right-from-bracket"></i> ログアウト</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
</header>
<main role="main" class="flex-shrink-0">
<div class="container">
{% block contents %}{% endblock %}
</div>
</main>
<footer class="footer mt-auto py-3 text-muted no-print">
<div class="container">
<p class="float-start">i-GIS 岩座神地理情報システム</p>
<p class="float-end">maintained by <a href="https://softark.net" target="_blank">softark</a></p>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" ...></script>
</body>
</html>
Bootstrap 5
と FontAwesome
を使ってスタイリングしているため、ソースが少し長くなっていますが、下記の点に注目して読んでみて下さい。
url_for()
<link rel="icon" href="{{ url_for('static', filename='assets/img/favicon.ico') }}">
- タイトルのプレースホルダ
{% block title %}{% endblock %}
- ロールに応じて、メニュー項目の表示・非表示を制御
- 例えば、
admin
ロールを持つユーザにだけ、特定のメニューを表示
- 例えば、
{% if 'admin' in roles %} <li class="nav-item"><a class="nav-link" href="/qwc_admin"><i class="fa-solid fa-wrench"></i>QWC Admin</a></li> <li class="nav-item"><a class="nav-link" href="/pga"><i class="fa-solid fa-wrench"></i> pgAdmin4</a></li> {% endif %}
- ユーザ名を表示
<li class="nav-item" style="margin-top:8px; margin-right:8px"><i class="fa-solid fa-user"></i>{{username}}</li>
- ユーザが
"guest"
なら「ログイン」を表示、そうでなければ「ログアウト」を表示
{% if username == 'guest' %} <li class="nav-item"><a class="nav-link" href="/auth/login?url=> {{url_for('index')}}"><i class="fa-solid fa-right-to-bracket"></i> ログイン</a></li> {% else %} <li class="nav-item"><a class="nav-link" href="/auth/logout?url={{url_for('index')}}"><i class="fa-solid fa-right-from-bracket"></i> ログアウト</a>> </li> {% endif %}
- メインのコンテンツのプレースホルダ
{% block contents %}{% endblock %}
3-8. index.html, manual.html テンプレート
これらのテンプレートでは、特に面白いことをしてはいませんので、作りかけの manual.html
の内容を掲示します。その方が構造が分りやすいでしょう。
{% extends 'base.html' %}
{% block title %}
<title>i-GIS マニュアル</title>
{% endblock %}
{% block contents %}
<div class="h-100 p-3">
<h1><img src="{{url_for('static', filename='assets/img/logo.svg')}}" height="80" alt="" style="margin-top:-10px;">マニュアル</h1>
<p>準備中です...</p>
<div class="body-content">
<div class="row">
<div class="col-12">
<h2 class="h4 border-secondary border-bottom pb-1 mt-1 mb-3">i-GIS の画面</h2>
</div>
</div>
</div>
</div>
{% endblock %}
Flask
を使うと、比較的短時間で簡単にページを作成することが出来ますし、Bootstrap
や FontAwesome
を併用すると、見た目もそれなりに整えることが出来ます。また、上で見たように、qwc-services
のユーザ権限の情報を簡単に流用することが可能ですので、ポータルや管理画面など QWC2
ビューワの機能を補うページを作りたい場合は最適解であろうと思います。
4. 非 Dockerize
Docker
のコンテナとして作成した Flask
アプリですが、後日、非 Dockerize
して、直接ホスト内で動かすようにしました。
理由は、Flask
で作成した管理ページから MapCache
のキャッシュ制御を実行したかったためです。
非 Dockerize
は比較的簡単にできました。以下、備忘のために手順を記録します。
4-1. コンテナを停める
docker compose down qwc-flask
で qwc-flask
コンテナを停止。
docker-compose.yml
を編集して、qwc-flask
コンテナをコメントアウト。
4-2. venv
cd qwc-flask
python3 -m venv ./venv
source ./venv/bin/activate
4-3. 必要なパッケージをインストール
pip install -r requirements.txt
pip install python3-dotenv
4-4. ソースの修正
app.py
を以下のように修正します。
# -*- coding: utf-8 -*-
from flask import Flask, request, jsonify, render_template, url_for
import requests
import jwt
import json
import os
from werkzeug.middleware.proxy_fix import ProxyFix
+ from dotenv import load_dotenv
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_prefix=1)
+ load_dotenv() # local
+ load_dotenv(dotenv_path="/home/my-account/qwc-docker/.env")
- PERMISSIONS_FILE = "/app/permissions.json"
+ PERMISSIONS_FILE = os.environ.get("PERMISSIONS_FILE", "/home/my-account/qwc-docker/volumes/config/default/permissions.json")
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "dev-secret")
JWT_ACCESS_COOKIE_NAME = os.environ.get("JWT_ACCESS_COOKIE_NAME", "access_token_cookie")
変更のポイントは:
-
qwc-docker
の.env
ファイルを読み取って環境変数を取得-
JWT_SECRET_KEY
環境変数が重要
-
-
permissions.json
のパスを変更- コンテナ内でボリューム・マウントしていたものを直接に指定
4-5. gunicorn のサービス化
/etc/systemd/system/qwc-flask.service
を下記のように作成します。
[Unit]
Description=Flask App via Gunicorn
After=network.target
[Service]
User=my-account
Group=my-account
WorkingDirectory=/home/my-account/qwc-docker/qwc-flask
ExecStart=/home/my-account/qwc-docker/qwc-flask/venv/bin/gunicorn -w 4 -b 127.0.0.1:8081 app:app
[Install]
WantedBy=multi-user.target
サービスを自動起動するように設定します。
sudo systemctl daemon-reexec
sudo systemctl daemon-reload
sudo systemctl enable qwc-flask
sudo systemctl start qwc-flask
以上で、Flask
アプリが直接にホスト内で動作するようになります。
And so, what's next?
次こそは、本当に、QWC2 Viewer
の編集機能を試します。ずっと先延ばししてきた案件ですが、もうこれぐらいしか残っていませんので、必ずやります。