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 の編集機能を試します。ずっと先延ばししてきた案件ですが、もうこれぐらいしか残っていませんので、必ずやります。