白皇集落営農システム変更通知

This commit is contained in:
Akira
2026-02-21 15:29:27 +09:00
parent f9909500e2
commit 6cf66d2806
4 changed files with 370 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(./wm-api.sh:*)",
"Bash(python3:*)",
"Bash(curl:*)",
"Bash(python3.exe -m json.tool:*)"
]
}
}

View File

@@ -0,0 +1,309 @@
# Windmill 通知ワークフロー連携仕様
> **作成日**: 2026-02-21
> **対象システム**: 白皇集落営農組合 統合システム (`shiraou.keinafarm.net`)
> **目的**: 予約・実績の変更をLINEで管理者に通知するWindmillワークフローの実装仕様
---
## 1. 概要
統合システム側が「変更履歴取得API」を提供する。
Windmillは定期的にこのAPIをポーリングし、変更があればLINEへ通知する。
```
Windmill定期実行
└→ GET https://shiraou.keinafarm.net/reservations/api/changes/?since=<前回実行時刻>
└→ 変更一覧(予約・実績)を取得
└→ 変更があればLINE Messaging APIへ通知
└→ 前回実行時刻を更新
```
---
## 2. 変更履歴取得API
### エンドポイント
```
GET https://shiraou.keinafarm.net/reservations/api/changes/
```
### 認証
`X-API-Key` ヘッダーにAPIキーを指定する統合システム管理者から取得
```
X-API-Key: <NOTIFICATION_API_KEY>
```
APIキーが不正な場合は `401 Unauthorized` が返る。
### クエリパラメータ
| パラメータ | 型 | 必須 | 説明 |
|-----------|-----|------|------|
| `since` | ISO8601文字列 | 必須 | この日時以降の変更を取得する |
**`since` の形式例**:
- `2026-02-21T10:00:00` ナイーブ、JSTとして扱われる
- `2026-02-21T10:00:00+09:00` (タイムゾーン付き、推奨)
### レスポンス200 OK
```json
{
"checked_at": "2026-02-21T12:00:00+09:00",
"since": "2026-02-21T10:00:00+09:00",
"reservations": [
{
"operation": "create",
"reservation_id": 123,
"user_name": "田中太郎",
"machine_name": "トラクター",
"start_at": "2026-02-25T09:00:00+09:00",
"end_at": "2026-02-25T12:00:00+09:00",
"operated_at": "2026-02-21T11:30:00+09:00",
"operator_name": "田中太郎",
"reason": ""
},
{
"operation": "cancel",
"reservation_id": 120,
"user_name": "佐藤花子",
"machine_name": "田植機",
"start_at": "2026-02-22T08:00:00+09:00",
"end_at": "2026-02-22T17:00:00+09:00",
"operated_at": "2026-02-21T11:45:00+09:00",
"operator_name": "佐藤花子",
"reason": ""
}
],
"usages": [
{
"operation": "update",
"usage_id": 456,
"user_name": "山田次郎",
"machine_name": "コンバイン",
"amount": 4.0,
"unit": "時間",
"start_at": "2026-02-20T08:00:00+09:00",
"end_at": "2026-02-20T12:00:00+09:00",
"operated_at": "2026-02-21T11:55:00+09:00",
"operator_name": "管理者A",
"reason": "記録ミスのため修正"
}
]
}
```
### operation の値一覧
**予約reservations**:
| 値 | 意味 |
|----|------|
| `create` | 予約が作成された |
| `update` | 予約の日時・機械が変更された |
| `cancel` | 予約がキャンセルされた |
**実績usages**:
| 値 | 意味 |
|----|------|
| `create` | 実績が登録された |
| `update` | 実績が修正された |
| `delete` | 実績が削除された |
### 変更なしの場合
`reservations``usages` が両方空配列になる。通知は不要。
```json
{
"checked_at": "2026-02-21T12:05:00+09:00",
"since": "2026-02-21T12:00:00+09:00",
"reservations": [],
"usages": []
}
```
### エラーレスポンス
| ステータス | 原因 |
|-----------|------|
| `401 Unauthorized` | APIキーが不正または未設定 |
| `400 Bad Request` | `since` パラメータが欠落または不正な日時形式 |
---
## 3. Windmillワークフロー設計
### 3.1 スケジュール
- **実行間隔**: 5分毎`*/5 * * * *`
- 農業機械の予約という用途上、数分の遅延は許容範囲
### 3.2 状態管理(前回実行時刻)
Windmillの **Resource** または **State Variable** に前回実行時刻を保存する。
```
変数名: last_checked_at
初期値: (初回実行時は現在時刻 - 10分 を使用)
```
### 3.3 ワークフロー全体フロー(擬似コード)
```python
# 1. 前回実行時刻を取得
last_checked = get_state("last_checked_at") or (now() - 10 minutes)
# 2. 変更履歴を取得
response = GET "https://shiraou.keinafarm.net/reservations/api/changes/"
params: { since: last_checked.isoformat() }
headers: { "X-API-Key": NOTIFICATION_API_KEY }
# 3. 変更があればLINEに通知
if response.reservations or response.usages:
message = format_line_message(response)
send_line_message(LINE_CHANNEL_ACCESS_TOKEN, LINE_USER_ID, message)
# 4. 前回実行時刻を更新
set_state("last_checked_at", response.checked_at)
```
### 3.4 LINEメッセージのフォーマット例
```python
def format_line_message(data):
lines = ["📋 営農システム 変更通知\n"]
for r in data["reservations"]:
start = r["start_at"][:16].replace("T", " ")
end = r["end_at"][:16].replace("T", " ")
if r["operation"] == "create":
icon = "🟢"
label = "予約作成"
elif r["operation"] == "update":
icon = "🔵"
label = "予約変更"
elif r["operation"] == "cancel":
icon = "🔴"
label = "予約キャンセル"
lines.append(f"{icon} {label}")
lines.append(f" 機械: {r['machine_name']}")
lines.append(f" 利用者: {r['user_name']}")
lines.append(f" 日時: {start}{end}")
if r["reason"]:
lines.append(f" 理由: {r['reason']}")
lines.append("")
for u in data["usages"]:
start = u["start_at"][:16].replace("T", " ")
if u["operation"] == "create":
icon = "🟢"
label = "実績登録"
elif u["operation"] == "update":
icon = "🔵"
label = "実績修正"
elif u["operation"] == "delete":
icon = "🔴"
label = "実績削除"
lines.append(f"{icon} {label}")
lines.append(f" 機械: {u['machine_name']}")
lines.append(f" 利用者: {u['user_name']}")
lines.append(f" 利用量: {u['amount']}{u['unit']}")
lines.append(f" 日: {start[:10]}")
if u["reason"]:
lines.append(f" 理由: {u['reason']}")
lines.append("")
return "\n".join(lines).strip()
```
**出力例**:
```
📋 営農システム 変更通知
🟢 予約作成
機械: トラクター
利用者: 田中太郎
日時: 2026-02-25 09:00 〜 2026-02-25 12:00
🔴 予約キャンセル
機械: 田植機
利用者: 佐藤花子
日時: 2026-02-22 08:00 〜 2026-02-22 17:00
🔵 実績修正
機械: コンバイン
利用者: 山田次郎
利用量: 4.0時間
日: 2026-02-20
理由: 記録ミスのため修正
```
---
## 4. Windmill側の環境変数シークレット
| 変数名 | 説明 | 設定場所 |
|--------|------|---------|
| `NOTIFICATION_API_KEY` | 統合システムのAPIキー | Windmill Secret |
| `LINE_CHANNEL_ACCESS_TOKEN` | LINE Messaging API チャネルアクセストークン | Windmill Secret |
| `LINE_USER_ID` / `LINE_GROUP_ID` | 通知先のユーザーIDまたはグループID | Windmill Secret |
---
## 5. 統合システム側の設定django側の作業
本番サーバーの `docker-compose.yml` に以下の環境変数を追加し、デプロイする。
```yaml
environment:
- NOTIFICATION_API_KEY=<任意の強いランダム文字列>
```
**APIキーの生成例**:
```bash
openssl rand -hex 32
```
---
## 6. 動作確認方法
### curlで直接テスト
```bash
# 変更なし直近1分
curl -H "X-API-Key: <キー>" \
"https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-02-21T11:59:00%2B09:00"
# 広い範囲で変更を取得(初期確認用)
curl -H "X-API-Key: <キー>" \
"https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-01-01T00:00:00"
# APIキーなし → 401が返ることを確認
curl "https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-01-01T00:00:00"
```
### ダッシュボードで変更確認
管理者アカウントで `https://shiraou.keinafarm.net/reservations/dashboard/` にアクセスすると、
直近30件の予約操作履歴・実績操作ログを確認できる。
Windmillワークフローのデバッグ時に「APIが何を返すべきか」の確認に使える。
---
## 7. 注意事項
- `since` に渡す時刻は **前回の `checked_at`** を使う(`since` ではなく `checked_at` を保存すること)
- 同一の変更が2回通知されないよう、状態管理を確実に行う
- ワークフローがエラーで終了した場合、`last_checked_at` を更新しないようにすること(次回実行時に再取得される)
- APIキーは定期的にローテーションすること変更時は統合システム側の環境変数も同時に更新

View File

@@ -0,0 +1,27 @@
{
"path": "f/shiraou/shiraou_notification",
"summary": "白皇集落営農 変更通知",
"description": "shiraou.keinafarm.net の予約・実績変更をポーリングし、変更があればLINEで管理者に通知する。5分毎に実行。",
"value": {
"modules": [
{
"id": "a",
"summary": "変更確認・LINE通知",
"value": {
"type": "rawscript",
"language": "python3",
"content": "import urllib.request\nimport urllib.parse\nimport json\nimport ssl\nfrom datetime import datetime, timezone, timedelta\nimport wmill\n\nJST = timezone(timedelta(hours=9))\n\n\ndef main():\n # シークレット取得\n api_key = wmill.get_variable(\"u/admin/NOTIFICATION_API_KEY\")\n line_token = wmill.get_variable(\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\")\n line_to = wmill.get_variable(\"u/admin/LINE_TO\")\n\n # 前回実行時刻を取得(初回は現在時刻 - 10分\n state = wmill.get_state() or {}\n last_checked = state.get(\"last_checked_at\")\n if last_checked:\n since = last_checked\n else:\n since = (datetime.now(JST) - timedelta(minutes=10)).isoformat()\n\n print(f\"[通知] 変更確認: since={since}\")\n\n # API呼び出し\n ssl_ctx = ssl.create_default_context()\n ssl_ctx.check_hostname = False\n ssl_ctx.verify_mode = ssl.CERT_NONE\n\n params = urllib.parse.urlencode({\"since\": since})\n url = f\"https://shiraou.keinafarm.net/reservations/api/changes/?{params}\"\n\n req = urllib.request.Request(url, headers={\"X-API-Key\": api_key})\n with urllib.request.urlopen(req, context=ssl_ctx, timeout=30) as resp:\n data = json.loads(resp.read().decode(\"utf-8\"))\n\n checked_at = data[\"checked_at\"]\n reservations = data.get(\"reservations\", [])\n usages = data.get(\"usages\", [])\n\n print(f\"[通知] checked_at={checked_at}, 予約={len(reservations)}件, 実績={len(usages)}件\")\n\n # 変更があればLINE通知エラー時は状態を更新しない\n if reservations or usages:\n message = _format_message(reservations, usages)\n _send_line(line_token, line_to, message)\n print(\"[通知] LINE送信完了\")\n else:\n print(\"[通知] 変更なし、通知スキップ\")\n\n # 正常完了時のみ状態更新\n wmill.set_state({\"last_checked_at\": checked_at})\n print(f\"[通知] last_checked_at更新: {checked_at}\")\n\n return {\n \"since\": since,\n \"checked_at\": checked_at,\n \"reservations_count\": len(reservations),\n \"usages_count\": len(usages),\n \"notified\": bool(reservations or usages),\n }\n\n\ndef _format_message(reservations, usages):\n lines = [\"\\U0001f4cb 営農システム 変更通知\\n\"]\n\n OP_R = {\n \"create\": (\"\\U0001f7e2\", \"予約作成\"),\n \"update\": (\"\\U0001f535\", \"予約変更\"),\n \"cancel\": (\"\\U0001f534\", \"予約キャンセル\"),\n }\n OP_U = {\n \"create\": (\"\\U0001f7e2\", \"実績登録\"),\n \"update\": (\"\\U0001f535\", \"実績修正\"),\n \"delete\": (\"\\U0001f534\", \"実績削除\"),\n }\n\n for r in reservations:\n start = r[\"start_at\"][:16].replace(\"T\", \" \")\n end = r[\"end_at\"][:16].replace(\"T\", \" \")\n icon, label = OP_R.get(r[\"operation\"], (\"\\u26aa\", r[\"operation\"]))\n lines += [\n f\"{icon} {label}\",\n f\" 機械: {r['machine_name']}\",\n f\" 利用者: {r['user_name']}\",\n f\" 日時: {start} \\uff5e {end}\",\n ]\n if r.get(\"reason\"):\n lines.append(f\" 理由: {r['reason']}\")\n lines.append(\"\")\n\n for u in usages:\n start = u[\"start_at\"][:16].replace(\"T\", \" \")\n icon, label = OP_U.get(u[\"operation\"], (\"\\u26aa\", u[\"operation\"]))\n lines += [\n f\"{icon} {label}\",\n f\" 機械: {u['machine_name']}\",\n f\" 利用者: {u['user_name']}\",\n f\" 利用量: {u['amount']}{u['unit']}\",\n f\" 日: {start[:10]}\",\n ]\n if u.get(\"reason\"):\n lines.append(f\" 理由: {u['reason']}\")\n lines.append(\"\")\n\n return \"\\n\".join(lines).strip()\n\n\ndef _send_line(token, to, message):\n payload = json.dumps({\n \"to\": to,\n \"messages\": [{\"type\": \"text\", \"text\": message}],\n }).encode(\"utf-8\")\n\n req = urllib.request.Request(\n \"https://api.line.me/v2/bot/message/push\",\n data=payload,\n headers={\n \"Authorization\": f\"Bearer {token}\",\n \"Content-Type\": \"application/json\",\n },\n method=\"POST\",\n )\n with urllib.request.urlopen(req, timeout=30) as resp:\n return resp.read().decode(\"utf-8\")\n",
"input_transforms": {},
"lock": ""
}
}
]
},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"order": [],
"properties": {},
"required": []
}
}

View File

@@ -61,6 +61,27 @@ case "${1:-help}" in
fi
api_post "/scripts/create" "$(cat "$2")"
;;
create-flow)
if [ -z "${2:-}" ]; then
echo "Usage: $0 create-flow <json-file>"
exit 1
fi
api_post "/flows/create" "$(cat "$2")"
;;
update-flow)
if [ -z "${2:-}" ] || [ -z "${3:-}" ]; then
echo "Usage: $0 update-flow <path> <json-file>"
exit 1
fi
api_put "/flows/update/$2" "$(cat "$3")"
;;
create-schedule)
if [ -z "${2:-}" ]; then
echo "Usage: $0 create-schedule <json-file>"
exit 1
fi
api_post "/schedules/create" "$(cat "$2")"
;;
run-script)
if [ -z "${2:-}" ]; then
echo "Usage: $0 run-script <path> [json-args]"
@@ -112,6 +133,9 @@ Windmill REST API ヘルパー
get-script <path> - スクリプトの詳細を取得
get-flow <path> - フローの詳細を取得
create-script <file> - JSONファイルからスクリプトを作成
create-flow <file> - JSONファイルからフローを作成
update-flow <path> <file> - フローを更新
create-schedule <file> - JSONファイルからスケジュールを作成
run-script <path> [args] - スクリプトを実行
run-flow <path> [args] - フローを実行
job-status <id> - ジョブのステータスを確認