-
Notifications
You must be signed in to change notification settings - Fork 0
Feature/39 box integration #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 22 commits
e75a4c2
74db110
5305599
06e29ee
591a7b3
457183b
b7c573d
5ed8da5
c6ceb54
88edf16
4b647db
8a24124
9837057
fc90e63
3721ad9
543557f
08cbd29
0002f9b
6b5d9f3
f7e266f
b7f104d
62c5d02
0c8c54c
f4e67e9
ea0c92b
c835dbb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| # Django Settings | ||
| SECRET_KEY=your-secret-key-here | ||
| DEBUG=True | ||
| DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 | ||
|
aspiringLich marked this conversation as resolved.
|
||
|
|
||
| # Box.com Integration | ||
| # Use exactly one authentication method; precedence: DEVELOPER_TOKEN > JWT > OAUTH. | ||
| # | ||
| # Option A — Developer token (local development only, expires after 1 hour): | ||
| BOX_DEVELOPER_TOKEN=your-box-developer-token-here | ||
| # | ||
| # Option B — JWT service account (CI / production, requires a Box app with JWT enabled): | ||
| BOX_JWT_CONFIG_FILE=/path/to/box-jwt-config.json | ||
| # | ||
| # Option C — OAuth 2.0 client credentials (production; generate in the Box Developer Console): | ||
| BOX_OAUTH_CLIENT_ID=your-box-oauth-client-id | ||
| BOX_OAUTH_CLIENT_SECRET=your-box-oauth-client-secret | ||
| # | ||
| BOX_FOLDER_ID=your-box-folder-id-here | ||
|
|
||
| # Scanner Configuration | ||
| SCANNER_API_BASE=http://localhost:5000 | ||
| SCANNER_DEVICE_ID=scanner-001 | ||
|
|
||
| # BFD9020 AI Service | ||
| BFD9020_BASE_URL=https://wingate.case.edu/bfd9020 | ||
|
|
||
| # CORS Configuration | ||
| CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,39 @@ | ||
| """Django app configuration for the archive app.""" | ||
|
|
||
| import logging | ||
| import os | ||
| import sys | ||
| import threading | ||
|
|
||
| from django.apps import AppConfig | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class ArchiveConfig(AppConfig): | ||
| """Configure default settings for the archive app.""" | ||
| default_auto_field = "django.db.models.BigAutoField" # pyright: ignore[reportAssignmentType] | ||
| name = "archive" | ||
|
|
||
| def ready(self): | ||
| """Initialize the archive app and start background tasks.""" | ||
| # Guard against running twice in development (autoreloader issue) | ||
| if 'runserver' in sys.argv: | ||
| if self._is_main_process(): | ||
| self._start_background_task() | ||
| # are we in production (gunicorn)? | ||
| elif os.path.basename(sys.argv[0]) == 'gunicorn': | ||
| self._start_background_task() | ||
| else: | ||
| logger.info("Background tasks not started: conditions have not been met (runserver, gunicorn)") | ||
|
|
||
| def _is_main_process(self): | ||
| """Check if this is the main process (development only).""" | ||
| return os.environ.get('RUN_MAIN') == 'true' | ||
|
|
||
| def _start_background_task(self): | ||
| """Start the background media upload thread.""" | ||
| from archive.media_upload import media_upload_worker | ||
|
|
||
| thread = threading.Thread(target=media_upload_worker, daemon=True) | ||
| thread.start() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| """Background worker for uploading local media files to Box storage.""" | ||
|
|
||
| import logging | ||
| import time | ||
| from pathlib import Path | ||
| from typing import List | ||
|
|
||
| from django.conf import settings | ||
|
|
||
| from archive.models import DigitalRecord | ||
| from .storage import BoxStorageBackend | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def media_upload_worker(): | ||
| from BFD9000.settings import BOX_DEVELOPER_TOKEN, BOX_FOLDER_ID, BOX_JWT_CONFIG_FILE | ||
|
|
||
| if not BOX_DEVELOPER_TOKEN and not BOX_JWT_CONFIG_FILE: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. High: this startup guard is stricter than the backend auth logic below. |
||
| logger.error( | ||
| "worker cannot start: neither BOX_DEVELOPER_TOKEN nor BOX_JWT_CONFIG_FILE is set" | ||
| ) | ||
| return | ||
| if not BOX_FOLDER_ID: | ||
| logger.error("worker cannot start: BOX_FOLDER_ID is not set") | ||
| return | ||
|
|
||
| time.sleep(5) | ||
|
|
||
| while True: | ||
| try: | ||
| files_processed = process_media_files() | ||
| if files_processed > 0: | ||
| logger.info("Processed %d media file(s)", files_processed) | ||
| except Exception as exc: | ||
| logger.error("Error in media upload worker: %s", exc, exc_info=True) | ||
| time.sleep(60) | ||
|
|
||
|
|
||
| def process_media_files() -> int: | ||
| """Upload all pending files from the local media/uploads directory to Box.""" | ||
| media_root = Path(settings.MEDIA_ROOT).joinpath("uploads") | ||
|
|
||
| if not media_root.exists(): | ||
| return 0 | ||
|
|
||
| image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Medium: this worker only archives a small image-extension allowlist, but the rest of the app still accepts other |
||
| files_processed: List[Path] = [] | ||
|
|
||
| for file_path in media_root.rglob("*"): | ||
| if ( | ||
| file_path.exists() | ||
| and file_path.is_file() | ||
| and file_path.suffix.lower() in image_extensions | ||
| ): | ||
| if handle_media_file(file_path): | ||
| files_processed.append(file_path) | ||
|
|
||
| for path in files_processed: | ||
| path.unlink() | ||
| logger.debug("Deleted local file: %s", path) | ||
| prune_empty_directory(path.parent) | ||
|
|
||
| return len(files_processed) | ||
|
|
||
|
|
||
| def handle_media_file(file_path: Path) -> bool: | ||
| """Upload *file_path* to Box and update the matching ``DigitalRecord`` link. | ||
|
|
||
| Returns ``True`` on success, ``False`` on any error (logged; worker continues). | ||
| """ | ||
| try: | ||
| logger.debug("Handling media file: %s", file_path) | ||
| relative_path = file_path.relative_to(settings.MEDIA_ROOT) | ||
|
|
||
| qs = DigitalRecord.objects.filter(source_file=str(relative_path)) | ||
| count = qs.count() | ||
| if count != 1: | ||
| logger.error( | ||
| "Expected 1 record for %s, found %d; skipping DB update", relative_path, count | ||
| ) | ||
| return False | ||
|
|
||
| with open(file_path, "rb") as f: | ||
| link = BoxStorageBackend().upload(f, str(relative_path)) | ||
|
|
||
| qs.update(source_file=link) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. High: this rewrites |
||
| return True | ||
| except Exception as exc: | ||
| logger.error("Error handling file %s: %s", file_path, exc, exc_info=True) | ||
| return False | ||
|
|
||
|
|
||
| def prune_empty_directory(directory: Path): | ||
| """Remove *directory* if empty, then recurse into its parent.""" | ||
| media_root = Path(settings.MEDIA_ROOT) | ||
|
|
||
| if directory == media_root or not directory.exists(): | ||
| return | ||
|
|
||
| if not any(directory.iterdir()): | ||
| directory.rmdir() | ||
| prune_empty_directory(directory.parent) | ||
Uh oh!
There was an error while loading. Please reload this page.