Spaces:
Sleeping
Sleeping
github-actions[bot] commited on
Commit ·
90a0b2c
0
Parent(s):
Deploy GitHub snapshot to Hugging Face Space
Browse files- .dockerignore +13 -0
- .env.example +22 -0
- .github/workflows/deploy-huggingface-space.yml +100 -0
- .gitignore +9 -0
- Dockerfile +18 -0
- IniBukanBuatHackathon.json +236 -0
- README.md +102 -0
- app/__init__.py +1 -0
- app/cloudinary.py +61 -0
- app/config.py +36 -0
- app/main.py +50 -0
- app/models.py +53 -0
- app/services.py +120 -0
- app/workflow.py +73 -0
- pyproject.toml +22 -0
- scripts/upload_cloudinary_test.py +72 -0
- tests/test_workflow.py +186 -0
- uv.lock +0 -0
.dockerignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.github
|
| 3 |
+
.venv
|
| 4 |
+
.pytest_cache
|
| 5 |
+
.ruff_cache
|
| 6 |
+
.mypy_cache
|
| 7 |
+
__pycache__
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*.log
|
| 10 |
+
.env
|
| 11 |
+
img
|
| 12 |
+
tests
|
| 13 |
+
scripts
|
.env.example
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
APP_NAME=gungfi-webhook-be
|
| 2 |
+
LOG_LEVEL=INFO
|
| 3 |
+
|
| 4 |
+
# Keep this path aligned with the n8n template.
|
| 5 |
+
WEBHOOK_PATH=/cloudinary-trigger
|
| 6 |
+
|
| 7 |
+
# AI pipeline base URL. The app posts webhook data to /process.
|
| 8 |
+
AI_PIPELINE_URL=https://be-aipipeline.bluecoast-4238dc8b.eastasia.azurecontainerapps.io
|
| 9 |
+
AI_PIPELINE_TIMEOUT_SECONDS=60
|
| 10 |
+
|
| 11 |
+
# Supabase REST API. Use a service role key on the backend only.
|
| 12 |
+
SUPABASE_URL=https://your-project.supabase.co
|
| 13 |
+
SUPABASE_SERVICE_ROLE_KEY=replace-me
|
| 14 |
+
SUPABASE_TABLE=reports
|
| 15 |
+
|
| 16 |
+
# Cloudinary webhook signature validation.
|
| 17 |
+
# Cloudinary sends X-Cld-Signature and X-Cld-Timestamp.
|
| 18 |
+
CLOUDINARY_API_SECRET=replace-me
|
| 19 |
+
CLOUDINARY_CLOUD_NAME=replace-me
|
| 20 |
+
CLOUDINARY_API_KEY=replace-me
|
| 21 |
+
CLOUDINARY_SIGNATURE_REQUIRED=true
|
| 22 |
+
CLOUDINARY_SIGNATURE_TOLERANCE_SECONDS=7200
|
.github/workflows/deploy-huggingface-space.yml
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Deploy to Hugging Face Spaces
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- main
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
|
| 9 |
+
concurrency:
|
| 10 |
+
group: huggingface-space
|
| 11 |
+
cancel-in-progress: true
|
| 12 |
+
|
| 13 |
+
jobs:
|
| 14 |
+
test-and-deploy:
|
| 15 |
+
runs-on: ubuntu-latest
|
| 16 |
+
|
| 17 |
+
steps:
|
| 18 |
+
- name: Checkout repository
|
| 19 |
+
uses: actions/checkout@v4
|
| 20 |
+
with:
|
| 21 |
+
fetch-depth: 0
|
| 22 |
+
|
| 23 |
+
- name: Install uv
|
| 24 |
+
uses: astral-sh/setup-uv@v5
|
| 25 |
+
|
| 26 |
+
- name: Set up Python
|
| 27 |
+
uses: actions/setup-python@v5
|
| 28 |
+
with:
|
| 29 |
+
python-version: "3.12"
|
| 30 |
+
|
| 31 |
+
- name: Install dependencies
|
| 32 |
+
run: uv sync --frozen
|
| 33 |
+
|
| 34 |
+
- name: Run tests
|
| 35 |
+
env:
|
| 36 |
+
APP_NAME: gungfi-webhook-be
|
| 37 |
+
LOG_LEVEL: INFO
|
| 38 |
+
WEBHOOK_PATH: /cloudinary-trigger
|
| 39 |
+
AI_PIPELINE_URL: http://be-aipipeline-jeki.indonesiacentral.azurecontainer.io:8000
|
| 40 |
+
AI_PIPELINE_TIMEOUT_SECONDS: 60
|
| 41 |
+
SUPABASE_URL: https://example.supabase.co
|
| 42 |
+
SUPABASE_SERVICE_ROLE_KEY: test-service-role-key
|
| 43 |
+
SUPABASE_TABLE: reports
|
| 44 |
+
CLOUDINARY_API_SECRET: test-cloudinary-secret
|
| 45 |
+
CLOUDINARY_SIGNATURE_REQUIRED: true
|
| 46 |
+
CLOUDINARY_SIGNATURE_TOLERANCE_SECONDS: 7200
|
| 47 |
+
run: uv run pytest
|
| 48 |
+
|
| 49 |
+
- name: Deploy to Hugging Face Space
|
| 50 |
+
env:
|
| 51 |
+
HF_USERNAME: ${{ secrets.HF_USERNAME }}
|
| 52 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 53 |
+
HF_SPACE_ID: ${{ secrets.HF_SPACE_ID }}
|
| 54 |
+
run: |
|
| 55 |
+
if [ -z "$HF_USERNAME" ] || [ -z "$HF_TOKEN" ] || [ -z "$HF_SPACE_ID" ]; then
|
| 56 |
+
echo "Missing one or more required secrets: HF_USERNAME, HF_TOKEN, HF_SPACE_ID"
|
| 57 |
+
exit 1
|
| 58 |
+
fi
|
| 59 |
+
|
| 60 |
+
SPACE_ID="$HF_SPACE_ID"
|
| 61 |
+
SPACE_ID="${SPACE_ID#https://huggingface.co/spaces/}"
|
| 62 |
+
SPACE_ID="${SPACE_ID#http://huggingface.co/spaces/}"
|
| 63 |
+
SPACE_ID="${SPACE_ID%/}"
|
| 64 |
+
|
| 65 |
+
if ! echo "$SPACE_ID" | grep -Eq '^[^/]+/[^/]+$'; then
|
| 66 |
+
echo "HF_SPACE_ID must be owner/space-name or a Hugging Face Space URL."
|
| 67 |
+
exit 1
|
| 68 |
+
fi
|
| 69 |
+
|
| 70 |
+
rm -rf hf-space
|
| 71 |
+
mkdir hf-space
|
| 72 |
+
git archive --format=tar HEAD | tar -x -C hf-space
|
| 73 |
+
|
| 74 |
+
oversized_files="$(find hf-space -type f -size +10M -print)"
|
| 75 |
+
if [ -n "$oversized_files" ]; then
|
| 76 |
+
echo "Hugging Face rejects regular Git files larger than 10 MiB."
|
| 77 |
+
echo "$oversized_files"
|
| 78 |
+
exit 1
|
| 79 |
+
fi
|
| 80 |
+
|
| 81 |
+
cd hf-space
|
| 82 |
+
git init -b main
|
| 83 |
+
git config user.name "github-actions[bot]"
|
| 84 |
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
| 85 |
+
git add -A
|
| 86 |
+
git commit -m "Deploy GitHub snapshot to Hugging Face Space"
|
| 87 |
+
git remote add huggingface "https://${HF_USERNAME}:${HF_TOKEN}@huggingface.co/spaces/${SPACE_ID}"
|
| 88 |
+
|
| 89 |
+
for attempt in 1 2 3 4 5; do
|
| 90 |
+
if git push --force huggingface HEAD:main; then
|
| 91 |
+
exit 0
|
| 92 |
+
fi
|
| 93 |
+
|
| 94 |
+
sleep_seconds=$((attempt * 30))
|
| 95 |
+
echo "Hugging Face push failed on attempt ${attempt}. Retrying in ${sleep_seconds}s..."
|
| 96 |
+
sleep "$sleep_seconds"
|
| 97 |
+
done
|
| 98 |
+
|
| 99 |
+
echo "Hugging Face deploy failed after 5 attempts."
|
| 100 |
+
exit 1
|
.gitignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
.pytest_cache/
|
| 5 |
+
.ruff_cache/
|
| 6 |
+
.mypy_cache/
|
| 7 |
+
.env
|
| 8 |
+
*.log
|
| 9 |
+
img/
|
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
UV_SYSTEM_PYTHON=1
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
RUN pip install --no-cache-dir uv
|
| 10 |
+
|
| 11 |
+
COPY pyproject.toml uv.lock ./
|
| 12 |
+
RUN uv sync --frozen --no-dev
|
| 13 |
+
|
| 14 |
+
COPY app ./app
|
| 15 |
+
|
| 16 |
+
EXPOSE 7860
|
| 17 |
+
|
| 18 |
+
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
IniBukanBuatHackathon.json
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "My workflow",
|
| 3 |
+
"nodes": [
|
| 4 |
+
{
|
| 5 |
+
"parameters": {
|
| 6 |
+
"httpMethod": "POST",
|
| 7 |
+
"path": "cloudinary-trigger",
|
| 8 |
+
"options": {}
|
| 9 |
+
},
|
| 10 |
+
"name": "Cloudinary Webhook",
|
| 11 |
+
"type": "n8n-nodes-base.webhook",
|
| 12 |
+
"typeVersion": 1,
|
| 13 |
+
"position": [
|
| 14 |
+
3344,
|
| 15 |
+
992
|
| 16 |
+
],
|
| 17 |
+
"id": "ca173e05-e036-48be-99ae-42ce18e457ed",
|
| 18 |
+
"webhookId": "e6dc3997-4c85-42fc-adde-396c60a64029"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"parameters": {
|
| 22 |
+
"mode": "runOnceForEachItem",
|
| 23 |
+
"jsCode": "const body = $input.item.json.body || {};\nconst context = body.context || {};\nconst custom = context.custom || {};\n\nreturn {\n json: {\n id: custom.Id || null,\n latitude: custom.latitude || null,\n longitude: custom.longitude || null,\n description: custom.description || \"\",\n before_img_url: body.secure_url || \"\"\n }\n};"
|
| 24 |
+
},
|
| 25 |
+
"name": "Parse Data",
|
| 26 |
+
"type": "n8n-nodes-base.code",
|
| 27 |
+
"typeVersion": 2,
|
| 28 |
+
"position": [
|
| 29 |
+
3568,
|
| 30 |
+
992
|
| 31 |
+
],
|
| 32 |
+
"id": "d9621bf8-aa4f-434c-992f-f2d0088a250d"
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"parameters": {
|
| 36 |
+
"conditions": {
|
| 37 |
+
"boolean": [
|
| 38 |
+
{
|
| 39 |
+
"value1": "={{!!$node[\"Parse Data\"].json.id}}",
|
| 40 |
+
"value2": true
|
| 41 |
+
}
|
| 42 |
+
]
|
| 43 |
+
}
|
| 44 |
+
},
|
| 45 |
+
"name": "Loop Protection",
|
| 46 |
+
"type": "n8n-nodes-base.if",
|
| 47 |
+
"typeVersion": 1,
|
| 48 |
+
"position": [
|
| 49 |
+
3792,
|
| 50 |
+
992
|
| 51 |
+
],
|
| 52 |
+
"id": "fc38d8a7-421b-4239-8382-e7afa0feead2"
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"parameters": {
|
| 56 |
+
"method": "POST",
|
| 57 |
+
"url": "http://be-aipipeline-jeki.indonesiacentral.azurecontainer.io:8000/process-url",
|
| 58 |
+
"sendBody": true,
|
| 59 |
+
"specifyBody": "json",
|
| 60 |
+
"jsonBody": "={\n \"id\": \"{{$node[\"Parse Data\"].json.id}}\",\n \"longitude\": {{$node[\"Parse Data\"].json.longitude}},\n \"latitude\": {{$node[\"Parse Data\"].json.latitude}},\n \"before_img_url\": \"{{$node[\"Parse Data\"].json.before_img_url}}\"\n}",
|
| 61 |
+
"options": {
|
| 62 |
+
"timeout": 60000
|
| 63 |
+
}
|
| 64 |
+
},
|
| 65 |
+
"name": "Trigger BE-AIPipeline",
|
| 66 |
+
"type": "n8n-nodes-base.httpRequest",
|
| 67 |
+
"typeVersion": 3,
|
| 68 |
+
"position": [
|
| 69 |
+
4016,
|
| 70 |
+
992
|
| 71 |
+
],
|
| 72 |
+
"id": "8cef1520-4c71-44f4-96f9-8f1180a0ffa0",
|
| 73 |
+
"notes": "BE akan download image, process AI, UPDATE Supabase, lalu hapus temp file"
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"parameters": {
|
| 77 |
+
"assignments": {
|
| 78 |
+
"assignments": [
|
| 79 |
+
{
|
| 80 |
+
"id": "id-1",
|
| 81 |
+
"name": "id",
|
| 82 |
+
"value": "={{ $('Parse Data').item.json.id }}",
|
| 83 |
+
"type": "string"
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"id": "id-2",
|
| 87 |
+
"name": "destruct_class",
|
| 88 |
+
"value": "={{ $('Trigger BE-AIPipeline').item.json.classification.condition }}",
|
| 89 |
+
"type": "string"
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"id": "id-3",
|
| 93 |
+
"name": "location_score",
|
| 94 |
+
"value": "={{ $('Trigger BE-AIPipeline').item.json.priority_score }}",
|
| 95 |
+
"type": "number"
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"id": "id-4",
|
| 99 |
+
"name": "total_score",
|
| 100 |
+
"value": "={{ $('Trigger BE-AIPipeline').item.json.max_impact }}",
|
| 101 |
+
"type": "number"
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"id": "id-5",
|
| 105 |
+
"name": "status",
|
| 106 |
+
"value": "complete",
|
| 107 |
+
"type": "string"
|
| 108 |
+
}
|
| 109 |
+
]
|
| 110 |
+
},
|
| 111 |
+
"includeOtherFields": true,
|
| 112 |
+
"options": {}
|
| 113 |
+
},
|
| 114 |
+
"id": "5a218d30-f6f2-45ca-942f-c73d80e4b3f3",
|
| 115 |
+
"name": "Prepare Supabase Update Data",
|
| 116 |
+
"type": "n8n-nodes-base.set",
|
| 117 |
+
"typeVersion": 3.4,
|
| 118 |
+
"position": [
|
| 119 |
+
4240,
|
| 120 |
+
992
|
| 121 |
+
]
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"parameters": {
|
| 125 |
+
"operation": "update",
|
| 126 |
+
"tableId": "reports",
|
| 127 |
+
"filterType": "string",
|
| 128 |
+
"filterString": "={{ 'id=eq.' + $('Prepare Supabase Update Data').item.json.id }}",
|
| 129 |
+
"fieldsUi": {
|
| 130 |
+
"fieldValues": [
|
| 131 |
+
{
|
| 132 |
+
"fieldId": "destruct_class",
|
| 133 |
+
"fieldValue": "={{ $('Prepare Supabase Update Data').item.json.destruct_class }}"
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"fieldId": "location_score",
|
| 137 |
+
"fieldValue": "={{ $('Prepare Supabase Update Data').item.json.location_score }}"
|
| 138 |
+
},
|
| 139 |
+
{
|
| 140 |
+
"fieldId": "total_score",
|
| 141 |
+
"fieldValue": "={{ $('Prepare Supabase Update Data').item.json.total_score }}"
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
"fieldId": "status",
|
| 145 |
+
"fieldValue": "={{ $('Prepare Supabase Update Data').item.json.status }}"
|
| 146 |
+
}
|
| 147 |
+
]
|
| 148 |
+
}
|
| 149 |
+
},
|
| 150 |
+
"id": "49aeac17-9849-47ba-ba57-93173b6fdbf2",
|
| 151 |
+
"name": "Update Supabase Record",
|
| 152 |
+
"type": "n8n-nodes-base.supabase",
|
| 153 |
+
"typeVersion": 1,
|
| 154 |
+
"position": [
|
| 155 |
+
4464,
|
| 156 |
+
992
|
| 157 |
+
],
|
| 158 |
+
"credentials": {
|
| 159 |
+
"supabaseApi": {
|
| 160 |
+
"id": "Ce7gSa8qnBBCcA3y",
|
| 161 |
+
"name": "Supabase account"
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
],
|
| 166 |
+
"pinData": {},
|
| 167 |
+
"connections": {
|
| 168 |
+
"Cloudinary Webhook": {
|
| 169 |
+
"main": [
|
| 170 |
+
[
|
| 171 |
+
{
|
| 172 |
+
"node": "Parse Data",
|
| 173 |
+
"type": "main",
|
| 174 |
+
"index": 0
|
| 175 |
+
}
|
| 176 |
+
]
|
| 177 |
+
]
|
| 178 |
+
},
|
| 179 |
+
"Parse Data": {
|
| 180 |
+
"main": [
|
| 181 |
+
[
|
| 182 |
+
{
|
| 183 |
+
"node": "Loop Protection",
|
| 184 |
+
"type": "main",
|
| 185 |
+
"index": 0
|
| 186 |
+
}
|
| 187 |
+
]
|
| 188 |
+
]
|
| 189 |
+
},
|
| 190 |
+
"Loop Protection": {
|
| 191 |
+
"main": [
|
| 192 |
+
[
|
| 193 |
+
{
|
| 194 |
+
"node": "Trigger BE-AIPipeline",
|
| 195 |
+
"type": "main",
|
| 196 |
+
"index": 0
|
| 197 |
+
}
|
| 198 |
+
]
|
| 199 |
+
]
|
| 200 |
+
},
|
| 201 |
+
"Trigger BE-AIPipeline": {
|
| 202 |
+
"main": [
|
| 203 |
+
[
|
| 204 |
+
{
|
| 205 |
+
"node": "Prepare Supabase Update Data",
|
| 206 |
+
"type": "main",
|
| 207 |
+
"index": 0
|
| 208 |
+
}
|
| 209 |
+
]
|
| 210 |
+
]
|
| 211 |
+
},
|
| 212 |
+
"Prepare Supabase Update Data": {
|
| 213 |
+
"main": [
|
| 214 |
+
[
|
| 215 |
+
{
|
| 216 |
+
"node": "Update Supabase Record",
|
| 217 |
+
"type": "main",
|
| 218 |
+
"index": 0
|
| 219 |
+
}
|
| 220 |
+
]
|
| 221 |
+
]
|
| 222 |
+
}
|
| 223 |
+
},
|
| 224 |
+
"active": true,
|
| 225 |
+
"settings": {
|
| 226 |
+
"executionOrder": "v1",
|
| 227 |
+
"availableInMCP": false
|
| 228 |
+
},
|
| 229 |
+
"versionId": "f32ea910-f847-438f-aee1-657680d0cf9e",
|
| 230 |
+
"meta": {
|
| 231 |
+
"templateCredsSetupCompleted": true,
|
| 232 |
+
"instanceId": "ec50aa1d8c0e15b703de4ec09bc4ae99ba1d29b466ee546490a9882118ec4069"
|
| 233 |
+
},
|
| 234 |
+
"id": "BFLADivi1dvw4GiMd9fuK",
|
| 235 |
+
"tags": []
|
| 236 |
+
}
|
README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Gungfi Webhook BE
|
| 3 |
+
colorFrom: blue
|
| 4 |
+
colorTo: green
|
| 5 |
+
sdk: docker
|
| 6 |
+
pinned: false
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
# Gungfi Webhook BE
|
| 10 |
+
|
| 11 |
+
FastAPI backend pengganti workflow n8n `IniBukanBuatHackathon.json`.
|
| 12 |
+
|
| 13 |
+
Flow yang dipertahankan:
|
| 14 |
+
|
| 15 |
+
1. Terima Cloudinary webhook di `POST /cloudinary-trigger`.
|
| 16 |
+
2. Parse `body.context.custom` menjadi `id`, `latitude`, `longitude`, `description`, dan `before_img_url`.
|
| 17 |
+
3. Jalankan loop protection: kalau `Id` kosong, tidak meneruskan proses.
|
| 18 |
+
4. Download image dari Cloudinary, lalu kirim multipart form-data ke BE AI Pipeline `POST /process`.
|
| 19 |
+
5. Update Supabase tabel `reports` dengan `destruct_class`, `location_score`, `total_score`, dan `status=complete`.
|
| 20 |
+
|
| 21 |
+
## Setup
|
| 22 |
+
|
| 23 |
+
```powershell
|
| 24 |
+
uv venv
|
| 25 |
+
uv sync
|
| 26 |
+
Copy-Item .env.example .env
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
Isi `.env`, terutama `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, dan `CLOUDINARY_API_SECRET`.
|
| 30 |
+
|
| 31 |
+
## Run
|
| 32 |
+
|
| 33 |
+
```powershell
|
| 34 |
+
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
Cloudinary notification URL:
|
| 38 |
+
|
| 39 |
+
```text
|
| 40 |
+
https://your-domain/cloudinary-trigger
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
Set URL tersebut sebagai global webhook Notification URL di Cloudinary Console, atau di upload preset yang dipakai aplikasi. Dengan begitu aplikasi upload tidak perlu mengirim parameter `notification_url` pada setiap request.
|
| 44 |
+
|
| 45 |
+
## Test
|
| 46 |
+
|
| 47 |
+
```powershell
|
| 48 |
+
uv run pytest
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
## CI/CD ke Hugging Face Spaces
|
| 52 |
+
|
| 53 |
+
Repository ini siap dideploy sebagai Docker Space. Buat Space di Hugging Face dengan SDK `Docker`, lalu tambahkan secret berikut di GitHub repository:
|
| 54 |
+
|
| 55 |
+
```text
|
| 56 |
+
HF_USERNAME=your-huggingface-username
|
| 57 |
+
HF_TOKEN=hf_...
|
| 58 |
+
HF_SPACE_ID=your-huggingface-username/your-space-name
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
Tambahkan juga environment variable aplikasi di Hugging Face Space settings:
|
| 62 |
+
|
| 63 |
+
```text
|
| 64 |
+
APP_NAME=gungfi-webhook-be
|
| 65 |
+
LOG_LEVEL=INFO
|
| 66 |
+
WEBHOOK_PATH=/cloudinary-trigger
|
| 67 |
+
AI_PIPELINE_URL=https://be-aipipeline.bluecoast-4238dc8b.eastasia.azurecontainerapps.io
|
| 68 |
+
AI_PIPELINE_TIMEOUT_SECONDS=60
|
| 69 |
+
SUPABASE_URL=https://your-project.supabase.co
|
| 70 |
+
SUPABASE_SERVICE_ROLE_KEY=replace-me
|
| 71 |
+
SUPABASE_TABLE=reports
|
| 72 |
+
CLOUDINARY_API_SECRET=replace-me
|
| 73 |
+
CLOUDINARY_SIGNATURE_REQUIRED=true
|
| 74 |
+
CLOUDINARY_SIGNATURE_TOLERANCE_SECONDS=7200
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
Setiap push ke branch `main` akan menjalankan test, lalu melakukan force-push isi repo ke Hugging Face Space.
|
| 78 |
+
|
| 79 |
+
## Test Upload Cloudinary
|
| 80 |
+
|
| 81 |
+
Tambahkan ini ke `.env`:
|
| 82 |
+
|
| 83 |
+
```text
|
| 84 |
+
CLOUDINARY_CLOUD_NAME=...
|
| 85 |
+
CLOUDINARY_API_KEY=...
|
| 86 |
+
CLOUDINARY_API_SECRET=...
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
Untuk test upload file `img/DSC06041.JPG` ke Cloudinary:
|
| 90 |
+
|
| 91 |
+
```powershell
|
| 92 |
+
uv run python scripts/upload_cloudinary_test.py
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
Kalau ingin Cloudinary memanggil FastAPI lokal, expose dulu server lokal ke HTTPS publik:
|
| 96 |
+
|
| 97 |
+
```powershell
|
| 98 |
+
uv run uvicorn app.main:app --host 127.0.0.1 --port 8000
|
| 99 |
+
ngrok http 8000
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
Setelah dapat URL HTTPS dari tunnel, set global webhook Notification URL atau upload preset Notification URL di Cloudinary ke `https://your-tunnel-url/cloudinary-trigger`, lalu jalankan lagi script upload Cloudinary.
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Gungfi webhook backend."""
|
app/cloudinary.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import hmac
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
from fastapi import HTTPException, Request, status
|
| 6 |
+
|
| 7 |
+
from app.config import Settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
SIGNATURE_HEADER = "x-cld-signature"
|
| 11 |
+
TIMESTAMP_HEADER = "x-cld-timestamp"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def verify_notification_signature(raw_body: bytes, signature: str, timestamp: str, api_secret: str) -> bool:
|
| 15 |
+
signed = raw_body + timestamp.encode("utf-8") + api_secret.encode("utf-8")
|
| 16 |
+
sha1_digest = hashlib.sha1(signed).hexdigest()
|
| 17 |
+
sha256_digest = hashlib.sha256(signed).hexdigest()
|
| 18 |
+
return hmac.compare_digest(signature, sha1_digest) or hmac.compare_digest(signature, sha256_digest)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def enforce_cloudinary_signature(request: Request, settings: Settings) -> bytes:
|
| 22 |
+
raw_body = await request.body()
|
| 23 |
+
if not settings.cloudinary_signature_required:
|
| 24 |
+
return raw_body
|
| 25 |
+
|
| 26 |
+
if not settings.cloudinary_api_secret:
|
| 27 |
+
raise HTTPException(
|
| 28 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 29 |
+
detail="Cloudinary signature validation is required but CLOUDINARY_API_SECRET is not configured.",
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
signature = request.headers.get(SIGNATURE_HEADER)
|
| 33 |
+
timestamp = request.headers.get(TIMESTAMP_HEADER)
|
| 34 |
+
if not signature or not timestamp:
|
| 35 |
+
raise HTTPException(
|
| 36 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 37 |
+
detail="Missing Cloudinary signature headers.",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
try:
|
| 41 |
+
timestamp_int = int(timestamp)
|
| 42 |
+
except ValueError as exc:
|
| 43 |
+
raise HTTPException(
|
| 44 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 45 |
+
detail="Invalid Cloudinary timestamp.",
|
| 46 |
+
) from exc
|
| 47 |
+
|
| 48 |
+
age = abs(int(time.time()) - timestamp_int)
|
| 49 |
+
if age > settings.cloudinary_signature_tolerance_seconds:
|
| 50 |
+
raise HTTPException(
|
| 51 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 52 |
+
detail="Expired Cloudinary signature.",
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
if not verify_notification_signature(raw_body, signature, timestamp, settings.cloudinary_api_secret):
|
| 56 |
+
raise HTTPException(
|
| 57 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 58 |
+
detail="Invalid Cloudinary signature.",
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
return raw_body
|
app/config.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
|
| 3 |
+
from pydantic import AnyHttpUrl, Field, PositiveInt, field_validator
|
| 4 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Settings(BaseSettings):
|
| 8 |
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
| 9 |
+
|
| 10 |
+
app_name: str
|
| 11 |
+
log_level: str
|
| 12 |
+
webhook_path: str
|
| 13 |
+
|
| 14 |
+
ai_pipeline_url: AnyHttpUrl
|
| 15 |
+
ai_pipeline_timeout_seconds: PositiveInt
|
| 16 |
+
|
| 17 |
+
supabase_url: AnyHttpUrl
|
| 18 |
+
supabase_service_role_key: str = Field(repr=False)
|
| 19 |
+
supabase_table: str
|
| 20 |
+
|
| 21 |
+
cloudinary_api_secret: str = Field(repr=False)
|
| 22 |
+
cloudinary_signature_required: bool
|
| 23 |
+
cloudinary_signature_tolerance_seconds: PositiveInt
|
| 24 |
+
|
| 25 |
+
@field_validator("webhook_path")
|
| 26 |
+
@classmethod
|
| 27 |
+
def normalize_webhook_path(cls, value: str) -> str:
|
| 28 |
+
value = value.strip()
|
| 29 |
+
if not value:
|
| 30 |
+
raise ValueError("WEBHOOK_PATH cannot be empty")
|
| 31 |
+
return value if value.startswith("/") else f"/{value}"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@lru_cache
|
| 35 |
+
def get_settings() -> Settings:
|
| 36 |
+
return Settings()
|
app/main.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
from fastapi import Depends, FastAPI, Request, status
|
| 4 |
+
|
| 5 |
+
from app.cloudinary import enforce_cloudinary_signature
|
| 6 |
+
from app.config import Settings, get_settings
|
| 7 |
+
from app.models import HealthResponse, WebhookAcceptedResponse
|
| 8 |
+
from app.services import PipelineClient, SupabaseClient
|
| 9 |
+
from app.workflow import parse_cloudinary_payload, run_webhook_workflow
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def create_app() -> FastAPI:
|
| 13 |
+
settings = get_settings()
|
| 14 |
+
logging.basicConfig(level=settings.log_level.upper())
|
| 15 |
+
|
| 16 |
+
app = FastAPI(title=settings.app_name)
|
| 17 |
+
|
| 18 |
+
@app.get("/health", response_model=HealthResponse)
|
| 19 |
+
async def health(settings: Settings = Depends(get_settings)) -> HealthResponse:
|
| 20 |
+
return HealthResponse(status="ok", app=settings.app_name)
|
| 21 |
+
|
| 22 |
+
@app.post(
|
| 23 |
+
settings.webhook_path,
|
| 24 |
+
response_model=WebhookAcceptedResponse,
|
| 25 |
+
status_code=status.HTTP_202_ACCEPTED,
|
| 26 |
+
)
|
| 27 |
+
async def cloudinary_trigger(
|
| 28 |
+
request: Request,
|
| 29 |
+
settings: Settings = Depends(get_settings),
|
| 30 |
+
) -> WebhookAcceptedResponse:
|
| 31 |
+
raw_body = await enforce_cloudinary_signature(request, settings)
|
| 32 |
+
parsed = parse_cloudinary_payload(raw_body)
|
| 33 |
+
|
| 34 |
+
if not parsed.id:
|
| 35 |
+
return WebhookAcceptedResponse(status="skipped", id=None)
|
| 36 |
+
|
| 37 |
+
pipeline_client = PipelineClient(settings)
|
| 38 |
+
supabase_client = SupabaseClient(settings)
|
| 39 |
+
await run_webhook_workflow(parsed, pipeline_client, supabase_client)
|
| 40 |
+
|
| 41 |
+
return WebhookAcceptedResponse(
|
| 42 |
+
status="complete",
|
| 43 |
+
id=parsed.id,
|
| 44 |
+
pipeline_url=settings.ai_pipeline_url,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
return app
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
app = create_app()
|
app/models.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict, Field, HttpUrl
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class ParsedWebhookData(BaseModel):
|
| 7 |
+
id: str | None = None
|
| 8 |
+
latitude: float | None = None
|
| 9 |
+
longitude: float | None = None
|
| 10 |
+
description: str = ""
|
| 11 |
+
before_img_url: str = ""
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class PipelineRequest(BaseModel):
|
| 15 |
+
lan: float
|
| 16 |
+
lon: float
|
| 17 |
+
img_location: str
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class PipelineClassification(BaseModel):
|
| 21 |
+
model_config = ConfigDict(extra="allow")
|
| 22 |
+
|
| 23 |
+
condition: str
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class PipelineResponse(BaseModel):
|
| 27 |
+
model_config = ConfigDict(extra="allow")
|
| 28 |
+
|
| 29 |
+
classification: PipelineClassification
|
| 30 |
+
priority_score: float
|
| 31 |
+
max_impact: float
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class SupabaseUpdate(BaseModel):
|
| 35 |
+
destruct_class: str
|
| 36 |
+
location_score: float
|
| 37 |
+
total_score: float
|
| 38 |
+
status: str = "complete"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class WebhookEnvelope(BaseModel):
|
| 42 |
+
body: dict[str, Any] = Field(default_factory=dict)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class HealthResponse(BaseModel):
|
| 46 |
+
status: str
|
| 47 |
+
app: str
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class WebhookAcceptedResponse(BaseModel):
|
| 51 |
+
status: str
|
| 52 |
+
id: str | None = None
|
| 53 |
+
pipeline_url: HttpUrl | None = None
|
app/services.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from urllib.parse import quote, urlparse
|
| 2 |
+
|
| 3 |
+
import httpx
|
| 4 |
+
from fastapi import HTTPException, status
|
| 5 |
+
|
| 6 |
+
from app.config import Settings
|
| 7 |
+
from app.models import PipelineRequest, PipelineResponse, SupabaseUpdate
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class PipelineClient:
|
| 11 |
+
_image_field_names = ("image", "file", "img")
|
| 12 |
+
|
| 13 |
+
def __init__(self, settings: Settings) -> None:
|
| 14 |
+
self._base_url = str(settings.ai_pipeline_url).rstrip("/")
|
| 15 |
+
self._timeout = settings.ai_pipeline_timeout_seconds
|
| 16 |
+
|
| 17 |
+
async def process_url(self, payload: PipelineRequest) -> PipelineResponse:
|
| 18 |
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
| 19 |
+
image = await self._download_image(client, payload.img_location)
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
response = await self._post_process_form(client, payload, image)
|
| 23 |
+
except httpx.HTTPError as exc:
|
| 24 |
+
raise HTTPException(
|
| 25 |
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
| 26 |
+
detail=f"AI pipeline request failed: {exc}",
|
| 27 |
+
) from exc
|
| 28 |
+
|
| 29 |
+
return PipelineResponse.model_validate(response.json())
|
| 30 |
+
|
| 31 |
+
async def _download_image(self, client: httpx.AsyncClient, image_url: str) -> tuple[str, bytes, str]:
|
| 32 |
+
try:
|
| 33 |
+
response = await client.get(image_url, follow_redirects=True)
|
| 34 |
+
response.raise_for_status()
|
| 35 |
+
except httpx.HTTPError as exc:
|
| 36 |
+
raise HTTPException(
|
| 37 |
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
| 38 |
+
detail=f"Failed to download Cloudinary image: {exc}",
|
| 39 |
+
) from exc
|
| 40 |
+
|
| 41 |
+
content = response.content
|
| 42 |
+
if not content:
|
| 43 |
+
raise HTTPException(
|
| 44 |
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
| 45 |
+
detail="Downloaded Cloudinary image is empty.",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
content_type = response.headers.get("content-type", "application/octet-stream").split(";")[0]
|
| 49 |
+
filename = _filename_from_url(image_url)
|
| 50 |
+
return filename, content, content_type
|
| 51 |
+
|
| 52 |
+
async def _post_process_form(
|
| 53 |
+
self,
|
| 54 |
+
client: httpx.AsyncClient,
|
| 55 |
+
payload: PipelineRequest,
|
| 56 |
+
image: tuple[str, bytes, str],
|
| 57 |
+
) -> httpx.Response:
|
| 58 |
+
last_response: httpx.Response | None = None
|
| 59 |
+
data = {
|
| 60 |
+
"lan": str(payload.lan),
|
| 61 |
+
"lon": str(payload.lon),
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
for field_name in self._image_field_names:
|
| 65 |
+
filename, content, content_type = image
|
| 66 |
+
files = {
|
| 67 |
+
field_name: (filename, content, content_type),
|
| 68 |
+
}
|
| 69 |
+
response = await client.post(f"{self._base_url}/process", data=data, files=files)
|
| 70 |
+
if response.status_code != 422:
|
| 71 |
+
response.raise_for_status()
|
| 72 |
+
return response
|
| 73 |
+
|
| 74 |
+
last_response = response
|
| 75 |
+
|
| 76 |
+
if last_response is not None:
|
| 77 |
+
last_response.raise_for_status()
|
| 78 |
+
|
| 79 |
+
raise HTTPException(
|
| 80 |
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
| 81 |
+
detail="AI pipeline did not return a response.",
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _filename_from_url(image_url: str) -> str:
|
| 86 |
+
path = urlparse(image_url).path
|
| 87 |
+
filename = path.rsplit("/", 1)[-1].strip()
|
| 88 |
+
return filename or "cloudinary-image.jpg"
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class SupabaseClient:
|
| 92 |
+
def __init__(self, settings: Settings) -> None:
|
| 93 |
+
if not settings.supabase_url or not settings.supabase_service_role_key:
|
| 94 |
+
raise HTTPException(
|
| 95 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 96 |
+
detail="Supabase is not configured.",
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
self._base_url = str(settings.supabase_url).rstrip("/")
|
| 100 |
+
self._table = settings.supabase_table
|
| 101 |
+
self._headers = {
|
| 102 |
+
"apikey": settings.supabase_service_role_key,
|
| 103 |
+
"Authorization": f"Bearer {settings.supabase_service_role_key}",
|
| 104 |
+
"Content-Type": "application/json",
|
| 105 |
+
"Prefer": "return=minimal",
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
async def update_report(self, report_id: str, payload: SupabaseUpdate) -> None:
|
| 109 |
+
encoded_id = quote(report_id, safe="")
|
| 110 |
+
url = f"{self._base_url}/rest/v1/{self._table}?id=eq.{encoded_id}"
|
| 111 |
+
|
| 112 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 113 |
+
try:
|
| 114 |
+
response = await client.patch(url, headers=self._headers, json=payload.model_dump())
|
| 115 |
+
response.raise_for_status()
|
| 116 |
+
except httpx.HTTPError as exc:
|
| 117 |
+
raise HTTPException(
|
| 118 |
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
| 119 |
+
detail=f"Supabase update failed: {exc}",
|
| 120 |
+
) from exc
|
app/workflow.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from fastapi import HTTPException, status
|
| 5 |
+
|
| 6 |
+
from app.models import ParsedWebhookData, PipelineRequest, PipelineResponse, SupabaseUpdate
|
| 7 |
+
from app.services import PipelineClient, SupabaseClient
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def parse_cloudinary_payload(raw_body: bytes) -> ParsedWebhookData:
|
| 11 |
+
try:
|
| 12 |
+
payload = json.loads(raw_body.decode("utf-8"))
|
| 13 |
+
except json.JSONDecodeError as exc:
|
| 14 |
+
raise HTTPException(
|
| 15 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 16 |
+
detail="Invalid JSON payload.",
|
| 17 |
+
) from exc
|
| 18 |
+
|
| 19 |
+
body = payload.get("body", payload)
|
| 20 |
+
if not isinstance(body, dict):
|
| 21 |
+
body = {}
|
| 22 |
+
|
| 23 |
+
context = _as_dict(body.get("context"))
|
| 24 |
+
custom = _as_dict(context.get("custom"))
|
| 25 |
+
|
| 26 |
+
return ParsedWebhookData(
|
| 27 |
+
id=custom.get("Id") or None,
|
| 28 |
+
latitude=_to_float_or_none(custom.get("latitude")),
|
| 29 |
+
longitude=_to_float_or_none(custom.get("longitude")),
|
| 30 |
+
description=custom.get("description") or "",
|
| 31 |
+
before_img_url=body.get("secure_url") or "",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def run_webhook_workflow(
|
| 36 |
+
parsed: ParsedWebhookData,
|
| 37 |
+
pipeline_client: PipelineClient,
|
| 38 |
+
supabase_client: SupabaseClient,
|
| 39 |
+
) -> PipelineResponse | None:
|
| 40 |
+
if not parsed.id:
|
| 41 |
+
return None
|
| 42 |
+
|
| 43 |
+
pipeline_response = await pipeline_client.process_url(
|
| 44 |
+
PipelineRequest(
|
| 45 |
+
lan=parsed.latitude,
|
| 46 |
+
lon=parsed.longitude,
|
| 47 |
+
img_location=parsed.before_img_url,
|
| 48 |
+
)
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
await supabase_client.update_report(
|
| 52 |
+
parsed.id,
|
| 53 |
+
SupabaseUpdate(
|
| 54 |
+
destruct_class=pipeline_response.classification.condition,
|
| 55 |
+
location_score=pipeline_response.priority_score,
|
| 56 |
+
total_score=pipeline_response.max_impact,
|
| 57 |
+
),
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
return pipeline_response
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _as_dict(value: Any) -> dict[str, Any]:
|
| 64 |
+
return value if isinstance(value, dict) else {}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _to_float_or_none(value: Any) -> float | None:
|
| 68 |
+
if value is None or value == "":
|
| 69 |
+
return None
|
| 70 |
+
try:
|
| 71 |
+
return float(value)
|
| 72 |
+
except (TypeError, ValueError):
|
| 73 |
+
return None
|
pyproject.toml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "gungfi-webhook-be"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "FastAPI backend for forwarding Cloudinary webhooks to the AI pipeline and Supabase."
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.12"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"fastapi>=0.115.0",
|
| 9 |
+
"httpx>=0.27.0",
|
| 10 |
+
"pydantic-settings>=2.6.0",
|
| 11 |
+
"uvicorn[standard]>=0.30.0",
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
[dependency-groups]
|
| 15 |
+
dev = [
|
| 16 |
+
"pytest>=8.3.0",
|
| 17 |
+
"respx>=0.21.1",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
[tool.pytest.ini_options]
|
| 21 |
+
testpaths = ["tests"]
|
| 22 |
+
pythonpath = ["."]
|
scripts/upload_cloudinary_test.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
import time
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def main() -> int:
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
image_path = Path(os.getenv("CLOUDINARY_TEST_IMAGE", "img/DSC06041.jpg"))
|
| 15 |
+
cloud_name = os.getenv("CLOUDINARY_CLOUD_NAME")
|
| 16 |
+
api_key = os.getenv("CLOUDINARY_API_KEY")
|
| 17 |
+
api_secret = os.getenv("CLOUDINARY_API_SECRET")
|
| 18 |
+
missing = [
|
| 19 |
+
name
|
| 20 |
+
for name, value in {
|
| 21 |
+
"CLOUDINARY_CLOUD_NAME": cloud_name,
|
| 22 |
+
"CLOUDINARY_API_KEY": api_key,
|
| 23 |
+
"CLOUDINARY_API_SECRET": api_secret,
|
| 24 |
+
}.items()
|
| 25 |
+
if not value
|
| 26 |
+
]
|
| 27 |
+
if missing:
|
| 28 |
+
print(f"Missing env: {', '.join(missing)}", file=sys.stderr)
|
| 29 |
+
return 1
|
| 30 |
+
|
| 31 |
+
if not image_path.exists():
|
| 32 |
+
print(f"Image not found: {image_path}", file=sys.stderr)
|
| 33 |
+
return 1
|
| 34 |
+
|
| 35 |
+
timestamp = str(int(time.time()))
|
| 36 |
+
params: dict[str, str] = {
|
| 37 |
+
"api_key": api_key,
|
| 38 |
+
"context": "Id=test-fastapi-webhook|latitude=-6.200000|longitude=106.816666|description=fastapi webhook smoke test",
|
| 39 |
+
"timestamp": timestamp,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
params["signature"] = sign_upload_params(params, api_secret)
|
| 43 |
+
|
| 44 |
+
url = f"https://api.cloudinary.com/v1_1/{cloud_name}/image/upload"
|
| 45 |
+
with image_path.open("rb") as image:
|
| 46 |
+
files = {"file": (image_path.name, image, "image/jpeg")}
|
| 47 |
+
response = httpx.post(url, data=params, files=files, timeout=120)
|
| 48 |
+
|
| 49 |
+
if response.is_error:
|
| 50 |
+
print(f"Cloudinary upload failed: HTTP {response.status_code}", file=sys.stderr)
|
| 51 |
+
print(response.text, file=sys.stderr)
|
| 52 |
+
return 1
|
| 53 |
+
|
| 54 |
+
data = response.json()
|
| 55 |
+
print("Upload OK")
|
| 56 |
+
print(f"public_id: {data.get('public_id')}")
|
| 57 |
+
print(f"secure_url: {data.get('secure_url')}")
|
| 58 |
+
return 0
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def sign_upload_params(params: dict[str, str], api_secret: str) -> str:
|
| 62 |
+
signable = {
|
| 63 |
+
key: value
|
| 64 |
+
for key, value in params.items()
|
| 65 |
+
if key not in {"api_key", "file", "resource_type", "cloud_name", "signature"} and value
|
| 66 |
+
}
|
| 67 |
+
payload = "&".join(f"{key}={signable[key]}" for key in sorted(signable))
|
| 68 |
+
return hashlib.sha1(f"{payload}{api_secret}".encode("utf-8")).hexdigest()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
if __name__ == "__main__":
|
| 72 |
+
raise SystemExit(main())
|
tests/test_workflow.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import json
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
import respx
|
| 7 |
+
from fastapi.testclient import TestClient
|
| 8 |
+
|
| 9 |
+
from app.cloudinary import verify_notification_signature
|
| 10 |
+
from app.config import Settings, get_settings
|
| 11 |
+
from app.main import create_app
|
| 12 |
+
from app.models import ParsedWebhookData, PipelineRequest
|
| 13 |
+
from app.services import PipelineClient
|
| 14 |
+
from app.workflow import parse_cloudinary_payload
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_parse_cloudinary_payload_matches_n8n_template() -> None:
|
| 18 |
+
raw = json.dumps(
|
| 19 |
+
{
|
| 20 |
+
"body": {
|
| 21 |
+
"secure_url": "https://res.cloudinary.com/demo/image/upload/sample.jpg",
|
| 22 |
+
"context": {
|
| 23 |
+
"custom": {
|
| 24 |
+
"Id": "report-1",
|
| 25 |
+
"latitude": "-6.2",
|
| 26 |
+
"longitude": "106.8",
|
| 27 |
+
"description": "jalan rusak",
|
| 28 |
+
}
|
| 29 |
+
},
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
).encode()
|
| 33 |
+
|
| 34 |
+
parsed = parse_cloudinary_payload(raw)
|
| 35 |
+
|
| 36 |
+
assert parsed.id == "report-1"
|
| 37 |
+
assert parsed.latitude == -6.2
|
| 38 |
+
assert parsed.longitude == 106.8
|
| 39 |
+
assert parsed.description == "jalan rusak"
|
| 40 |
+
assert parsed.before_img_url == "https://res.cloudinary.com/demo/image/upload/sample.jpg"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_cloudinary_signature_verification_uses_raw_body_timestamp_and_secret() -> None:
|
| 44 |
+
raw_body = b'{"public_id":"sample"}'
|
| 45 |
+
timestamp = "1315060510"
|
| 46 |
+
secret = "abcd"
|
| 47 |
+
signature = hashlib.sha1(raw_body + timestamp.encode() + secret.encode()).hexdigest()
|
| 48 |
+
|
| 49 |
+
assert verify_notification_signature(raw_body, signature, timestamp, secret)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_webhook_skips_missing_id_without_external_calls() -> None:
|
| 53 |
+
app = create_app()
|
| 54 |
+
app.dependency_overrides[get_settings] = lambda: Settings(
|
| 55 |
+
app_name="gungfi-webhook-be",
|
| 56 |
+
log_level="INFO",
|
| 57 |
+
webhook_path="/cloudinary-trigger",
|
| 58 |
+
ai_pipeline_url="https://pipeline.example.com",
|
| 59 |
+
ai_pipeline_timeout_seconds=60,
|
| 60 |
+
cloudinary_api_secret="secret",
|
| 61 |
+
cloudinary_signature_required=True,
|
| 62 |
+
cloudinary_signature_tolerance_seconds=7200,
|
| 63 |
+
supabase_url="https://example.supabase.co",
|
| 64 |
+
supabase_service_role_key="service-role",
|
| 65 |
+
supabase_table="reports",
|
| 66 |
+
)
|
| 67 |
+
client = TestClient(app)
|
| 68 |
+
body = json.dumps({"body": {"context": {"custom": {}}, "secure_url": ""}}, separators=(",", ":")).encode()
|
| 69 |
+
timestamp = str(int(time.time()))
|
| 70 |
+
signature = hashlib.sha1(body + timestamp.encode() + b"secret").hexdigest()
|
| 71 |
+
|
| 72 |
+
response = client.post(
|
| 73 |
+
"/cloudinary-trigger",
|
| 74 |
+
content=body,
|
| 75 |
+
headers={
|
| 76 |
+
"content-type": "application/json",
|
| 77 |
+
"x-cld-signature": signature,
|
| 78 |
+
"x-cld-timestamp": timestamp,
|
| 79 |
+
},
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
assert response.status_code == 202
|
| 83 |
+
assert response.json() == {"status": "skipped", "id": None, "pipeline_url": None}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_webhook_workflow_sends_process_payload() -> None:
|
| 87 |
+
from app.workflow import run_webhook_workflow
|
| 88 |
+
|
| 89 |
+
class FakePipelineClient:
|
| 90 |
+
payload = None
|
| 91 |
+
|
| 92 |
+
async def process_url(self, payload):
|
| 93 |
+
self.payload = payload
|
| 94 |
+
return type(
|
| 95 |
+
"PipelineResponse",
|
| 96 |
+
(),
|
| 97 |
+
{
|
| 98 |
+
"classification": type("Classification", (), {"condition": "rusak"})(),
|
| 99 |
+
"priority_score": 0.8,
|
| 100 |
+
"max_impact": 0.9,
|
| 101 |
+
},
|
| 102 |
+
)()
|
| 103 |
+
|
| 104 |
+
class FakeSupabaseClient:
|
| 105 |
+
async def update_report(self, report_id, payload):
|
| 106 |
+
self.report_id = report_id
|
| 107 |
+
self.payload = payload
|
| 108 |
+
|
| 109 |
+
pipeline_client = FakePipelineClient()
|
| 110 |
+
supabase_client = FakeSupabaseClient()
|
| 111 |
+
|
| 112 |
+
import anyio
|
| 113 |
+
|
| 114 |
+
anyio.run(
|
| 115 |
+
run_webhook_workflow,
|
| 116 |
+
ParsedWebhookData(
|
| 117 |
+
id="report-1",
|
| 118 |
+
latitude=-6.2,
|
| 119 |
+
longitude=106.8,
|
| 120 |
+
before_img_url="https://res.cloudinary.com/demo/image/upload/sample.jpg",
|
| 121 |
+
),
|
| 122 |
+
pipeline_client,
|
| 123 |
+
supabase_client,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
assert pipeline_client.payload.model_dump() == {
|
| 127 |
+
"lan": -6.2,
|
| 128 |
+
"lon": 106.8,
|
| 129 |
+
"img_location": "https://res.cloudinary.com/demo/image/upload/sample.jpg",
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@respx.mock
|
| 134 |
+
def test_pipeline_client_downloads_image_and_posts_multipart_to_process() -> None:
|
| 135 |
+
settings = Settings(
|
| 136 |
+
app_name="gungfi-webhook-be",
|
| 137 |
+
log_level="INFO",
|
| 138 |
+
webhook_path="/cloudinary-trigger",
|
| 139 |
+
ai_pipeline_url="https://pipeline.example.com",
|
| 140 |
+
ai_pipeline_timeout_seconds=60,
|
| 141 |
+
cloudinary_api_secret="secret",
|
| 142 |
+
cloudinary_signature_required=True,
|
| 143 |
+
cloudinary_signature_tolerance_seconds=7200,
|
| 144 |
+
supabase_url="https://example.supabase.co",
|
| 145 |
+
supabase_service_role_key="service-role",
|
| 146 |
+
supabase_table="reports",
|
| 147 |
+
)
|
| 148 |
+
image_route = respx.get("https://res.cloudinary.com/demo/image/upload/sample.jpg").mock(
|
| 149 |
+
return_value=httpx.Response(200, content=b"fake-image-bytes", headers={"content-type": "image/jpeg"})
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
def handle_process_request(request: httpx.Request) -> httpx.Response:
|
| 153 |
+
body = request.content
|
| 154 |
+
assert "multipart/form-data" in request.headers["content-type"]
|
| 155 |
+
assert b'name="lan"' in body
|
| 156 |
+
assert b"-6.2" in body
|
| 157 |
+
assert b'name="lon"' in body
|
| 158 |
+
assert b"106.8" in body
|
| 159 |
+
assert b'name="image"; filename="sample.jpg"' in body
|
| 160 |
+
assert b"fake-image-bytes" in body
|
| 161 |
+
return httpx.Response(
|
| 162 |
+
200,
|
| 163 |
+
json={
|
| 164 |
+
"classification": {"condition": "rusak", "confidence": 0.9, "probabilities": {}},
|
| 165 |
+
"priority_score": 0.8,
|
| 166 |
+
"max_impact": 0.9,
|
| 167 |
+
},
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
route = respx.post("https://pipeline.example.com/process").mock(
|
| 171 |
+
side_effect=handle_process_request,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
import anyio
|
| 175 |
+
|
| 176 |
+
anyio.run(
|
| 177 |
+
PipelineClient(settings).process_url,
|
| 178 |
+
PipelineRequest(
|
| 179 |
+
lan=-6.2,
|
| 180 |
+
lon=106.8,
|
| 181 |
+
img_location="https://res.cloudinary.com/demo/image/upload/sample.jpg",
|
| 182 |
+
),
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
assert image_route.called
|
| 186 |
+
assert route.called
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|