0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

qwc-services による Web GIS の構築 #4 独自ページを追加

Last updated at Posted at 2025-03-23

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 を作成します。

Dockerfile
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 も作成します。

requirements.txt
flask
requests
werkzeug
PyJWT
gunicorn
sudo dnf install python3
sudo dnf install python3-pip

qwc-docker/docker-compose.ymlqwc-flask のエントリを追加します。

docker-compose.yml
      ...
      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-flaskDocker イメージはレディーメイドのものではありませんので、構成を変更するたびにビルドする必要があります。

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 5FontAwesome を使ってスタイリングしているため、ソースが少し長くなっていますが、下記の点に注目して読んでみて下さい。

  • 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 を使うと、比較的短時間で簡単にページを作成することが出来ますし、BootstrapFontAwesome を併用すると、見た目もそれなりに整えることが出来ます。また、上で見たように、qwc-services のユーザ権限の情報を簡単に流用することが可能ですので、ポータルや管理画面など QWC2 ビューワの機能を補うページを作りたい場合は最適解であろうと思います。

4. 非 Dockerize

Docker のコンテナとして作成した Flask アプリですが、後日、非 Dockerize して、直接ホスト内で動かすようにしました。

理由は、Flask で作成した管理ページから MapCache のキャッシュ制御を実行したかったためです。

Dockerize は比較的簡単にできました。以下、備忘のために手順を記録します。

4-1. コンテナを停める

docker compose down qwc-flaskqwc-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 を下記のように作成します。

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?