ai_qc/backend/scripts/box_setup.py
nickviljoen 65848bcda1 feat(box-jwt): add box_setup.py bootstrap CLI for webhook management
One-off script used to register/inspect Box V2 webhooks against the
service account. Subcommands: list-webhooks, list-folder, list-clients,
create-webhook, delete-webhook, register-all-clients.

Typical bootstrap flow on a fresh deploy:
1. Drop box_jwt_config.json on the server (gitignored, scp'd in).
2. Verify the service account can read each client folder:
   `python backend/scripts/box_setup.py list-folder <folder_id>`
3. Once a client's box_folder_id is set in client_config.py, register
   its webhook idempotently:
   `python backend/scripts/box_setup.py register-all-clients \
       https://optical-dev.oliver.solutions/ai_qc/api/box/webhook`
4. Copy the signing keys from the Box Developer Console (Custom App →
   Webhooks) into BOX_WEBHOOK_PRIMARY_KEY / BOX_WEBHOOK_SECONDARY_KEY
   in the env file, then restart ai-qc.service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:53:03 +02:00

193 lines
6.7 KiB
Python
Executable file

#!/usr/bin/env python3
"""
Box JWT service-account admin CLI.
One-off script for setting up + inspecting the Box V2 webhooks that drive the
QC pipeline. Run on the dev/prod server once the Box admin has invited the
service account to each client folder, with the JWT config JSON in place at
backend/config/box_jwt_config.json (or BOX_JWT_CONFIG_PATH).
Subcommands:
list-webhooks
Show every webhook the service account can see.
list-folder <folder_id>
List the items in a Box folder. Sanity-check the service account
can actually read the folder (otherwise it isn't a collaborator yet).
list-clients
Print which clients in client_config.py have a box_folder_id set.
create-webhook <folder_id> <address>
Register a FILE.UPLOADED V2 webhook on a folder. Address is the public
URL of /api/box/webhook (e.g. https://optical-dev.oliver.solutions/ai_qc/api/box/webhook).
delete-webhook <webhook_id>
Remove a single webhook by id.
register-all-clients <address>
For every client with a box_folder_id, ensure a FILE.UPLOADED webhook
pointing at <address> exists on that folder. Idempotent — already-present
webhooks are left alone.
After registering, set the signing keys (Box Developer Console → Custom App →
Webhooks Settings) in the env file as BOX_WEBHOOK_PRIMARY_KEY and
BOX_WEBHOOK_SECONDARY_KEY, then restart ai-qc.service.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
BACKEND_DIR = os.path.dirname(THIS_DIR)
sys.path.insert(0, BACKEND_DIR)
import box_jwt_client
from client_config import get_clients_with_box_folder
WEBHOOK_TRIGGERS_DEFAULT = ['FILE.UPLOADED']
def cmd_list_webhooks(_args):
webhooks = box_jwt_client.list_webhooks()
if not webhooks:
print('No webhooks visible to this service account.')
return 0
for wh in webhooks:
target = wh.get('target') or {}
print(
f" id={wh.get('id')} target={target.get('type')}/{target.get('id')}"
f" address={wh.get('address')} triggers={wh.get('triggers')}"
)
return 0
def cmd_list_folder(args):
items = box_jwt_client.list_folder_items(args.folder_id, fields=['id', 'name', 'type', 'size'])
print(f'Folder {args.folder_id} contains {len(items)} items:')
for it in items:
size = f" ({it.get('size')} bytes)" if it.get('size') is not None else ''
print(f" {it.get('type')}/{it.get('id')} {it.get('name')}{size}")
return 0
def cmd_list_clients(_args):
configured = get_clients_with_box_folder()
if not configured:
print('No clients have box_folder_id set in client_config.py.')
return 0
for cid, cfg in configured:
print(
f" {cid}: source_folder={cfg.get('box_folder_id')} "
f"reports_folder={cfg.get('box_reports_folder_id') or '(falls back to source)'} "
f"default_profile={cfg.get('default_profile') or '(first profile in list)'}"
)
return 0
def cmd_create_webhook(args):
wh = box_jwt_client.create_webhook(
target_type='folder',
target_id=args.folder_id,
address=args.address,
triggers=WEBHOOK_TRIGGERS_DEFAULT,
)
print('Created webhook:')
print(json.dumps(wh, indent=2))
return 0
def cmd_delete_webhook(args):
box_jwt_client.delete_webhook(args.webhook_id)
print(f'Deleted webhook {args.webhook_id}.')
return 0
def cmd_register_all_clients(args):
"""Idempotent: skip folders that already have a webhook pointing at this address."""
existing = box_jwt_client.list_webhooks()
existing_by_folder = {}
for wh in existing:
target = wh.get('target') or {}
if target.get('type') == 'folder':
existing_by_folder.setdefault(str(target.get('id')), []).append(wh)
configured = get_clients_with_box_folder()
if not configured:
print('No clients have box_folder_id set. Nothing to register.')
return 0
created = 0
skipped = 0
errors = 0
for cid, cfg in configured:
folder_id = str(cfg['box_folder_id'])
already = existing_by_folder.get(folder_id, [])
matching = [w for w in already if w.get('address') == args.address]
if matching:
print(f" {cid} ({folder_id}): SKIP — webhook already exists (id={matching[0].get('id')})")
skipped += 1
continue
try:
wh = box_jwt_client.create_webhook(
target_type='folder',
target_id=folder_id,
address=args.address,
triggers=WEBHOOK_TRIGGERS_DEFAULT,
)
print(f" {cid} ({folder_id}): CREATED webhook id={wh.get('id')}")
created += 1
except Exception as exc:
print(f" {cid} ({folder_id}): ERROR — {exc}")
errors += 1
print()
print(f'Summary: {created} created, {skipped} already present, {errors} errored.')
if errors:
print('Common causes for errors: service account not invited as collaborator on the folder yet,')
print('or the folder_id in client_config.py is wrong.')
return 0 if errors == 0 else 1
def main():
parser = argparse.ArgumentParser(description='Box JWT admin CLI')
sub = parser.add_subparsers(dest='cmd', required=True)
sub.add_parser('list-webhooks').set_defaults(func=cmd_list_webhooks)
p_lf = sub.add_parser('list-folder', help='List items in a Box folder')
p_lf.add_argument('folder_id')
p_lf.set_defaults(func=cmd_list_folder)
sub.add_parser('list-clients').set_defaults(func=cmd_list_clients)
p_cw = sub.add_parser('create-webhook', help='Create a single FILE.UPLOADED webhook')
p_cw.add_argument('folder_id')
p_cw.add_argument('address', help='Public URL of /api/box/webhook')
p_cw.set_defaults(func=cmd_create_webhook)
p_dw = sub.add_parser('delete-webhook', help='Delete a webhook by id')
p_dw.add_argument('webhook_id')
p_dw.set_defaults(func=cmd_delete_webhook)
p_ra = sub.add_parser('register-all-clients', help='Create FILE.UPLOADED webhooks for every client with a box_folder_id')
p_ra.add_argument('address', help='Public URL of /api/box/webhook')
p_ra.set_defaults(func=cmd_register_all_clients)
args = parser.parse_args()
if not box_jwt_client.is_configured():
print(f'ERROR: Box JWT config not found. Drop the JSON from Box at '
f'{os.path.join(BACKEND_DIR, "config", "box_jwt_config.json")} or set BOX_JWT_CONFIG_PATH.',
file=sys.stderr)
return 2
try:
return args.func(args) or 0
except box_jwt_client.BoxJWTError as exc:
print(f'Box API error: {exc}', file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())