diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5d51cd4d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +.git +.github +.context +.conductor + +.env +.env.* +!.env.example + +.venv +venv +__pycache__ +*.py[cod] +.pytest_cache +.ruff_cache +.mypy_cache +.coverage +htmlcov +*.sqlite3 + +media +staticfiles +node_modules +theme/static_src/node_modules diff --git a/.omo/run-continuation/ses_09086707effe8Q5lE1eol9wCrU.json b/.omo/run-continuation/ses_09086707effe8Q5lE1eol9wCrU.json new file mode 100644 index 00000000..1f49458e --- /dev/null +++ b/.omo/run-continuation/ses_09086707effe8Q5lE1eol9wCrU.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_09086707effe8Q5lE1eol9wCrU", + "updatedAt": "2026-07-19T04:44:33.319Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-19T04:44:33.319Z" + } + } +} \ No newline at end of file diff --git a/apps/common/middleware.py b/apps/common/middleware.py new file mode 100644 index 00000000..06c0901b --- /dev/null +++ b/apps/common/middleware.py @@ -0,0 +1,50 @@ +""" +Middleware to allow Fly.io internal health checks through ALLOWED_HOSTS validation. + +Fly.io health checks hit the app via internal IPs (172.19.x.x) which are not in +ALLOWED_HOSTS. This middleware adds those IPs to the allowed hosts for health +check requests only. +""" + +import re + +from django.conf import settings +from django.http import HttpResponseForbidden + +FLY_INTERNAL_IP_PATTERN = re.compile(r"^172\.19\.\d+\.\d+$") +HEALTH_CHECK_PATHS = {"/health/", "/healthz/", "/ready/"} + + +class FlyHealthCheckMiddleware: + """ + Allow Fly.io internal health checks to bypass ALLOWED_HOSTS restriction. + + Fly.io's internal load balancer sends health checks from internal IPs + (172.19.x.x) which are not in ALLOWED_HOSTS. This middleware checks if + the request is a health check from a Fly internal IP and allows it through. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # Check if this is a health check request from Fly internal IP + # Use META directly to avoid triggering DisallowedHost in get_host() + host_header = request.META.get("HTTP_HOST", "").split(":")[0] + path = request.path + + if path in HEALTH_CHECK_PATHS and FLY_INTERNAL_IP_PATTERN.match(host_header): + # Temporarily add the host to ALLOWED_HOSTS for this request + # This is safe because it's only for known health check paths + # from known internal IP range + original_allowed = settings.ALLOWED_HOSTS + if host_header not in original_allowed: + settings.ALLOWED_HOSTS = list(original_allowed) + [host_header] + + response = self.get_response(request) + + # Restore original ALLOWED_HOSTS + if path in HEALTH_CHECK_PATHS and FLY_INTERNAL_IP_PATTERN.match(host_header): + settings.ALLOWED_HOSTS = original_allowed + + return response \ No newline at end of file diff --git a/config/settings/base.py b/config/settings/base.py index 0e18823c..1cdd961c 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -358,8 +358,13 @@ # Platform credentials env vars (cloud version) _META_CREDENTIALS = { - "app_id": env("PLATFORM_FACEBOOK_APP_ID", default=""), - "app_secret": env("PLATFORM_FACEBOOK_APP_SECRET", default=""), + # The existing PnC Meta app is configured through the Instagram Business + # use case. Keep the dedicated Instagram variables as a compatibility + # fallback so an existing deployment can use the same app without a + # destructive secret rotation. + "app_id": env("PLATFORM_FACEBOOK_APP_ID", default="") or env("PLATFORM_INSTAGRAM_APP_ID", default=""), + "app_secret": env("PLATFORM_FACEBOOK_APP_SECRET", default="") + or env("PLATFORM_INSTAGRAM_APP_SECRET", default=""), } _GOOGLE_CREDENTIALS = { "client_id": env("PLATFORM_GOOGLE_CLIENT_ID", default=""), diff --git a/config/settings/production.py b/config/settings/production.py index 8728ff31..367950d8 100644 --- a/config/settings/production.py +++ b/config/settings/production.py @@ -13,6 +13,20 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_REDIRECT_EXEMPT = [r"^health/$"] +# Allow Fly.io internal health checks (internal IPs in 172.19.x.x range) +import re +FLY_INTERNAL_IP_PATTERN = re.compile(r"^172\.19\.\d+\.\d+$") + +# Add Fly health check middleware BEFORE CommonMiddleware to allow internal IPs +# for health checks before host validation runs +MIDDLEWARE = [ + "apps.common.middleware.FlyHealthCheckMiddleware", + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", +] + MIDDLEWARE[5:] + # Logging LOGGING = { "version": 1, diff --git a/fly.toml b/fly.toml new file mode 100644 index 00000000..f1b670b6 --- /dev/null +++ b/fly.toml @@ -0,0 +1,45 @@ +app = 'brightbean-pnc' +primary_region = 'lax' + +[build] + +[processes] + app = 'sh -c "gunicorn config.wsgi:application --bind 0.0.0.0:$PORT --workers 2 --threads 2"' + worker = 'python manage.py process_tasks' + +[env] + PORT = '8000' + +[http_service] + internal_port = 8000 + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 0 + processes = ['app'] + + [[http_service.checks]] + grace_period = '10s' + interval = '30s' + method = 'GET' + timeout = '5s' + path = '/health/' + +[[restart]] + policy = 'always' + processes = ['worker'] + +[[vm]] + memory = '1gb' + cpu_kind = 'shared' + cpus = 1 + processes = ['app'] + +[[vm]] + memory = '1gb' + cpu_kind = 'shared' + cpus = 1 + processes = ['worker'] + +[deploy] + release_command = 'python manage.py migrate --noinput' diff --git a/providers/instagram.py b/providers/instagram.py index 94c9dfdb..edf61d59 100644 --- a/providers/instagram.py +++ b/providers/instagram.py @@ -106,11 +106,14 @@ def supported_media_types(self) -> list[MediaType]: @property def required_scopes(self) -> list[str]: + # Meta renamed Instagram permissions with an "instagram_business_" + # prefix; the old names (instagram_basic, instagram_content_publish, + # etc.) are now rejected as invalid scopes. return [ - "instagram_basic", - "instagram_content_publish", - "instagram_manage_comments", - "instagram_manage_insights", + "instagram_business_basic", + "instagram_business_content_publish", + "instagram_business_manage_comments", + "instagram_business_manage_insights", "pages_show_list", "pages_read_engagement", ] diff --git a/scripts/INGEST_GUIDE.md b/scripts/INGEST_GUIDE.md new file mode 100644 index 00000000..c4a2c3dd --- /dev/null +++ b/scripts/INGEST_GUIDE.md @@ -0,0 +1,107 @@ +# BrightBean Asset Ingestion - Run Locally on Your Mac + +## Prerequisites + +You need the R2 credentials from the API token you just created in Cloudflare: + +```bash +# From Cloudflare Dashboard → R2 → Manage R2 API tokens → your token +export S3_ENDPOINT_URL="https://.r2.cloudflarestorage.com" +export S3_ACCESS_KEY_ID="" +export S3_SECRET_ACCESS_KEY="" +export S3_BUCKET_NAME="brightbean-assets" +export S3_REGION_NAME="auto" +``` + +Find your **Account ID** at: https://dash.cloudflare.com/ (top right, copy the 32-char ID) + +--- + +## Run the Ingestion + +```bash +cd /Users/mfdoom/conductor/workspaces/brightbean-studio/brazzaville + +# 1. Install boto3 (one-time) +pip3 install boto3 + +# 2. Preview what will be uploaded (dry run) +python3 scripts/ingest_assets_standalone.py --dry-run + +# 3. Actual upload to R2 +python3 scripts/ingest_assets_standalone.py +``` + +--- + +## What Happens + +1. **Scans 4 source directories** (from `.context/brightbean-social-readiness.md`) +2. **Deduplicates by SHA-256** (skips identical files) +3. **Uploads to R2** at `assets///` +4. **Creates `asset_manifest.json`** with all metadata for later MediaAsset creation + +--- + +## After Upload: Create MediaAsset Records + +The upload puts files in R2. To make them usable in BrightBean: + +### Option A: BrightBean API (once social accounts connected) +```bash +# Get API key from BrightBean UI → Organization → API Keys +export BB_API_KEY="bb_studio_..." +export BB_URL="https://brightbean-pnc.fly.dev" + +# Use the manifest to create records +python3 -c " +import json, requests +manifest = json.load(open('asset_manifest.json')) +for asset in manifest: + r = requests.post(f'{BB_URL}/api/v1/media', headers={'Authorization': f'Bearer {BB_API_KEY}'}, json={ + 'title': asset['title'], + 'file': asset['r2_key'], + 'sha256': asset['sha256'], + 'file_size': asset['file_size'], + 'mime_type': asset['mime_type'], + 'project': asset['project'], + 'tags': [asset['project']], + 'alt_text': asset['title'], + }) + print(asset['filename'], r.status_code) +" +``` + +### Option B: Django Admin (immediate) +1. Go to https://brightbean-pnc.fly.dev/admin/ +2. Login with superuser +3. Media Library → Media Assets → Add +4. Fill in from `asset_manifest.json` + +### Option C: BrightBean UI +1. Go to Media Library in BrightBean +2. Click "Add" → the files are already in R2, just need records + +--- + +## Expected Results + +| Category | Files | +|----------|-------| +| Social-ready set | 26 PNGs | +| Content pool | ~152 unique (deduped from 259) | +| Originals (PAC) | 2 PNGs | +| Reels | 2 MP4s | +| **Total unique** | **~182 assets** | + +--- + +## Troubleshooting + +**"Source directory not found"** — The paths in `SOURCE_DIRS` are from your `.context/brightbean-social-readiness.md`. If files moved, update the script. + +**"Missing R2 credentials"** — Re-export the 5 environment variables above. + +**"Access Denied"** — R2 token needs "Object Read & Write" permissions on the bucket. + +**"Bucket not found"** — Create bucket `brightbean-assets` in R2 first, or update `S3_BUCKET_NAME`. \ No newline at end of file diff --git a/scripts/ingest_assets.py b/scripts/ingest_assets.py new file mode 100644 index 00000000..f9087dac --- /dev/null +++ b/scripts/ingest_assets.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +BrightBean Asset Ingestion Script + +Ingests verified assets from local filesystem into R2 storage and creates +PocketBase records with SHA-256 deduplication. + +Run after R2 credentials are rotated and set as Fly secrets. + +Usage: + python scripts/ingest_assets.py --dry-run # Preview what would be ingested + python scripts/ingest_assets.py # Actual ingestion +""" + +import hashlib +import os +import sys +from pathlib import Path +from typing import Dict, List, Optional + +import requests +from django.conf import settings + +# Setup Django +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + +import django +django.setup() + +from django.core.files.storage import default_storage +from apps.media_library.models import MediaAsset + + +# Verified source directories from brightbean-social-readiness.md +SOURCE_DIRS = [ + Path("/Users/mfdoom/Projects/agency-os/output/social-ready-2026-06-25/feed"), + Path("/Users/mfdoom/Projects/agency-os/output/content-pool-2026-06-23"), + Path("/Users/mfdoom/Downloads/pac_store-ites_3d/item_1"), + Path("/Users/mfdoom/Projects/agency-os/reels"), +] + +# Known asset mappings (filename -> metadata) +ASSET_METADATA = { + "IMG_1761.png": {"title": "Plants Over Chairs hero", "project": "plants-over-chairs"}, + "IMG_1634.png": {"title": "Plants & Jeeps hero", "project": "plants-and-jeeps"}, + "new-01-screenprint-process.mp4": {"title": "Screen-print process Reel", "project": "plants-over-chairs", "type": "video"}, + "new-03-beach-lifestyle.mp4": {"title": "Studio/place Reel", "project": "studio", "type": "video"}, + # Social-ready set (26 files) + "s01-iconic-hero.png": {"title": "Iconic hero", "project": "plants-over-chairs"}, + "s02-pink-hero.png": {"title": "Pink hero", "project": "plants-over-chairs"}, + "s03-royal-hero.png": {"title": "Royal hero", "project": "plants-over-chairs"}, + "s04-tote-hero.png": {"title": "Tote hero", "project": "plants-over-chairs"}, + "s05-beanie-hero.png": {"title": "Beanie hero", "project": "plants-over-chairs"}, + "s06-iconic-grid2.png": {"title": "Iconic grid 2", "project": "plants-over-chairs"}, + "s07-dot-grid2.png": {"title": "Dot grid 2", "project": "plants-over-chairs"}, + "s08-scatter-sky.png": {"title": "Scatter sky", "project": "plants-over-chairs"}, + "s09-scatter-bone.png": {"title": "Scatter bone", "project": "plants-over-chairs"}, + "s10-scatter-dark.png": {"title": "Scatter dark", "project": "plants-over-chairs"}, + "v2-01-stone-hero.png": {"title": "Stone hero", "project": "plants-over-chairs"}, + "v2-02-payasotee.png": {"title": "Payasotee", "project": "plants-over-chairs"}, + "v2-03-vanlife5.png": {"title": "Vanlife 5", "project": "plants-over-chairs"}, + "v2-04-dotcap-grid2.png": {"title": "Dotcap grid 2", "project": "plants-over-chairs"}, + "v2-05-scatter-bone.png": {"title": "Scatter bone v2", "project": "plants-over-chairs"}, + "v2-06-barlogo-hero.png": {"title": "Barlogo hero", "project": "plants-over-chairs"}, + "v3-s1-petstore-card.png": {"title": "Petstore card", "project": "plants-over-chairs"}, + "v3-s2-productos-card.png": {"title": "Productos card", "project": "plants-over-chairs"}, + "v3-s3-mialma-card.png": {"title": "Mialma card", "project": "plants-over-chairs"}, + "v3-s4-scatter-pink.png": {"title": "Scatter pink", "project": "plants-over-chairs"}, + "post-2-flowers-pink.png": {"title": "Flowers pink", "project": "plants-over-chairs"}, + "post-3-vanlife-royal.png": {"title": "Vanlife royal", "project": "plants-over-chairs"}, + "post-4-payaso-tote.png": {"title": "Payaso tote", "project": "plants-over-chairs"}, + "post-5-beanie.png": {"title": "Beanie", "project": "plants-over-chairs"}, + "cap-dir3-streetwear-post.png": {"title": "Streetwear post", "project": "plants-over-chairs"}, + "cap-dir4-ar-v2.png": {"title": "AR v2", "project": "plants-over-chairs"}, +} + + +def compute_sha256(filepath: Path) -> str: + """Compute SHA-256 hash of a file.""" + sha256 = hashlib.sha256() + with open(filepath, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256.update(chunk) + return sha256.hexdigest() + + +def find_all_assets() -> List[Dict]: + """Find all asset files in source directories.""" + assets = [] + for src_dir in SOURCE_DIRS: + if not src_dir.exists(): + print(f"⚠️ Source directory not found: {src_dir}") + continue + for filepath in src_dir.rglob("*"): + if filepath.is_file() and not filepath.name.startswith("."): + # Skip non-media files + if filepath.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp", ".mp4", ".mov", ".webm"}: + assets.append({ + "path": filepath, + "filename": filepath.name, + "relative_path": filepath.relative_to(src_dir), + }) + return assets + + +def ingest_assets(dry_run: bool = False) -> Dict: + """Ingest assets into R2 and create PocketBase records.""" + assets = find_all_assets() + print(f"Found {len(assets)} candidate assets") + + # Deduplicate by SHA-256 + seen_hashes = {} + unique_assets = [] + for asset in assets: + sha256 = compute_sha256(asset["path"]) + if sha256 in seen_hashes: + print(f" ⏭️ Duplicate (SHA-256): {asset['filename']} -> {seen_hashes[sha256]}") + continue + seen_hashes[sha256] = asset["filename"] + asset["sha256"] = sha256 + unique_assets.append(asset) + + print(f"Unique assets after deduplication: {len(unique_assets)}") + + results = {"uploaded": 0, "skipped": 0, "errors": []} + + for asset in unique_assets: + metadata = ASSET_METADATA.get(asset["filename"], {}) + title = metadata.get("title", asset["filename"]) + project = metadata.get("project", "general") + asset_type = metadata.get("type", "image" if asset["filename"].endswith((".png", ".jpg", ".jpeg", ".webp")) else "video") + + # Check if already exists in DB + if MediaAsset.objects.filter(sha256=asset["sha256"]).exists(): + print(f" ⏭️ Already in DB: {asset['filename']}") + results["skipped"] += 1 + continue + + print(f" 📤 Uploading: {asset['filename']} ({title})") + + if dry_run: + print(f" [DRY RUN] Would upload to R2 and create MediaAsset record") + results["uploaded"] += 1 + continue + + try: + # Upload to R2 via Django storage + with open(asset["path"], "rb") as f: + # Use the storage backend to save + saved_path = default_storage.save( + f"assets/{project}/{asset['sha256'][:8]}/{asset['filename']}", + f + ) + + # Create MediaAsset record + media_asset = MediaAsset.objects.create( + title=title, + file=saved_path, + sha256=asset["sha256"], + file_size=asset["path"].stat().st_size, + mime_type=asset_type, + project=project, + tags=[project], + alt_text=title, + ) + print(f" ✅ Created MediaAsset #{media_asset.id}") + results["uploaded"] += 1 + + except Exception as e: + error_msg = f"Failed to ingest {asset['filename']}: {e}" + print(f" ❌ {error_msg}") + results["errors"].append(error_msg) + + return results + + +def main(): + import argparse + parser = argparse.ArgumentParser(description="Ingest assets into BrightBean R2 + PocketBase") + parser.add_argument("--dry-run", action="store_true", help="Preview without uploading") + args = parser.parse_args() + + print("=" * 60) + print("BrightBean Asset Ingestion") + print("=" * 60) + + if args.dry_run: + print("🔍 DRY RUN MODE - No changes will be made") + print() + + results = ingest_assets(dry_run=args.dry_run) + + print() + print("=" * 60) + print("SUMMARY") + print("=" * 60) + print(f" Uploaded: {results['uploaded']}") + print(f" Skipped: {results['skipped']}") + print(f" Errors: {len(results['errors'])}") + if results["errors"]: + for err in results["errors"]: + print(f" - {err}") + + if args.dry_run: + print("\n💡 Run without --dry-run to perform actual ingestion") + + sys.exit(0 if not results["errors"] else 1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/ingest_assets_standalone.py b/scripts/ingest_assets_standalone.py new file mode 100644 index 00000000..16e00fc3 --- /dev/null +++ b/scripts/ingest_assets_standalone.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +""" +Standalone BrightBean Asset Ingestion Script + +Uploads assets directly to Cloudflare R2 using boto3 (no Django required). +Creates a manifest JSON for later MediaAsset creation via API/UI. + +Usage: + export S3_ENDPOINT_URL="https://.r2.cloudflarestorage.com" + export S3_ACCESS_KEY_ID="" + export S3_SECRET_ACCESS_KEY="" + export S3_BUCKET_NAME="brightbean-assets" + export S3_REGION_NAME="auto" + + python scripts/ingest_assets_standalone.py --dry-run # Preview + python scripts/ingest_assets_standalone.py # Actual upload +""" + +import hashlib +import json +import os +import sys +from pathlib import Path +from typing import Dict, List +from dataclasses import dataclass, asdict + +try: + import boto3 + from botocore.config import Config +except ImportError: + print("❌ boto3 not installed. Run: pip install boto3") + sys.exit(1) + + +# Verified source directories from brightbean-social-readiness.md +SOURCE_DIRS = [ + Path("/Users/mfdoom/Projects/agency-os/output/social-ready-2026-06-25/feed"), + Path("/Users/mfdoom/Projects/agency-os/output/content-pool-2026-06-23"), + Path("/Users/mfdoom/Downloads/pac_store-ites_3d/item_1"), + Path("/Users/mfdoom/Projects/agency-os/reels"), +] + +# Known asset mappings (filename -> metadata) +ASSET_METADATA = { + "IMG_1761.png": {"title": "Plants Over Chairs hero", "project": "plants-over-chairs"}, + "IMG_1634.png": {"title": "Plants & Jeeps hero", "project": "plants-and-jeeps"}, + "new-01-screenprint-process.mp4": {"title": "Screen-print process Reel", "project": "plants-over-chairs", "type": "video"}, + "new-03-beach-lifestyle.mp4": {"title": "Studio/place Reel", "project": "studio", "type": "video"}, + # Social-ready set (26 files) + "s01-iconic-hero.png": {"title": "Iconic hero", "project": "plants-over-chairs"}, + "s02-pink-hero.png": {"title": "Pink hero", "project": "plants-over-chairs"}, + "s03-royal-hero.png": {"title": "Royal hero", "project": "plants-over-chairs"}, + "s04-tote-hero.png": {"title": "Tote hero", "project": "plants-over-chairs"}, + "s05-beanie-hero.png": {"title": "Beanie hero", "project": "plants-over-chairs"}, + "s06-iconic-grid2.png": {"title": "Iconic grid 2", "project": "plants-over-chairs"}, + "s07-dot-grid2.png": {"title": "Dot grid 2", "project": "plants-over-chairs"}, + "s08-scatter-sky.png": {"title": "Scatter sky", "project": "plants-over-chairs"}, + "s09-scatter-bone.png": {"title": "Scatter bone", "project": "plants-over-chairs"}, + "s10-scatter-dark.png": {"title": "Scatter dark", "project": "plants-over-chairs"}, + "v2-01-stone-hero.png": {"title": "Stone hero", "project": "plants-over-chairs"}, + "v2-02-payasotee.png": {"title": "Payasotee", "project": "plants-over-chairs"}, + "v2-03-vanlife5.png": {"title": "Vanlife 5", "project": "plants-over-chairs"}, + "v2-04-dotcap-grid2.png": {"title": "Dotcap grid 2", "project": "plants-over-chairs"}, + "v2-05-scatter-bone.png": {"title": "Scatter bone v2", "project": "plants-over-chairs"}, + "v2-06-barlogo-hero.png": {"title": "Barlogo hero", "project": "plants-over-chairs"}, + "v3-s1-petstore-card.png": {"title": "Petstore card", "project": "plants-over-chairs"}, + "v3-s2-productos-card.png": {"title": "Productos card", "project": "plants-over-chairs"}, + "v3-s3-mialma-card.png": {"title": "Mialma card", "project": "plants-over-chairs"}, + "v3-s4-scatter-pink.png": {"title": "Scatter pink", "project": "plants-over-chairs"}, + "post-2-flowers-pink.png": {"title": "Flowers pink", "project": "plants-over-chairs"}, + "post-3-vanlife-royal.png": {"title": "Vanlife royal", "project": "plants-over-chairs"}, + "post-4-payaso-tote.png": {"title": "Payaso tote", "project": "plants-over-chairs"}, + "post-5-beanie.png": {"title": "Beanie", "project": "plants-over-chairs"}, + "cap-dir3-streetwear-post.png": {"title": "Streetwear post", "project": "plants-over-chairs"}, + "cap-dir4-ar-v2.png": {"title": "AR v2", "project": "plants-over-chairs"}, +} + + +@dataclass +class AssetRecord: + """Record of an uploaded asset for manifest.""" + filename: str + title: str + project: str + asset_type: str + sha256: str + file_size: int + r2_key: str + r2_url: str + mime_type: str + + +def compute_sha256(filepath: Path) -> str: + """Compute SHA-256 hash of a file.""" + sha256 = hashlib.sha256() + with open(filepath, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256.update(chunk) + return sha256.hexdigest() + + +def find_all_assets() -> List[Dict]: + """Find all asset files in source directories.""" + assets = [] + for src_dir in SOURCE_DIRS: + if not src_dir.exists(): + print(f"⚠️ Source directory not found: {src_dir}") + continue + for filepath in src_dir.rglob("*"): + if filepath.is_file() and not filepath.name.startswith("."): + if filepath.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp", ".mp4", ".mov", ".webm"}: + assets.append({ + "path": filepath, + "filename": filepath.name, + "relative_path": filepath.relative_to(src_dir), + }) + return assets + + +def get_s3_client(): + """Create S3 client for R2.""" + endpoint = os.environ.get("S3_ENDPOINT_URL") + access_key = os.environ.get("S3_ACCESS_KEY_ID") + secret_key = os.environ.get("S3_SECRET_ACCESS_KEY") + region = os.environ.get("S3_REGION_NAME", "auto") + + if not all([endpoint, access_key, secret_key]): + print("❌ Missing R2 credentials. Set these environment variables:") + print(" S3_ENDPOINT_URL=https://.r2.cloudflarestorage.com") + print(" S3_ACCESS_KEY_ID=") + print(" S3_SECRET_ACCESS_KEY=") + print(" S3_BUCKET_NAME=brightbean-assets") + print(" S3_REGION_NAME=auto") + sys.exit(1) + + return boto3.client( + "s3", + endpoint_url=endpoint, + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + region_name=region, + config=Config(signature_version="s3v4"), + ) + + +def ingest_assets(dry_run: bool = False) -> Dict: + """Upload assets to R2 and create manifest.""" + # Validate credentials + bucket = os.environ.get("S3_BUCKET_NAME", "brightbean-assets") + s3 = get_s3_client() + + assets = find_all_assets() + print(f"Found {len(assets)} candidate assets") + + # Deduplicate by SHA-256 + seen_hashes = {} + unique_assets = [] + for asset in assets: + sha256 = compute_sha256(asset["path"]) + if sha256 in seen_hashes: + print(f" ⏭️ Duplicate (SHA-256): {asset['filename']} -> {seen_hashes[sha256]}") + continue + seen_hashes[sha256] = asset["filename"] + asset["sha256"] = sha256 + unique_assets.append(asset) + + print(f"Unique assets after deduplication: {len(unique_assets)}") + + results = {"uploaded": 0, "skipped": 0, "errors": []} + manifest = [] + + for asset in unique_assets: + metadata = ASSET_METADATA.get(asset["filename"], {}) + title = metadata.get("title", asset["filename"]) + project = metadata.get("project", "general") + asset_type = metadata.get("type", "image" if asset["filename"].endswith((".png", ".jpg", ".jpeg", ".webp")) else "video") + + r2_key = f"assets/{project}/{asset['sha256'][:8]}/{asset['filename']}" + r2_url = f"{os.environ['S3_ENDPOINT_URL']}/{bucket}/{r2_key}" + file_size = asset["path"].stat().st_size + mime_type = "video/mp4" if asset_type == "video" else "image/png" + + print(f" 📤 Uploading: {asset['filename']} ({title})") + + if dry_run: + print(f" [DRY RUN] Would upload to R2: {r2_key}") + results["uploaded"] += 1 + manifest.append(AssetRecord( + filename=asset["filename"], + title=title, + project=project, + asset_type=asset_type, + sha256=asset["sha256"], + file_size=file_size, + r2_key=r2_key, + r2_url=r2_url, + mime_type=mime_type, + )) + continue + + try: + # Upload to R2 + with open(asset["path"], "rb") as f: + s3.upload_fileobj( + f, + bucket, + r2_key, + ExtraArgs={"ContentType": mime_type} + ) + + print(f" ✅ Uploaded to R2: {r2_key}") + + record = AssetRecord( + filename=asset["filename"], + title=title, + project=project, + asset_type=asset_type, + sha256=asset["sha256"], + file_size=file_size, + r2_key=r2_key, + r2_url=r2_url, + mime_type=mime_type, + ) + manifest.append(record) + results["uploaded"] += 1 + + except Exception as e: + error_msg = f"Failed to upload {asset['filename']}: {e}" + print(f" ❌ {error_msg}") + results["errors"].append(error_msg) + + # Save manifest + manifest_path = Path("asset_manifest.json") + with open(manifest_path, "w") as f: + json.dump([asdict(r) for r in manifest], f, indent=2) + print(f"\n📋 Manifest saved to: {manifest_path}") + + return results, manifest + + +def main(): + import argparse + parser = argparse.ArgumentParser(description="Upload assets to Cloudflare R2") + parser.add_argument("--dry-run", action="store_true", help="Preview without uploading") + args = parser.parse_args() + + print("=" * 60) + print("BrightBean Asset Ingestion (Standalone R2 Upload)") + print("=" * 60) + + if args.dry_run: + print("🔍 DRY RUN MODE - No changes will be made") + print() + + results, manifest = ingest_assets(dry_run=args.dry_run) + + print() + print("=" * 60) + print("SUMMARY") + print("=" * 60) + print(f" Uploaded: {results['uploaded']}") + print(f" Skipped: {results['skipped']}") + print(f" Errors: {len(results['errors'])}") + if results["errors"]: + for err in results["errors"]: + print(f" - {err}") + + if args.dry_run: + print("\n💡 Run without --dry-run to perform actual upload") + print("📋 After upload, use the manifest to create MediaAsset records via:") + print(" - BrightBean API: POST /api/v1/media") + print(" - Django admin: /admin/media_library/mediaasset/") + print(" - BrightBean UI: Media Library → Add") + + sys.exit(0 if not results["errors"] else 1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_deployment_config.py b/tests/test_deployment_config.py new file mode 100644 index 00000000..e2ae3b95 --- /dev/null +++ b/tests/test_deployment_config.py @@ -0,0 +1,42 @@ +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_docker_build_context_excludes_local_secrets(): + patterns = { + line.strip() + for line in (ROOT / ".dockerignore").read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + + assert ".env" in patterns + assert ".env.*" in patterns + assert "!.env.example" in patterns + + +def test_fly_runs_web_and_background_worker_process_groups(): + config = tomllib.loads((ROOT / "fly.toml").read_text()) + + assert config["processes"] == { + "app": "gunicorn config.wsgi:application --bind 0.0.0.0:${PORT:-8000} --workers 2 --threads 2", + "worker": "python manage.py process_tasks", + } + assert config["http_service"]["processes"] == ["app"] + assert config["http_service"]["checks"] == [ + { + "grace_period": "10s", + "interval": "30s", + "method": "GET", + "timeout": "5s", + "path": "/health/", + } + ] + + assert config["restart"] == [{"policy": "always", "processes": ["worker"]}] + + vm_process_groups = {process for vm in config["vm"] for process in vm["processes"]} + assert vm_process_groups == {"app", "worker"} + worker_vm = next(vm for vm in config["vm"] if vm["processes"] == ["worker"]) + assert worker_vm["memory"] == "1gb"