Bitbucketで急にVSCodeがリポジトリと通信できなくなってハマった

VSCodeでSphinxで書いているドキュメントを更新、Bitbucketを使っているリポジトリにPushしようとしたらなぜかできなくて、トラブルシュートですごい時間とかしてしまった。

エラーは以下。

fatal: unable to access 'https://xxxx.com/xxxxxx/xxxxx.git/': Recv failure: Connection was reset

Gitの認証情報が変わったのにどこかにキャッシュ持ってるのかなっていう感じがしたのでずっと、もう一度別フォルダにクローンしなおしてみたり、手を変え色々やってみたり、WindowsのCredential Managerみたいなところを見てここか!と思ったりしたけど関係なし。

結局、別環境用の設定が .vscode/settings.json にあって、そこにhttps_proxyが設定されていたのが、今の環境だと意味なかったというだけであった。Terminalで以下のコマンドを打つと急に何でもできるようになった。

set https_proxy=

もったいないことで悩んでしまった。

つぶやく2024年3月17日

ろくでなしブルースもう一回読みたいな。高校1年のときめっちゃ教室で読んでたな。そしてえいちゃんの白い下敷き懐かしい。

今日はちょっとSharePointブログについて調べてみましょうかね。何かを調べながら試して、ノウハウを残すという行為をいっぱいするようになったのだけど、これをきっちりアウトプットする先がほしい。自社の社内ブログ標準のWordpressがサーバが弱すぎるのか遅すぎて、もうちょっとどうにかなってほしいのだが。いっそ外部のnoteとかQiitaを主戦場にしてみるかどうか。Sphinxをブログ風に使うような拡張機能もあるならそれもありだけども。

あとClaudeの件もちゃんと調べないとだし、昨日開発したSimpleRAGを自社のとこに置いてしまうハックもやりたいなー。

と言う前にゼロタッチのノウハウまとめをやらなければ。3月に全体脱稿をまず目指すか。やりたいことたくさんだなあ。

家のサーバの再構築 2024年2-3月

自宅で運用しているサーバの作りを、結構大きく見直した。ハードウェアは何も変わっていないけど。

まず、VMWare ESXiをやめた。それの上に色んなサーバ立てるのも楽しかったのだけど、1サーバごとにCPUコアの使用制限がかかっているのがどうも気になっていたので一度外してみることにした。KVMとか代替手段もあるようなので。いったんハイパーバイザ的なことはやめてUbuntuをクリーンに入れてみた。

しかし、それが簡単にはいかなくて、何が厳しいかというとPCIスロットにカード増設してインストールしたNVMe SSDにOSを入れてやろうとしたものの、どうもこの人はUEFIブートの出発点にできないらしく、LinuxをUSBメモリからそのドライブにインストールすることもできるのだが、起動画面でずっと空振りするようなことになってしまう。

Clover EFIという救世主らしきものに出会ったけど、これもダメで結局SATA HDDにインストールした。ここまででどえらい時間が溶けた。

そして、色んなアプリをインストールしていくのだが、すべてDocker ComposeでやってホストOSはできるだけ何もない状態に、というのを目指してみる。Dockerでやると、Composeファイルとかを全部Gitで管理して、設定ファイルも同じところにおいてVolumeとしてマウントしてあげることで環境がまっさらになっても結構楽ちんで同じものが作れる。データの永続化もHDDにしてから多重化しておくと安心。

これはこれで結構苦労しているけど、現状動かせているアプリケーションやサービスは以下。

  • 家庭内DNSサーバ (unbound)
  • nginxリバプロ
  • WordPress
  • PhotoprismとNextCloud
  • Gitpod (ここでサーバの資材やサービス、Docker関係のファイルを全部管理。簡易シェルにもなって便利)
  • Portainer
  • Gitlab
  • Splunk Free Lisence
  • NFS/SMB
  • Minecraft Bedrock のワールドサーバ
  • Ntfy

この後もうちょっとやりたいのもある。

  • 自前のChatGPT利用したRAGアプリをGradioで作る
  • Djangoで作る勉強用アプリのホスティングと開発環境
  • Mattermost

Mkdocsも手足のように使いこなせるようになっときたいなー。

最近勉強できたこと2023下半期

  • 自動化の社内ツール
  • splunk
  • sphinx(ドキュメンテーション)
  • docker
  • mongodb
  • kubernetes
  • powerautomate
  • gitpod
  • gitlab
  • microsoft graph api
  • python
  • office script

やってみたが断念したもの

  • PowerApps、Dataverse
  • Nuxt.js

CakePHP 4: Consoleコマンドでバッチ処理、PHPのバージョンの切替

Cakephp 4で作ったアプリにバッチ処理も作り足したい場合はコンソールコマンドを使うことになるが、その使用例はこうなっている

$ bin/cake hello

自分が作業していた環境(RHEL)は、phpのバージョンが7.4と8.2がどちらもインストールされており、どちらも使っている。今回8.2で実行したいが、単純にシェルでphpコマンドを打つと今は7.4が有効なので

$ bin/cake
PHP Fatal error:  Your PHP version must be equal or higher than 7.4.x to use CakePHP. in /var/www/html/xxxxx/config/requirements.php on line 24

という感じでエラーが出る。そりゃそうだよね。

Webとかでもあまりはっきり書いてないけど、これで解決する。

$ php82 ./bin/cake.php
No command provided. Choose one of the available commands.

Current Paths:

* app:  src/
* root: /var/www/html/xxxxx/
* core: /var/www/html/xxxxx/vendor/cakephp/cakephp/

Available Commands:

App:
 - help

Bake:
 - bake
 - bake all
 - bake behavior
 - bake cell
 - bake command
 - bake command_helper
 - bake component
 - bake controller
 - bake controller all
 - bake fixture
 - bake fixture all
 - bake form
 - bake helper
 - bake mailer
 - bake middleware
 - bake model
 - bake model all
 - bake plugin
 - bake shell_helper
 - bake template
 - bake template all
 - bake test

Cake/TwigView:
 - twig-view compile

CakePHP:
 - cache clear
 - cache clear_all
 - cache list
 - completion
 - i18n
 - i18n extract
 - i18n init
 - plugin assets copy
 - plugin assets remove
 - plugin assets symlink
 - plugin load
 - plugin loaded
 - plugin unload
 - routes
 - routes check
 - routes generate
 - schema_cache build
 - schema_cache clear
 - server
 - version

DebugKit:
 - benchmark

Migrations:
 - bake migration
 - bake migration_diff
 - bake migration_snapshot
 - bake seed
 - bake simple_migration
 - migrations
 - migrations create
 - migrations dump
 - migrations mark_migrated
 - migrations migrate
 - migrations orm-cache-build
 - migrations orm-cache-clear
 - migrations rollback
 - migrations seed
 - migrations status

To run a command, type `cake command_name [args|options]`
To get help on a specific command, type `cake command_name --help`

Djangoの自動テストをあきらめた話

Python Djangoを使ってWebアプリを作り始めて、今まで自動テストをちゃんとやったことがなかったので、いい加減にこの機会にちゃんと学ぼうと思って、30-40時間を学習と試行に費やしたけど結局諦めたという話。

何のために自動テストを使うのか

テスト駆動開発くらいの言葉もあるし、Qiitaにはこんな素晴らしい投稿をされている人もいて概念自体はとてもいいなと思うものの、作業量があまりにも多すぎる。感覚的には、動くアプリを書いて画面をチェックして開発を進めるだけっていうのの3倍以上の時間がかかる気がする。PythonやDjangoでなくてもなんだって同じだろうなという気がする。

CI/CDなんて概念も、もはや世の中では当たり前だよというような感じで語られているし、自分は、とにかく回帰テストとしてDjangoのテストフレームワークを使いたかった。関数ベースビューが何十にもなって、モデルの数もどんどん増えていき、何か新機能を付けたりバグを修正したりした時に、既存のうまくいっているところが動かなくなるのを防ぐのには確かにうってつけなので。

改修する、それがうまくいっているかテストする、そのテストをWebブラウザを操作して目検で確認するだけでなくコード化する、蓄積されたテストをすべてブン回して何もかもうまくいくことを確認して、本番環境にリリースする。ということが毎回できればよいなというお話です。

学習したこと、試したこと(CLIベースのテスト)

Django付属のTestCaseから試したが、まずこのテストは大きく二つの目的に使える。
その1はモデルのテスト、その2は疑似ブラウザ(Client)を使った画面動作のテスト。

モデルのテストはこんな塩梅になる。

class CompanyModelTests(TestCase):

    def setUp(self):
        self.cp = Company(
            name="test company",
            since=timezone.now(),
            start_year='same',
            start_month=4,
        )
        self.cp.save()
        return super().setUp()

    def test_cp_count(self):
        cp = Company.objects.all().first()
        self.assertEqual(len(Company.objects.all()), 1)

Companyというモデルを1件Saveしてあげて、テストメソッド(test_cp_count)にて、Companyのモデルの数を数えてあげたらちゃんと1になるよね、というテストコードになる。Webのチュートリアルとかを見ても、これくらいのサンプルコードは山ほどあるので、理解はしやすい。

しかし、こんなテストを大量に作ってどこまで意味があるのかと、この作業を延々続けることがばかばかしく思えてしまう。この例はほぼDjangoのフレームワークをテストしているだけだ。じゃあ、Modelに自分で追加したメソッドをテストすればいいということにはそれは賛成なのだけど、結局そんなメソッド単体でテストしたところで、基本はViewのロジックがテストされないと意味ないし、Viewが期待通り動いてくれてるかどうかを見るのが大事なのに、部品のテストにそこまで時間をかけるのが正解かどうか。だいぶ包含されるし。

そして、Viewをテストするとなると、その2.疑似ブラウザ的なClientを利用したテストになる。これはだいぶいいものに思えた。

class CompanyDisplayTests(TestCase):

    fixtures = ['test_data_01']

    def setUp(self):
        self.p_ad = Person.objects.filter(pk=2).first()
        self.client.force_login(self.p_ad.user)
        return super().setUp()

    def test_company_top_suc(self):
        ''' company top normal show'''
        response = self.client.get('/pj01/company_top')
        self.assertContains(response, "Project and Workcodes")
        self.assertEquals(response.status_code, 200)

    def test_wctype_change_order(self):
        ''' wctype_change_order '''
        self.client.force_login(self.p_ad.user)
        data = {"form-0-id": "1",
                "form-0-cp": "1",
                "form-0-name": "Normal",
                "form-0-is_active": "on",
                "form-0-ord": "03",
                "form-1-id": "2",
                "form-1-cp": "1",
                "form-1-name": "AO",
                "form-1-is_active": "on",
                "form-1-ord": "20",
                "form-TOTAL_FORMS": "2",
                "form-INITIAL_FORMS": "1",
                "form-MIN_NUM_FORMS": "0",
                "form-MAX_NUM_FORMS": "20",
                "modify": "Save"}
        
        response = self.client.post('/pj01/mt_wctype', data)
        response = self.client.get('/pj01/mt_wctype')
        self.assertContains(response, 'Succesfully Saved.')
        self.assertEquals(response.status_code, 200)

DjangoのTestCaseでは、self.clientという疑似ブラウザが使える。この例では、’/pj01/company_top’というURLをGETリクエストして、その結果返ってきたHTMLに入っている文字列と、レスポンスのHTTPステータスコードが200であることを検証して、いずれかに問題があればテストがエラーとなる。

同様に、画面に対するPOSTをすることも可能。それが上記サンプルの中のtest_wctype_change_order。postメソッドで呼んで配列を渡してやることでWebブラウザからのデータ入力、そのあとのメッセージの検証や、例からは省略したがModelを使ってDBにデータが想定通り格納されたかを検証させることもできる。

fixtureというDBの基本マスターデータをロードして、毎回まっさらなデータベースを用意してくれる思想もなるほどなと思った。前回のテスト実行の結果でデータが変わっていたら同じ結果が得られないという悩みはない。

そして、こんなコードを手でいちいち書いてトライアンドエラーするのもまた時間がもったいないので、コードを書いたあとに手動でブラウザを動作させてテストしてあげるときにうまくログを吐き出させて、上記のようなテストコードへ流用する情報、主にURLやPOSTデータをTextファイルに吐き出すようにMiddlewareを作ってやることによってテストコードを作る手間を省力化させてもみた。

しかしそれでも、画面でテストした通りのことを、こいつにサクッとやらせるまでにはどうしても至らなかった。
いくつか問題があって

  • Fixtureのデータの中に日付項目があると、なぜかテスト実行時の出力結果にその日付がダラダラと出力されてくる。意味も分からないし、だいぶデバッグもしたが止める方法が不明。
  • Fixtureがすべてのテストに影響するとなると、気軽に直すわけにもいかないし、でもテストを追加するたびにどうしてもFixtureを修正したくなるし非常にストレス。
  • 自分でブラウザからマニュアルテストしてうまくいっている機能が、自動テストでうまくAssertを通らないときに、デバッグしているときの謎の労力(エラーが出てる瞬間のDBの状態も見られないし)
  • 日付関係がしんどい。2022年10月にうまく通っていたテストが2023年2月にコケて、それをまたトラブルシュートすることの空虚さ。
  • テストメソッドをまたぐとDBが初期状態に戻る。このテストメソッドで登録したデータを、別のテストメソッドで参照画面で見よう、と思ってもそうはいかない。結局、データを登録して、削除して、もう一度登録して、編集して、ステータスを変えて、参照画面で見たらここがこう変わってて・・・といったユーザストーリー的なテストは一つのテストメソッドの中にどんどん書き足す必要がある。そして、途中であきらめたけど、登録したはずのデータがなぜかうまく残らなくてテストが通らない。
  • これだけ色々な問題をクリアしたところで、結局人が目で見たときに「あれ、ここ思った通り動いてないな」と気づく力には勝てない。自動テストはassertで指示されたところしかチェックしない。assertを死ぬほど書けばいいし、Djangoの場合は返されたHTMLやステータスコードだけではなくてコンテキストに対してチェックをすることもできるしDBをModel経由でチェックさせることもできるのだが、これも労力があまりにもかかる。

Webで色々調べたけれど、どれもいい解決策に至らず、僕ちゃんにはCI/CDなんてまだ早かったのかな、落ちこぼれだな、と泣いた。何をどうやって見ても、回帰テストが1コマンドでブン回せるというメリットに釣り合うとはとても思えないほどの労力がかかると思われた。

Djangoのtestcase.clientはそれでもどうしても使いこなしたかったので粘った。Seleniumを使ったLiveServerTestCaseも存在は知っていたけど、こちらは気軽にテストが回せないのが気にかかった。本当のWebブラウザをスクリプト操作でテストさせる場合、完全に直列でテストが走るから、テストコードが蓄積するほどに待ち時間が長くなるのが必至。テストコード自体のテストみたいなのが発生するの明白だから、1回あたりの実行時間が長くなるのはどうも気が進まなかった。

でもそれでも、本当にやりたいことは
・VsCodeでアプリを書いて
・横でブラウザでそれを叩いてみて期待通り動作するかを見る
・うまく動いていれば開発を続ける、期待と違ったら修正して再度見る
・一通りうまくいったことを確認したら、ブラウザで確認した手動テストを自動テストコード化して回帰テストとして蓄積する
ということなので、Seleniumにも行ってみることにした

学習したこと、試したこと(LiveServerTestCase)

やりたかったことは
①VsCodeでアプリを書き、manage.py runserverしたテスト環境で叩いて機能を作っていき
②大体満足したところで、Selenium IDEを起動、開発したコードが一通り叩けるような操作をレコード
③それをやる過程で、Selenium IDEのスクリプトの中にAssertを仕込んでいく
④PythonコードをExportする
⑤DjangoのLiveServerTestCaseを継承したテストクラスにスクリプトとして取り込む
⑥テストコードがうまく動くことを確認したら、テストコードとして蓄積していく
という流れ。

②と③は大まかにはうまくいったけど、Assertに関してはHTTPのステータスコードを取得する機能はないことが判明。公式にも、それをやろうとしていること自体が間違ってるよ的な見解が。あれば楽なのになと思ったけど、<h1>タグとかみればええやん、って書いてある。

それではとあきらめてAssert Textを多用していくが、、、全文一致しかAssertが通らず部分一致やる方法がどうもなさそうだ。これもつらい。

Pythonコードを出力した後、LiveServerTestCaseにはめ込んだら一応動いているような気もするが

TypeError: expected str, bytes or os.PathLike object, not NoneType

動作はしているっぽいけど吐き出し続けられる謎のこのエラーは一体何なのか。ググっても何もわからない。

さらに動いている画面を見るとCSSとかが読み込めておらず何とも調子が出ない。これも直し方あるのかな。

あきらめることに

CI/CDとか自動テストがこんなに大変なものなのかと愕然とし、いったん使うのをあきらめることにした。Djangoとして機能がすでに十分そろっていて、自分の頭が足りなくて使いこなせていないだけなのか、世の中の皆さんは大変な労力をかけてちゃんとやっていらっしゃるのか、本当はまだ全然普及してなくてマニュアルテストが大半の世の中なのか、謎。

自動テストは、回帰テストでGetした画面が想定外のステータスコード返してきてないかくらいをチェックするくらいにとどめて、マニュアルテスト基本でやっていくことにする。

Django Modelのフィールド間でチェック(例えば”開始日”と”終了日”の前後関係)

例えばタスクというModelを作るとして、
以下のように、タスク名称と、開始日、完了期日、完了日という3つの日付フィールドをつけたとする。

from django.db import models

class Task(models.Model):
    def __str__(self):
        return self.title
    title = models.CharField(max_length=128)
    start_at = models.DateField('From')
    due_at =  models.DateField('Due',blank=True,null=True)
    end_at = models.DateField('To',blank=True,null=True)

さて、タスク登録する画面でも作ろうかってなると「開始日と終了日を逆にしたらエラーになるようにしたいな」と思うわけで、Djangoだとどういうやりようがあるのかな、と思ってネットを調べたら

① saveメソッドをオーバーライド
② MetaのConstraintsを使う
③ formのvalidationを使う

くらいの方法が見つかった。

結論は、①が一番よさそうに見えたのでこれで行くことにする。こうしてしまう。

from django.db import models

class Task(models.Model):
    def __str__(self):
        return self.title
    title = models.CharField(max_length=128)
    start_at = models.DateField('From')
    due_at =  models.DateField('Due',blank=True,null=True)
    end_at = models.DateField('To',blank=True,null=True)

    def save(self, *args, **kwargs):
        if ( (self.dueat is not None) and (self.start_at > self.due_at) ):
            raise ValidationError("Due date is before Start Date.")

        if ( (self.endat is not None) and (self.start_at > self.end_at) ):
            raise ValidationError("End date is before Start Date.")

        super(Task, self).save(*args, **kwargs)

②は、Django公式のガイドにも何やかや書いてあるんだけど、多分今回やりたいことがそもそもできない。テストもしてないけど、散々頑張ってできなかったっていうオチが何となく予想される。

③はたぶんできるけど、どうもDjangoのFormはあまり信用していなくて細かい使い方がわからないし、先々APIとかバッチ処理とか書いて、直接ModelのSaveとか叩かせたりしたときに効かないだろうから、イヤなのでやめた。ドキュメントもななめ読みしただけでも理解大変そう。

Modelによるデータの保存にsaveメソッド通らないことなんてあるのかな。多分ないからやっぱり①が確実かな、というのがいったん結論。

Djangoで公開WebアプリをFirebase認証で作る

Djangoでの開発の勉強として、練習用に以下のようなサンプルアプリを作る。

  • 極力ややこしいことはせず、Google、Twitter、Facebook等でユーザ登録や認証(ログイン)ができる。
  • 「サークル」というユーザが集まる単位があり、ユーザは複数のサークルに所属する。
  • あらゆるユーザは新しいサークルを作成できる。
  • サークルへの参加は、そのサークルにすでに参加しているメンバーの招待により可能となる。
  • サークルごとには、簡単なメッセージボードがあってメッセージをやり取りできる。

技術的な制約としては以下とする。

  • フロントエンド開発はなし。ReactやAngularなんかのフレームワーク使わないし、Javascriptも最低限にする。画面遷移はDjangoの基本的な使い方で、TemplateのHTMLを使う。

以降の手順の説明において、解説はポイントだけとしてDjangoの学習レベルの説明はしない。

テスト用のアプリを用意して動かす

まずはDjangoをインストールしてStartAppまで行く。

# Homeにアプリ用のディレクトリを作成
user@sv:~$ mkdir simplecircle
user@sv:~$ cd ./simplecircle

user@sv:~/simplecircle$ python3 -V
Python 3.10.6

# VirtualEnv環境を作成
user@sv:~/simplecircle$ python3 -m venv scenv
user@sv:~/simplecircle$ . ./scenv/bin/activate

# pipの更新、Djangoとグローバルログインモジュールを導入
(scenv) user@sv:~/simplecircle$ pip install -U pip
(省略)
Successfully installed pip-22.2.2

(scenv) user@sv:~/simplecircle$ pip install django
(省略)
Successfully installed asgiref-3.5.2 django-4.1.2 sqlparse-0.4.3

(scenv) user@sv:~/simplecircle$ pip install django-glrm
(省略)
Successfully installed django-glrm-1.1.3

(scenv) user@sv:~/simplecircle$ django-admin startproject simplecircle
(scenv) user@sv:~/simplecircle$ cd simplecircle/
(scenv) user@sv:~/simplecircle/simplecircle$ python manage.py startapp app

さて、Settingsの方はここだけいじる。

[simplecircle/settings.py]

ALLOWED_HOSTS = ['localhost']

INSTALLED_APPS = [
    ...
    'django.contrib.staticfiles',
    'app'
]

MIDDLEWARE = [
    ...
    'global_login_required.GlobalLoginRequiredMiddleware', &lt;- 追加
    'simplecircle.middleware.AppMiddleware', &lt;- 追加、後で作る
]

### 書き足す ↓↓↓
LOGIN_REDIRECT_URL = '/app/circle_board'
LOGOUT_REDIRECT_URL = '/accounts/login'

PUBLIC_PATHS = [
	'/accounts/',
]
### 書き足す ↑↑↑

まずはModelを作ってしまう。
Circleというのがこのアプリの人が集まっているサークルのマスタになる。属性は名前だけ。JoiningというのがManyToMany的なCircleとユーザの紐付けテーブル。
最初は使わないけども、サークルへの招待をすることになるのでその招待チケットのテーブルも作り、あとはメッセージボード的なところに流れるメッセージとした。

[app/models.py]
from django.db import models
from django.contrib.auth.models import User

# Circle - サークル
class Circle(models.Model):
    def __str__(self):
        return self.name
    name = models.CharField(max_length=64)

# Joining - ユーザがサークルに入っている紐付け
class Joining(models.Model):
    def __str__(self):
        return self.circle.name + " hires " + str(self.user)
    circle = models.ForeignKey(Circle, on_delete=models.CASCADE)
    user = models.ForeignKey(User, on_delete=models.CASCADE)

# CircleInvitation - サークルへの招待
class CircleInvitation(models.Model):
    def __str__(self):
        return 'invitation of ' + str(self.circle)
    circle = models.ForeignKey(Circle, on_delete=models.CASCADE)
    key = models.TextField('Random Hash')
    expired_at = models.DateTimeField('Expired At')

# Message - サークルの伝言板
class Message(models.Model):
    def __str__(self):
        return 'message at' + str(self.posted_at)
    circle = models.ForeignKey(Circle, on_delete=models.CASCADE)
    user_posted = models.ForeignKey(User, on_delete=models.CASCADE)
    body = models.TextField('Message Body')
    posted_at = models.DateTimeField('Posted At')

Modelが満足行く出来になったらもちろんこれ。

$ python manage.py makemigrations
$ python manage.py migrate

次にurl関係を。
Djangoの付属の管理画面(admin)も一旦有効にする。このままだと公開はできないような気もするけど設定を間違えなければ問題ないような気もする。accountsに関してはログイン認証関係をやってくれるのでいったん入れるが、ログイン画面を用意するくらいにしか使わない。
アプリ側の画面は1画面だけ。サークルの伝言表示&投稿画面だけとする。ユーザが複数のサークルに参加している場合はサークルの切り替えが必要なので、そのためのViewを一つ用意している。

[simplecircle/urls.py]
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('accounts/', include('django.contrib.auth.urls')),
    path('app/',include('app.urls')),
]

[app/urls.py]
from django.urls import path
from . import views

app_name='app'
urlpatterns = [
    path('circle_board', views.circle_board, name='circle_board'),
    path('circle_switch/<int:to_circle>', views.circle_switch, name='circle_switch'),
]

Admin画面からテストデータを登録してみたいので

[app/admin.py]
from django.contrib import admin
from .models import *

admin.site.register(Circle)
admin.site.register(Joining)
admin.site.register(CircleInvitation)
admin.site.register(Message)

この辺まで来たらスーパーユーザをひとり、Mikeくんというのを作ってから、 Django の仮サーバを上げて見る。

$ python manage.py createsuperuser --username mike
Email address: mike@greatmail.com
Password: 
Password (again): 
Superuser created successfully.
$ python manage.py runserver 0.0.0.0:8000

Admin画面(http://localhost:8000/admin/)に入ってみて、テストデータを登録してみる。まずはギター練習と筋トレと骨董カメラという3つのサークルを登録した。

次にMikeくん以外にもKevinとAnnaを登録。

3人と3つのサークルを適当に紐つけてあげる。(annaは全部、kevinはカメラとギター、mikeは筋トレとギター)

いよいよViewを。そこまで複雑なことはしていないが、ModelFormを使っているのとredirectなんかの意味合いがわからない場合はDjangoの公式チュートリアルなどで確認するとよい。
cirecle_boardはサークルの伝言板ページになる。ひとことのメッセージが投稿可能なのでフォームを出しており、POSTデータが投稿されたらMessageクラスを利用してSaveを行っている。
circle_switchはrequest.session[‘circle’]というところに入るcircleのidを変更するだけというシンプルな処理をして伝言板にリダイレクトしている。一応、参加していない人は弾くという分岐だけは実装している。

[app/views.py]
from django.shortcuts import render, get_object_or_404, redirect
from django.http import Http404
from .models import *
from .forms import MessageForm
from django.utils import timezone as tz

def circle_board(request):

    context={}

    context['circle'] = get_object_or_404(Circle, pk=request.circle.id)
    context['swcs'] = Joining.objects.filter(user=request.user)\
        .exclude(circle=request.circle.id)
    context['form'] = MessageForm()
    context['messages'] = Message.objects.filter(circle=request.circle)

    if(request.method == 'POST'):

        p = request.POST
        m = Message(
            circle=request.circle,
            user_posted=request.user,
            body=p['body'],
            posted_at=tz.now(),
        )
        m.save()

        return redirect('app:circle_board')

    return render(request, 'circle_board.html', context)

def circle_switch(request, to_circle):

    j = Joining.objects.filter(user=request.user,circle=to_circle).first()
    if(j is None):
        raise Http404('Not be joining to this circle.')

    request.session['circle'] = to_circle

    return redirect('app:circle_board')

上で、MessageFormと出てきたModelformをappフォルダの下にforms.pyとして作成。

[app/forms.py]
from django import forms
from .models import Message

class MessageForm(forms.ModelForm):

    class Meta:
        model = Message
        fields = ["body"]

Templateを作成。まずはapp/temlatesフォルダを切った上で、そこにcircle_board画面のhtmlを置く。
大きく3ブロックあることを確認してほしい。
1: サークルの名称を大きく表示、ログイン中のユーザとログアウト用のリンクを表示。
2: 他のサークルのボードへ切り替え(circle_switch)をする
3: 伝言メッセージの投稿フォームと、投稿済みのメッセージの表示

[app/templates/circle_board.html]
<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>Circle Board</title>
  </head>
  <body>
    <h1>Circle: {{circle.name}}</h1>
    <p>Hi {{request.user.username}}. <a href="{% url 'logout' %}">Logout</a></p>


    <h2>Change Circle</h2>
      {% for c in swcs %}
        <p><a href="{% url 'app:circle_switch' c.circle.id %}">
          {{c.circle.name}}
        </a></p>
      {% endfor %}



    <h2>Message Board</h2>

    <form method="POST"> {% csrf_token %}
        <fieldset>
          {% for field in form %}
            <label>{{ field.label_tag }}</label>
            <div>
                {{ field }}
                {{ field.errors }}
            </div>
          {% endfor %}
          <input name="submit" type="submit" value="Post" />
        </fieldset>
    </form>

    {% for m in messages %}
    <p>
      {{m.user_posted.username}} sayid in {{m.posted_at}} : {{m.body}}
    </p>
    {% endfor %}


  </body>
</html>

それと、ログイン画面用に一つだけtemplatesの下にregistrationというフォルダを掘ってファイルを置く。

[app/templates/login.html]
</<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>Circle Board</title>
  </head>
  <body>

    <h2>Login</h2>
    <form method="post">
      {% csrf_token %}
      {{ form.as_p }}
      <button type="submit">Login</button>
    </form>

  </body>
</html>

あとは、ユーザが今どこのサークルを見ているのかをSessionで管理したいのでミドルウェアを一つ作る。やっていることはシンプルにいうと3つくらいで
「アクセスを受けたときにユーザがログオンしていてまだrequest.session[‘circle’]がセットされていない場合は適当なものを一つ選ぶ」
「request.session[‘circle’]にIDの数値が保存されていたら、Circleオブジェクトをrequest.circleにセットする=Viewでrequest.circleがいきなり使える」
「ログオンしているユーザがどこのCircleにも所属していない場合はエラーになる(return “error” とかいま時点は適当)」

from app.models import Circle, Joining
from django.shortcuts import redirect,get_object_or_404

def AppMiddleware(get_response):

    def middleware(request):

        if request.user.is_authenticated:
            if('circle' not in request.session):
                j = Joining.objects.filter(user=request.user).first()
                if j is None:

                    # the user is orfan!!
                    return "error"

                else:
                    # get company and set session from top hire records.
                    request.session['circle'] = j.circle.id
                    circle = j.circle

            else:
                circle = get_object_or_404(Circle, pk=request.session['circle'])

        else:
            circle = None

        request.circle = circle

        response = get_response(request)
        return response

    return middleware

さて動かして見ると、こんなアプリがちゃんと動いた。Mikeでログインしていてギターのサークルにいる。Mikeが参加している他のサークルとしては筋トレがあるのでChange Circleにはそれが表示されている。
Message Boardには他のユーザが投稿したメッセージも含めて表示されている。

これで基本的なアプリは作り終えたとする。今の所Circleの新規追加画面やユーザを新規に登録する画面はAdmin画面にしかないが、これは認証をFirebaseを使ってGoogleなどのソーシャルサービスとの連携で作る練習用の前提なので、そこへ進むことにする。

Firebase認証を導入

この簡単なアプリにFirebase認証を組み込んでいく。40時間くらい調べまくってようやく大体動くようになった。

ひとまずFirebaseにのアカウントを登録して以下の画面で </> のWebを選択し、あれこれみつつなんとなくこの画面までたどり着く。

メール/パスワード認証と、GoogleとTwitterあたりをログオン方法には入れておきたいので、ここはWebで調べながら実施。Googleは簡単だった気がするけど、TwitterについてはElevated Accessの申請というのが必要で少し手間取った。手順のメモをここは残していない。

Firebase側が大体整ったならば、アプリ側を作っていく。
まず、全体的に以下の構成となっている。結構ややこしいけど最低限認証出やりたいことをやっていくとほぼこれくらいにはなる気がする。

Portal

DjangoとしてViewを一つ切る。やっていることはFirebase Auth UIをほぼマニュアル通りに表示するだけ。
もしすでにDjangoアプリに認証済みであればアプリのトップページ(circle_board)に飛ばすが、そうでなければportal.htmlをレンダリングするだけ。

[views.py]
def portal(request):

    if(request.user.is_authenticated):
        return redirect('app:circle_board')

    return render(request, 'portal.html')

templates/portal.htmlは、ほぼマニュアル通りにFirebase Auth UIを表示させる。
ただ、firebaseそのもののマニュアルとかだとfirebase-app.jsとかを使わせようとしくるが、”-compat”付きのファイルを選ぶように注意。そうでなければmoduleとして利用しなければならなくなり、Javascriptあまり詳しくないけど、<script type=”module”>ってのには気をつけないと恐ろしい沼にはまる。あとconfigに関しては環境ごとにことなるので調べて設定が必要。signInSuccessURLというのが、次のBridgeというViewに飛ばしている部分になる。

[teemplates/portal.html]
{% load static %}
<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <script src="https://www.gstatic.com/firebasejs/9.11.0/firebase-app-compat.js"></script>
    <script src="https://www.gstatic.com/firebasejs/9.11.0/firebase-auth-compat.js"></script>
    <script type="text/javascript">
      var config = {
        apiKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        authDomain: "xxxxxxxxxxxx.firebaseapp.com",
        projectId: "xxxxxxxxxxxxxxxx",
        storageBucket: "xxxxxxxxxxxx.firebaseapp.com",
        messagingSenderId: "333333333333333",
        appId: "9:9999999999999:web:01234567890123456",
      };
      firebase.initializeApp(config);
    </script>
    <script src="https://www.gstatic.com/firebasejs/ui/3.1.1/firebase-ui-auth__{{request.LANGUAGE_CODE}}.js"></script>
    <link type="text/css" rel="stylesheet" href="https://www.gstatic.com/firebasejs/ui/3.1.1/firebase-ui-auth.css" />
    <title>SimpleCircle Logon</title>
  </head>
  <body>

    <h1>Simple Circle Service</h1>

    <div id="firebaseui-auth-container"></div>
    <div id="loader">Loading...</div>

    <script type="text/javascript">

      var uiConfig = {
        callbacks: {
          signInSuccessWithAuthResult: function(authResult, redirectUrl) {
            return true;
          },
          uiShown: function() {
            document.getElementById('loader').style.display = 'none';
          }
        },
        signInFlow: 'redirect',
        signInSuccessUrl: 'http://localhost:8000/app/bridge',
        signInOptions: [
          firebase.auth.EmailAuthProvider.PROVIDER_ID,
          firebase.auth.GoogleAuthProvider.PROVIDER_ID,
          firebase.auth.TwitterAuthProvider.PROVIDER_ID,
        ],

        tosUrl: 'http://localhost:8000/app/term_of_service',
        // Privacy policy url/callback.
        privacyPolicyUrl: 'http://localhost:8000/app/privacy_policy',

      };

      var ui = new firebaseui.auth.AuthUI(firebase.auth());
      ui.start('#firebaseui-auth-container', uiConfig);
    </script>
  </body>
  </body>
</html>

これで以下が(たぶん)表示される。

Bridge

こっちもDjangoのViewは全く同じ。

def bridge(request):

    if(request.user.is_authenticated):
        return redirect('app:circle_board')

    return render(request, 'bridge.html')

問題はHTMLとJavascriptでそこそこのややこしい。このHTMLは白紙のページしか見えなくなっている。
FirebaseのonAuthStatusChangedっていうのが、Firebase Authのブラウザのログオン情報が変更されたときにトリガされるものっていう説明になっているのだけど、どうやらページがロードされたらすぐ確認に行くようなのでPortalから飛ばされてきたときに起動するよう。

その時点でFirebase的にログオンできてない場合、Portalに返す。
ログオンできている場合は後で解説する「trytoken」というDjango側で自作するAPIに、Firebaseからuser.getIdToken()で受け取ったトークンを送って、Django側、つまりサーバサイドでもFirebaseにトークンを送って真贋判定を行っている。JSON.stringifyだけだと、なぜかトークンが”xxxxxx”ってダブルクォーテーションで囲われたままになってえーって思ったけど、ネットで調べてもよくわからず、勘でevalしてやったらなんかうまく行った。
そしてJqueryの$.postでAPIを検証させている。
APIが返してきた結果により
 FirebaseのTokenが通らなかった → Portalへリダイレクト
 Tokenが通り、ユーザIDで照合したらDjangoアプリとしても登録済みのユーザだった → ログオン処理はAPI側で終わらせてあげているので、アプリのトップページにリダイレクトする。
 Tokenが通ったが、お初の認証の人の場合、GoogleやTwitter認証の場合は一気にアプリ側のユーザ登録もAPIで終わっているので、アプリのトップページに迎え入れる。
 Tokenが通ってお初の人、またはメール/パスワード認証でメールアドレスの検証が終わっていない人の場合にはリンク付きのメールを送ってそのリンクを分でもらって初めてお迎えすることにするので<div id=”email_verify”>の部分を表示させてメールボックスを見るように誘導している。

{% load static %}
<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <script src="https://www.gstatic.com/firebasejs/9.11.0/firebase-app-compat.js"></script>
    <script src="https://www.gstatic.com/firebasejs/9.11.0/firebase-auth-compat.js"></script>
    <script type="text/javascript">
      var config = {
        apiKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        authDomain: "xxxxxxxxxxxx.firebaseapp.com",
        projectId: "xxxxxxxxxxxxxxxx",
        storageBucket: "xxxxxxxxxxxx.firebaseapp.com",
        messagingSenderId: "333333333333333",
        appId: "9:9999999999999:web:01234567890123456",
      };
      firebase.initializeApp(config);
    </script>
    <script type="text/javascript" src="{% static 'js/jquery/jquery.min.js' %}"></script>
    <title>SimpleCircle Logon</title>
  </head>
  <body>

    <div id="email_verify" class="email_verify" style="display: none;">
      <p>Email Verification mail is sent to your address.</p>
      <p>Please confirm it and follow the link. you can close this window.</p>
    </div>
    <script type="text/javascript">

      firebase.auth().onAuthStateChanged(user => {
        console.log(user.displayName)
        if (user == null){
          window.location.href = "{% url 'app:portal' %}";
        } else {

            user.getIdToken(true).then((data) => {

              var token = eval(JSON.stringify(data));

              $.post({
                  url: "{% url 'app:trytoken' %}",
                  data: {
                    'token': token,
                    'csrfmiddlewaretoken' : '{{ csrf_token }}'
                  },
                  dataType: 'json',
                  success: function(rjson){
                    console.log(rjson.vertoken);
                    if (rjson.vertoken == false) {
                      window.location.href = "{% url 'app:portal' %}";
                    }else if(rjson.dloggedin == true){
                      console.log('logged on to django, redirect.');
                      window.location.href = "{% url 'app:circle_board' %}";
                    }else {
                      if(rjson.emailvalid == false){
                        console.log('email has not verified yet.');
                        document.getElementById('email_verify').style.display = 'block';
                        var uri = new URL(window.location.href);
                        var actionCodeSettings = {
                          url: uri.origin + "{% url 'app:bridge' %}",
                          handleCodeInApp: false
                        };

                        firebase.auth().currentUser.sendEmailVerification(actionCodeSettings);
                      }
                    }
                  },
                  error: function(){
                    alert('Server Error');
                  }

              });
          });
        }
      });
    </script>
  </body>
  </body>
</html>

trytoken API

これはAjaxでFirebaseのトークンを検証する、Bridgeから呼んでいたもの。HTMLのTemplateはいらなくてDjangoのapp/views.pyだけを作ってやればよい。
まずPOSTで”token”というキーが何かしら来ていなければ当然エラー。
firebase_adminというパッケージはpipでインストールしてimportしておいてやる必要がある。そしてinitialize_appしてあげるのだが、なぜかif notですでにinitializeが終わってないか判定後にしてあげないとエラーが出る。ここの理屈はわからないけどとりあえずこうしたら無難に動いた。jsonで別途Firebaseのコンソールで作る(プロジェクトの設定→アカウント→秘密鍵の生成)Service Account Keyなるものをを食わせる。
で、auth.verify_id_tokenということをしてあげると裏でfirebaseに検証をかけに行ってくれてこの辺は簡単。firebase上で持っているuidを返してくるのでこれをDjangoのusernameとして使う。Userモデルが取得できればdjango.contrib.authのlogin(request, user)というのを呼んでやればDjango側でのログイン処理ができてしまうので、それをBridgeのJavascriptへ返してあげればリダイレクトでアプリ内にお迎えができる。
Firebaseのトークン検証では、Email認証が終わっているかどうかを’email_verified’という項目で返してくれているので、メール認証が終わっていなければ、Bridgeの画面で認証を促す(前述)。
GoogleやTwitter認証の人はここは必ずTrueだったりするので、メール認証が終わっている人も含めて、ここまできたらDjangoアプリにUserを登録してあげて、パスワードは無効なものとし(set_unusable_password)、ログオンさせてあげてデフォルトのサークルボードも作ってあげて迎え入れる。

[app/views.py]
from django.contrib.auth import login
from django.contrib.auth.models import User
import firebase_admin
from firebase_admin import credentials, auth

def trytoken(request):

    id_token = request.POST.get('token')
    if(id_token is None):
        return JsonResponse({
            'dloggedin':False,'emailvalid':False,
            'vertoken':False,'message':str(e)
        })

    if not firebase_admin._apps:
        cred = credentials.Certificate(
            os.path.join('/xxxx/xxxxxx/sak.json'))
        firebase_admin.initialize_app(cred)

    try:
        decoded_token = auth.verify_id_token(id_token)
    except Exception as e:
        return JsonResponse({
            'dloggedin':False,'emailvalid':False,
            'vertoken':False,'message':str(e)
        })

    uid = decoded_token["uid"]

    user = User.objects.filter(last_name=uid).first()

    if(user is not None):
        login(request, user)
        return JsonResponse({
            'dloggedin':True,'emailvalid':True,
            'error':False
        })

    if(decoded_token['firebase']['sign_in_provider'] == 'password'):
        if(decoded_token['email_verified'] == False):
            return JsonResponse({
                'dloggedin':False,'emailvalid':False,
                'vertoken':True
            })

    # Email is verified so create user
    user = User.objects.create_user(
        username = decoded_token.get('user_id'),
        email = decoded_token.get('email'),
        first_name = decoded_token['name'],
        last_name = decoded_token['uid']
    )
    user.set_unusable_password()
    user.save()

    login(request, user)

    circle = Circle(
        name = 'Personal Board of ' + user.first_name
    )
    circle.save()

    j = Joining(
        user = user,
        circle = circle
    )
    j.save()

    return JsonResponse({
        'dloggedin':True,'emailvalid':True,
        'vertoken':True
    })

肝の部分はここまででほとんどOK。

djangoのsettings.py

Djangoのsettings.pyを少し変える必要がある。
LocaleMiddleWareは、Portalでfirebase authuiに言語を教えて上げるために追加した。
LOGOUT_REDIRECT_URLは、logout_firebaseという画面に一度飛ばして上げて、Firebaseとブラウザの関係もちょん切って上げるために。あとはGlobalLoginRequiredMiddlewareに教えてあげるための認証を必要としないパスをPUBLIC_PATHとして設定してやる。

MIDDLEWARE = [
    ......
    'django.middleware.locale.LocaleMiddleware',
    ......
]

LOGIN_REDIRECT_URL = '/app/circle_board'
LOGOUT_REDIRECT_URL = '/app/logout_firebase'

PUBLIC_PATHS = [
    '/app/portal',
    '/app/bridge',
    '/app/trytoken',
    '/app/logout_firebase',
]

logout_firebase

viewはこれだけでいい。

def logout_firebase(request):
    return render(request, 'logout_firebase.html')

HTML側で、ブラウザとFirebaseのログオン状態を切ってから、Portalにリダイレクトしてやる。
なぜかこれだけModuleでやってるので後で書き直す。window.xxxx = function () => { };みたいなことをやってグローバルに呼べる関数を作ってあげたりしないといけなくて、これほんとどうかしているよな・・・。

{% load static %}
<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <script type="text/javascript" src="{% static 'js/jquery/jquery.min.js' %}"></script>
    <title>Logout also from Firebase</title>
  </head>
  <body>

    <h1>Simple Circle Service</h1>

    <input type='hidden' value="" name="status" id="status" />

    <p id="exit" style="visibility: hidden;"><a href="{% url 'app:portal' %}">Back to Logon Screen.</a></p>

    <script type="text/javascript">
      setInterval(nnn,1000);
      function nnn(){
        logon_check();
        if($('#status').val() == 'logout'){
            $("#exit").css('visibility','visible');
        }
      }
    </script>

    <script type="module">
      import { initializeApp } from 'https://www.gstatic.com/firebasejs/9.10.0/firebase-app.js'
      import { getAnalytics } from 'https://www.gstatic.com/firebasejs/9.10.0/firebase-analytics.js'
      import { getAuth, signInWithEmailAndPassword, signOut, signInWithRedirect,
        onAuthStateChanged, GoogleAuthProvider, getRedirectResult }
       from 'https://www.gstatic.com/firebasejs/9.10.0/firebase-auth.js'
      import { getFirestore } from 'https://www.gstatic.com/firebasejs/9.10.0/firebase-firestore.js'

      const firebaseConfig = {
        apiKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        authDomain: "xxxxxxxxxxxx.firebaseapp.com",
        projectId: "xxxxxxxxxxxxxxxx",
        storageBucket: "xxxxxxxxxxxx.firebaseapp.com",
        messagingSenderId: "333333333333333",
        appId: "9:9999999999999:web:01234567890123456",
      };

      // Initialize Firebase
      const app = initializeApp(firebaseConfig);
      const auth = getAuth(app);
 
      window.logon_check = () => {
        console.log(auth.currentUser);
      }

      signOut(auth).then(() => {
        // Sign-out successful.
        console.log("Logout Successful.")
        window.document.getElementById("status").value = "logout";
      }).catch((error) => {
        // An error happened.
      });

    </script>
  </body>
</html>

以上で、Firebaseを使ったDjangoアプリへのログオンの仕組みができた。長い道のりだった。今後なにか公開サービスアプリを作るぞと思ったらこれで大体やれそうで嬉しい。

Lubuntu で作業してたけどFirefoxが急に固まってこまってたけどSWAP足したらすぐ解決

プログラミング作業用の環境をHyper-VでLubuntuで物理メモリは2G割り当てて作って使っている。

VS Code突っ込むのはしんどかろうと思ったのでAtomエディタ+Terminal+Firefoxだけを立ち上げて、それで必要な作業は全部できるようになったので、いやいやいい時代だとやってたけど、どうしても時々Firefoxが急にハングしてしまう。だんだん重くなって固まるとかでなくて突然。

うーん、って思ってたけど、あ、スワップじゃないか、とおもったら案の定Swapがデフォルトだと512Mしかない。2GB追加してみる。

$ sudo dd if=/dev/zero of=/swapfile2 bs=1024 count=2M
2097152+0 records in
2097152+0 records out
2147483648 bytes (2.1 GB, 2.0 GiB) copied, 7.09391 s, 303 MB/s

$ sudo mkswap /swapfile2
$ sudo chmod 0600 /swapfile2

$ sudo vim /etc/fstab
[add]
/swapfile2                                swap           swap    defaults   0 0

$ sudo swapon /swapfile2

$ free -h
total        used        free      shared  buff/cache   available
Mem:           1.9Gi       1.2Gi        80Mi       209Mi       598Mi       310Mi
Swap:          2.5Gi       943Mi       1.6Gi

たったこれだけで完了。簡単かよ。しかもFirefox、超安定。幸せだ。生まれてきてよかった。

Grafanaを活用して自前Webアプリでダッシュボードを作る(4) – 厳しそうなので打ち切る

Grafanaを使って、自分の工数管理Webアプリのダッシュボードページにかっこいいパネルを並べる構想をしていたけど、この使いみちは厳しいということがわかったので諦める。

  • Time Seriesのデータであることが前提すぎる。Time Series以外のデータを扱う方法がこんなのとか見つかったんだけど、実際やってみてある程度マシにはなるけど、これで全部やるかとかはやってられない。
  • PanelだけEmbedで自分のアプリに流用する使い方がしたかったけど、やっぱりそんな曲がった使い方のために作られていないので、データのフィルタとか権限制御に無理がある。

やっぱりPythonでやるしかないってことでグラフに関してはSeabornを確認して使っていくことで決心。

Excelのグラフ機能って偉大だな・・・