diff --git a/.gitignore b/.gitignore index 7a56844..3f362b2 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,4 @@ tailwindcss.exe .direnv/ tmp/ .DS_Store +.box_token \ No newline at end of file diff --git a/bfd9000_web/.env.example b/bfd9000_web/.env.example new file mode 100644 index 0000000..4552a15 --- /dev/null +++ b/bfd9000_web/.env.example @@ -0,0 +1,30 @@ +# Django Settings +SECRET_KEY=your-secret-key-here +DEBUG=True +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 + +# 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): +# Add /box/oauth/callback/ to 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 diff --git a/bfd9000_web/BFD9000/settings.py b/bfd9000_web/BFD9000/settings.py index 618fab3..5e85bcc 100644 --- a/bfd9000_web/BFD9000/settings.py +++ b/bfd9000_web/BFD9000/settings.py @@ -10,20 +10,56 @@ https://docs.djangoproject.com/en/5.2/ref/settings/ """ +import logging import os from pathlib import Path +import sys + +from dotenv import load_dotenv # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +# Load environment variables from .env file +load_dotenv() + + +def _read_secret(name: str, default: str | None = None) -> str | None: + """Return the value of secret *name*, or *default* if not found. + + Resolution order: + 1. Environment variable ``name`` + 2. Docker Compose secret file ``/run/secrets/`` + 3. *default* + """ + value = os.environ.get(name) + if value is not None: + return value + secret_path = Path(f"/run/secrets/{name}") + if secret_path.is_file(): + return secret_path.read_text(encoding="utf-8").strip() or None + return default + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.environ.get( +SECRET_KEY = _read_secret( 'SECRET_KEY', 'django-insecure-+6m#s88j*)qb+a%2s%cw31e2k04um&*a-fk!jgcpl3849(w4sm') +# Box.com Configuration +BOX_DEVELOPER_TOKEN = _read_secret('BOX_DEVELOPER_TOKEN') +BOX_JWT_CONFIG_FILE = _read_secret('BOX_JWT_CONFIG_FILE') +BOX_FOLDER_ID = _read_secret('BOX_FOLDER_ID') +BOX_OAUTH_CLIENT_ID = _read_secret('BOX_OAUTH_CLIENT_ID') +BOX_OAUTH_CLIENT_SECRET = _read_secret('BOX_OAUTH_CLIENT_SECRET') +# Optional: explicit redirect URI for Box OAuth callback (defaults to auto-detected from request) +BOX_OAUTH_REDIRECT_URI = _read_secret('BOX_OAUTH_REDIRECT_URI') +# Path prefix for the shelve file used to persist Box OAuth tokens +BOX_TOKEN_STORAGE_PATH = _read_secret('BOX_TOKEN_STORAGE_PATH') or str(BASE_DIR / '.box_token') + # SECURITY WARNING: don't run with debug turned on in production! DEBUG = os.environ.get('DEBUG', 'True') == 'True' APP_VERSION_FILE = BASE_DIR / 'VERSION' @@ -242,8 +278,63 @@ def _prefix_url(path): SCANNER_API_BASE = os.environ.get('SCANNER_API_BASE', 'http://localhost:5000') SCANNER_DEVICE_ID = os.environ.get('SCANNER_DEVICE_ID', 'scanner-001') -BFD9020_BASE_URL = os.environ.get( - 'BFD9020_BASE_URL', 'https://wingate.case.edu/bfd9020') +BFD9020_BASE_URL = os.environ.get('BFD9020_BASE_URL', 'https://wingate.case.edu/bfd9020') + +# Logging Configuration +class PrettyFormatter(logging.Formatter): + """A custom formatter to add color to stdout log records.""" + + GRAY = "\x1b[90m" + YELLOW = "\x1b[33;21m" + RED = "\x1b[31;21m" + BOLD_RED = "\x1b[31;1m" + RESET = "\x1b[0m" + + # Define a different format for each level + FORMATS = { + logging.DEBUG: f"{GRAY}%(asctime)s {GRAY}DEBUG {GRAY}%(name)s: {RESET}%(message)s", + logging.INFO: f"{GRAY}%(asctime)s {RESET}INFO {GRAY}%(name)s: {RESET}%(message)s", + logging.WARNING: f"{GRAY}%(asctime)s {YELLOW}WARN {GRAY}%(name)s: {RESET}%(message)s", + logging.ERROR: f"{GRAY}%(asctime)s {RED}ERROR {GRAY}%(name)s: {RESET}%(message)s", + logging.CRITICAL: f"{GRAY}%(asctime)s {BOLD_RED}CRIT {GRAY}%(name)s: {RESET}%(message)s" + } + + def format(self, record): + use_color = hasattr(sys.stderr, 'isatty') and sys.stderr.isatty() + log_fmt = self.FORMATS.get(record.levelno) if use_color else '%(asctime)s %(levelname)-5s %(name)s: %(message)s' + return logging.Formatter(log_fmt).format(record) + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'pretty': { + '()': PrettyFormatter, + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'pretty', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, + }, + 'archive': { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': False, + }, + }, +} # Thumbnail generation policy (staging and UI previews) THUMBNAIL_MAX_WIDTH = int(os.environ.get('THUMBNAIL_MAX_WIDTH', '300')) diff --git a/bfd9000_web/README.md b/bfd9000_web/README.md index 387c525..9873c63 100644 --- a/bfd9000_web/README.md +++ b/bfd9000_web/README.md @@ -13,11 +13,17 @@ Alternatively just install django with python. ## Running the Django Application -1. Make sure to apply any database migrations: +1. Make sure to apply any database migrations and import data into the database: ```bash cd bfd9000_web python manage.py migrate + + # import data into database + cd docs/collections_data + python ../../manage.py import_subjects bolton + python ../../manage.py import_valuesets + cd ../../ ``` 2. ONLY IF YOU ARE DEVELOPING THE FRONTEND, install DaisyUI and run tailwindcss in a seperate terminal window. @@ -41,9 +47,8 @@ Alternatively just install django with python. ```bash # add yourself as a user - python bfd9000_web/manage.py createsuperuser - - python bfd9000_web/manage.py runserver + python manage.py createsuperuser + python manage.py runserver ``` 4. Open your web browser and go to `http://127.0.0.1:9000` to view the application. @@ -88,7 +93,7 @@ Or use the provided compose file: ```bash # Copy the example env file and edit as needed -cp bfd9000_web/dot-env.example bfd9000_web/.env +cp bfd9000_web/.env.example bfd9000_web/.env docker compose -f bfd9000_web/docker-compose.yml up ``` diff --git a/bfd9000_web/archive/apps.py b/bfd9000_web/archive/apps.py index 4ff1cbb..acb0fd2 100644 --- a/bfd9000_web/archive/apps.py +++ b/bfd9000_web/archive/apps.py @@ -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() diff --git a/bfd9000_web/archive/media_upload.py b/bfd9000_web/archive/media_upload.py new file mode 100644 index 0000000..c0bff5e --- /dev/null +++ b/bfd9000_web/archive/media_upload.py @@ -0,0 +1,105 @@ +"""Background worker for uploading local media files to Box storage.""" + +import logging +import time +from pathlib import Path + +from django.conf import settings + +from archive.models import DigitalRecord +from .storage import BoxStorageBackend + +logger = logging.getLogger(__name__) + + +def media_upload_worker(): + # wait for box to become available + for i in range(3): + e = BoxStorageBackend().error() + if e: + if i == 2: + logger.error(f"Failed to connect to Box, exiting: {e}") + return + logger.info(f"Could not connect to Box, retrying in 60 seconds: {e}") + time.sleep(60) + else: + break + + logger.info("Connected to Box. Starting media upload worker...") + + 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 = settings.MEDIA_ROOT + + if not media_root.exists(): + return 0 + + image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"} + + uploads_dir = media_root / "uploads" + if not uploads_dir.exists(): + return 0 + + count = 0 + for file_path in uploads_dir.rglob("*"): + if ( + file_path.exists() + and file_path.is_file() + and file_path.suffix.lower() in image_extensions + and handle_media_file(file_path) + ): + file_path.unlink() + logger.debug("Deleted local file: %s", file_path) + prune_empty_directory(file_path.parent) + count += 1 + + return count + + +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, relative_path) + + qs.update(source_file=link) + 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) diff --git a/bfd9000_web/archive/serializers.py b/bfd9000_web/archive/serializers.py index 72ec0ca..f25f99e 100644 --- a/bfd9000_web/archive/serializers.py +++ b/bfd9000_web/archive/serializers.py @@ -10,6 +10,8 @@ import os import importlib import json +from pathlib import Path +import uuid from typing import Any, Dict, Optional, cast from django.core.files.base import ContentFile try: @@ -46,6 +48,7 @@ RECORD_TYPE_MODALITY_MAP, ) from .media_utils import generate_thumbnail_jpeg_bytes +from .storage import Storage LATERAL_RECORD_TYPE_CODE = 'L' @@ -726,11 +729,12 @@ def create(self, validated_data: Dict[str, Any]) -> DigitalRecord: digital_record.save() ext = os.path.splitext(file_obj.name)[1].lower() - filename = f"{getattr(digital_record, 'pk', digital_record.id)}{ext}" - digital_record.source_file.save(filename, file_obj, save=False) + file_uuid = str(uuid.uuid4()) + filename = f"{file_uuid}{ext}" + # Generate thumbnail before the file stream is consumed by upload. + thumb_bytes: bytes | None = None try: - thumb_bytes = None if thumbnail_preview is not None: thumb_bytes = generate_thumbnail_jpeg_bytes( thumbnail_preview, @@ -738,22 +742,25 @@ def create(self, validated_data: Dict[str, Any]) -> DigitalRecord: transform_ops=None, ) else: - file_stream = digital_record.source_file.open('rb') - try: - thumb_bytes = generate_thumbnail_jpeg_bytes( - file_stream, - digital_record.source_file.name, - transform_ops=transform_ops, - ) - finally: - file_stream.close() - - if thumb_bytes: - thumb_content = ContentFile(thumb_bytes) - thumb_name = f"{getattr(digital_record, 'pk', digital_record.id)}.jpg" - digital_record.thumbnail.save(thumb_name, thumb_content, save=False) + file_obj.seek(0) + thumb_bytes = generate_thumbnail_jpeg_bytes( + file_obj, + filename, + transform_ops=transform_ops, + ) except Exception: - logger.warning("Thumbnail generation failed for digital_record %s", digital_record.pk, exc_info=True) + logger.warning("Thumbnail generation failed for %s", filename, exc_info=True) + + assert isinstance(DigitalRecord.source_file.field.upload_to, str | Path), "We expect that upload_to is a pathlike (str | Path)" + upload_path = Path(DigitalRecord.source_file.field.upload_to).joinpath(filename) + + file_obj.seek(0) + source_uri = Storage().upload(file_obj, upload_path, fallback=True) + digital_record.source_file = source_uri + + if thumb_bytes: + thumb_content = ContentFile(thumb_bytes) + digital_record.thumbnail.save(f"{file_uuid}.jpg", thumb_content, save=False) digital_record.save() diff --git a/bfd9000_web/archive/storage.py b/bfd9000_web/archive/storage.py new file mode 100644 index 0000000..b775ac7 --- /dev/null +++ b/bfd9000_web/archive/storage.py @@ -0,0 +1,391 @@ +"""Storage backend abstraction for media files. + +Backends use URIs to identify stored files: + - Box: ``box://`` + - Local: relative path from ``MEDIA_ROOT`` + +Usage:: + + # Download an existing file by its stored uri + stream, filename = Storage(uri).download(uri) + + # Upload a new file ( + uri = Storage().upload(file_obj, "patient/scan/image.tif") + uris = Storage().list("patient/scan/") +""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from os import PathLike +from pathlib import Path +import shutil +from typing import IO, Iterator, Optional + +from box_sdk_gen import FileBaseTypeField +from box_sdk_gen.schemas.folder_mini import FolderBaseTypeField +from django.conf import settings + +logger = logging.getLogger(__name__) + + +# ── Box client helpers ──────────────────────────────────────────────────────── + +@dataclass +class _ItemData: + id: str + type: FolderBaseTypeField | FileBaseTypeField + + +# avoid repeated API calls. (parent_id, name) -> _ItemData | None +_box_item_cache: dict[tuple[str, str], _ItemData | None] = {} + + +def _get_box_client(): + """Return an authenticated Box client. + + Authentication precedence: developer token > JWT > OAuth. + + For OAuth, the token must have been obtained previously via the + ``/box/oauth/start/`` → ``/box/oauth/callback/`` flow and will be + loaded from the file-backed token storage configured by + ``BOX_TOKEN_STORAGE_PATH``. + """ + from BFD9000.settings import ( + BOX_DEVELOPER_TOKEN, + BOX_JWT_CONFIG_FILE, + BOX_OAUTH_CLIENT_ID, + BOX_OAUTH_CLIENT_SECRET, + BOX_TOKEN_STORAGE_PATH, + ) + from box_sdk_gen import BoxClient, BoxJWTAuth, BoxOAuth, JWTConfig, OAuthConfig + from box_sdk_gen.box.developer_token_auth import BoxDeveloperTokenAuth + from box_sdk_gen.box.token_storage import FileTokenStorage + + if BOX_DEVELOPER_TOKEN: + auth = BoxDeveloperTokenAuth(token=BOX_DEVELOPER_TOKEN) + elif BOX_JWT_CONFIG_FILE: + jwt_config = JWTConfig.from_config_file(config_file_path=BOX_JWT_CONFIG_FILE) + auth = BoxJWTAuth(config=jwt_config) # pyright: ignore[reportArgumentType] + elif BOX_OAUTH_CLIENT_ID and BOX_OAUTH_CLIENT_SECRET: + token_storage = FileTokenStorage(filename=BOX_TOKEN_STORAGE_PATH) + auth = BoxOAuth( + OAuthConfig( + client_id=BOX_OAUTH_CLIENT_ID, + client_secret=BOX_OAUTH_CLIENT_SECRET, + token_storage=token_storage, + ) + ) + else: + raise RuntimeError( + "Box authentication is not configured. Set BOX_DEVELOPER_TOKEN, " + "BOX_JWT_CONFIG_FILE, or BOX_OAUTH_CLIENT_ID + BOX_OAUTH_CLIENT_SECRET." + ) + return BoxClient(auth=auth) + + +def _get_item_by_name(client, folder_id: str, name: str) -> _ItemData | None: + """Look up a file or folder by name within a Box folder, with caching.""" + cache_key = (folder_id, name) + if cache_key in _box_item_cache: + result = _box_item_cache[cache_key] + logger.debug( + "Cache hit (%s): %s in folder %s", + "not found" if result is None else result.id, + name, + folder_id, + ) + return result + + try: + items = client.folders.get_folder_items(folder_id) + while True: + for item in items.entries or []: + if item.name == name: + logger.debug("Found item by name: %s %s (type: %s)", item.id, name, item.type) + data = _ItemData(id=item.id, type=item.type) + _box_item_cache[cache_key] = data + return data + if items.next_marker is None: + break + items = client.folders.get_folder_items(folder_id, marker=items.next_marker) + + logger.debug("Item not found (caching negative result): %s in folder %s", name, folder_id) + _box_item_cache[cache_key] = None + except Exception as exc: + logger.error("Box API error getting item by name: %s", exc, exc_info=True) + + return None + + +def _get_or_create_folder(client, parent_folder_id: str, folder_name: str) -> str | None: + """Return the ID of a Box folder, creating it if it does not exist.""" + from box_sdk_gen import BoxAPIError, CreateFolderParent + + try: + item = _get_item_by_name(client, parent_folder_id, folder_name) + if item is None: + subfolder = client.folders.create_folder( + name=folder_name, + parent=CreateFolderParent(id=parent_folder_id), + ) + logger.debug("Created Box folder: %s (ID: %s)", folder_name, subfolder.id) + _box_item_cache[(parent_folder_id, folder_name)] = _ItemData(id=subfolder.id, type=FolderBaseTypeField.FOLDER) + return subfolder.id + if item.type == FolderBaseTypeField.FOLDER: + return item.id + logger.error("Item '%s' exists but is not a folder (type: %s)", folder_name, item.type) + return None + except BoxAPIError as exc: + logger.error("Box API error creating folder %s: %s", folder_name, exc, exc_info=True) + return None + except Exception as exc: + logger.error("Error creating folder %s: %s", folder_name, exc, exc_info=True) + return None + + +# ── Abstract interface ──────────────────────────────────────────────────────── + +class StorageBackend(ABC): + """Abstract interface for a file storage backend.""" + + @abstractmethod + def upload(self, file: IO[bytes], path: PathLike[str]) -> str: + """Upload *file* to the given relative *path* and return a storage uri.""" + + @abstractmethod + def list(self, path: PathLike[str]) -> Iterator[str]: + """Yield storage uris for every file found under *path*.""" + + @abstractmethod + def download(self, uri: str) -> tuple[IO[bytes], str]: + """Download the resource identified by *uri*; return ``(stream, filename)``.""" + + @abstractmethod + def error(self) -> Optional[str]: + """Returns ``None`` if the storage backend is alive and reachable, and a string error message otherwise.""" + + +# ── Concrete backends ───────────────────────────────────────────────────────── + +class BoxStorageBackend(StorageBackend): + """Box.com storage backend. uris use the ``box://`` scheme.""" + + SCHEME = "box://" + + def upload(self, file: IO[bytes], path: PathLike[str]) -> str: + """Upload *file* to Box, mirroring the directory structure of *path*. + + Handles preflight checks and 409 conflicts (file already exists → delete + and retry). Raises ``NotImplementedError`` for files larger than 50 MB + until chunked upload is implemented. + + Returns a ``box://`` uri. + """ + from BFD9000.settings import BOX_FOLDER_ID + from box_sdk_gen import BoxAPIError + from box_sdk_gen.managers.uploads import ( + PreflightFileUploadCheckParent, + UploadFileAttributes, + UploadFileAttributesParentField, + ) + + client = _get_box_client() + parts = Path(path) + file_name = parts.name + current_folder_id = BOX_FOLDER_ID or "0" + + for folder_name in parts.parent.parts: + fid = _get_or_create_folder(client, current_folder_id, folder_name) + if fid is None: + raise RuntimeError(f"Failed to create/navigate to Box folder: {folder_name}") + current_folder_id = fid + + # Determine file size for preflight check (best-effort on seekable streams). + file_size = 0 + try: + pos = file.tell() + file.seek(0, 2) + file_size = file.tell() + file.seek(pos) + except (AttributeError, OSError): + pass + + if file_size > 50_000_000: + logger.warning("Chunked uploads (> 50 MB) are not yet implemented.") + + # Preflight check; retry up to 3 times on 409 (file exists → delete first). + upload_url = None + for _ in range(3): + try: + upload_url = client.uploads.preflight_file_upload_check( + name=file_name, + size=file_size, + parent=PreflightFileUploadCheckParent(id=current_folder_id), + ) + break + except BoxAPIError as exc: + if exc.response_info.status_code == 409: + logger.debug("File already exists in Box; deleting %s …", file_name) + file_id = exc.response_info.context_info["conflicts"]["id"] # pyright: ignore[reportOptionalSubscript] + client.files.delete_file_by_id(file_id) + _box_item_cache.pop((current_folder_id, file_name), None) + else: + raise exc + + if upload_url is None: + raise RuntimeError("Multiple 409 responses received during Box preflight check.") + + result = client.uploads.upload_file( + attributes=UploadFileAttributes( + name=file_name, + parent=UploadFileAttributesParentField(id=current_folder_id), + ), + file=file, # type: ignore[arg-type] + ) + uploaded_file = result.entries[0] # pyright: ignore[reportOptionalSubscript] + logger.debug("Uploaded %s to Box (ID: %s)", path, uploaded_file.id) + return f"{self.SCHEME}{uploaded_file.id}" + + def list(self, path: PathLike[str]) -> Iterator[str]: + """Yield ``box://`` uris for every file under *path* in Box.""" + from BFD9000.settings import BOX_FOLDER_ID + + client = _get_box_client() + current_folder_id = BOX_FOLDER_ID or "0" + + for folder_name in Path(path).parts: + item = _get_item_by_name(client, current_folder_id, folder_name) + if item is None or item.type != FolderBaseTypeField.FOLDER: + return + current_folder_id = item.id + + items = client.folders.get_folder_items(current_folder_id) + while True: + for item in items.entries or []: + if item.type == FileBaseTypeField.FILE: + yield f"{self.SCHEME}{item.id}" + if items.next_marker is None: + break + items = client.folders.get_folder_items(current_folder_id, marker=items.next_marker) + + def download(self, uri: str) -> tuple[IO[bytes], str]: + """Download the Box file identified by *uri*; return ``(stream, filename)``.""" + client = _get_box_client() + file_id = uri[len(self.SCHEME):] + file_info = client.files.get_file_by_id(file_id) + stream = client.downloads.download_file(file_id) + if stream is None: + raise RuntimeError(f"Box returned no content for file id {file_id}") + return stream, file_info.name # type: ignore[return-value] + + def error(self) -> Optional[str]: + """Return a string error message if the storage backend is alive and reachable, or ``None`` otherwise.""" + from BFD9000.settings import ( + BOX_DEVELOPER_TOKEN, + BOX_JWT_CONFIG_FILE, + BOX_OAUTH_CLIENT_ID, + BOX_OAUTH_CLIENT_SECRET, + BOX_FOLDER_ID + ) + + if not BOX_FOLDER_ID: + return "BOX_FOLDER_ID is not configured" + if ( + not BOX_DEVELOPER_TOKEN + and not BOX_JWT_CONFIG_FILE + and not (BOX_OAUTH_CLIENT_ID and BOX_OAUTH_CLIENT_SECRET) + ): + return "No Box authentication configured" + client = _get_box_client() + if BOX_OAUTH_CLIENT_ID and BOX_OAUTH_CLIENT_SECRET: + if client.auth.retrieve_token(): + return None + return "OAuth 2.0: no active token" + return None + + +class LocalStorageBackend(StorageBackend): + """Local filesystem storage backend. uris are paths relative to ``MEDIA_ROOT``.""" + + def upload(self, file: IO[bytes], path: PathLike[str]) -> str: + """Write *file* to *path* (relative to ``MEDIA_ROOT``) and return the path as a uri.""" + dest = Path(settings.MEDIA_ROOT) / path + dest.parent.mkdir(parents=True, exist_ok=True) + with open(dest, "wb") as f: + f.write(file.read()) + return str(path) + + def list(self, path: PathLike[str]) -> Iterator[str]: + """Yield relative paths for every file under *path* within ``MEDIA_ROOT``.""" + root = Path(settings.MEDIA_ROOT) / path + if not root.exists(): + return + yield from ( + str(p.relative_to(settings.MEDIA_ROOT)) + for p in root.rglob("*") + if p.is_file() + ) + + def download(self, uri: str) -> tuple[IO[bytes], str]: + """Open the local file at *uri* (relative to ``MEDIA_ROOT``); return ``(stream, filename)``.""" + full_path = Path(settings.MEDIA_ROOT) / uri + return open(full_path, "rb"), full_path.name + + def error(self) -> Optional[str]: + """Return an error if running low on disk space (< 5GiB), or ``None`` otherwise. This error is technically nonfatal.""" + try: + total, used, free = shutil.disk_usage(settings.MEDIA_ROOT) + if free < 5 * 1024 * 1024 * 1024: # less than 5 GB + return f"Disk space running low (free: {free / 1024 / 1024:2f} MiB)" + except Exception as exc: + return f"Error checking disk space: {exc}" + return None + + +class Storage(StorageBackend): + """Upload backend that tries Box first and falls back to local storage on failure. + + ``download`` and ``list`` delegate to the backend matching the uri scheme, + so uris produced by either backend continue to resolve correctly. + """ + + def upload(self, file: IO[bytes], path: PathLike[str], fallback: bool = False) -> str: + """Try Box; on any error reset the stream and write locally instead.""" + try: + return BoxStorageBackend().upload(file, path) + except Exception as exc: + if fallback: + logger.warning("Box upload failed, falling back to local storage (%s)", exc) + file.seek(0) + return LocalStorageBackend().upload(file, path) + else: + logger.error("Box upload failed, fallback=False (%s)", exc) + raise exc + + def list(self, path: PathLike[str]) -> Iterator[str]: + """Yield uris from both backends merged.""" + try: + yield from BoxStorageBackend().list(path) + except Exception: + pass + yield from LocalStorageBackend().list(path) + + def download(self, uri: str) -> tuple[IO[bytes], str]: + """Delegate to whichever backend owns this uri.""" + if uri.startswith(BoxStorageBackend.SCHEME): + return BoxStorageBackend().download(uri) + return LocalStorageBackend().download(uri) + + def error(self) -> Optional[str]: + """Return the errors for each backend.""" + e = "" + box = BoxStorageBackend().error() + if box: + e += f"Box: {box}\n" + local = LocalStorageBackend().error() + if local: + e += f"Local: {local}\n" + return e or None diff --git a/bfd9000_web/archive/tests/test_box_storage.py b/bfd9000_web/archive/tests/test_box_storage.py new file mode 100644 index 0000000..5c48dd5 --- /dev/null +++ b/bfd9000_web/archive/tests/test_box_storage.py @@ -0,0 +1,324 @@ +"""Tests for BoxStorageBackend using a fake in-memory Box client. + +The fake client simulates the Box folder/file tree entirely in memory and +records every API call so tests can assert on the exact sequence of operations. +No real Box credentials or network access are required. +""" + +from __future__ import annotations + +import io +from types import SimpleNamespace +from unittest.mock import patch + +from django.test import TestCase + +import archive.storage as storage_module +from archive.storage import BoxStorageBackend +from box_sdk_gen import BoxAPIError as _RealBoxAPIError, FileBaseTypeField +from box_sdk_gen.schemas.folder_mini import FolderBaseTypeField + +ROOT_FOLDER = "root-0" + + + +class _Conflict(_RealBoxAPIError): + """Minimal BoxAPIError carrying a 409 status and the conflicting file id.""" + + def __init__(self, file_id: str) -> None: + object.__init__(self) # bypass SDK constructor; we only need response_info + self.response_info = SimpleNamespace( + status_code=409, + context_info={"conflicts": {"id": file_id}}, + ) + +class _Item: + def __init__(self, item_id: str, name: str, type_) -> None: + self.id = item_id + self.name = name + self.type = type_ + + +class FakeBoxClient: + """Simulated Box client backed by an in-memory folder/file tree. + + Tree structure:: + + _tree: {folder_id: [_Item, ...]} # folder contents + _content: {file_id: bytes} # file bodies + + All operations are recorded in ``calls`` as + ``(method_label, *positional_args)`` tuples. + """ + + def __init__(self, root_folder_id: str = ROOT_FOLDER) -> None: + self._tree: dict[str, list[_Item]] = {root_folder_id: []} + self._content: dict[str, bytes] = {} + self._counter = 0 + self.calls: list[tuple] = [] + + def _new_id(self, prefix: str = "item") -> str: + self._counter += 1 + return f"{prefix}-{self._counter}" + + # test-setup helpers + + def seed_folder(self, parent_id: str, name: str) -> str: + """Insert a folder into the tree without recording an API call.""" + fid = self._new_id("folder") + self._tree[parent_id].append(_Item(fid, name, FolderBaseTypeField.FOLDER)) + self._tree[fid] = [] + return fid + + def seed_file(self, folder_id: str, name: str, content: bytes = b"data") -> str: + """Insert a file into the tree without recording an API call.""" + fid = self._new_id("file") + self._tree[folder_id].append(_Item(fid, name, FileBaseTypeField.FILE)) + self._content[fid] = content + return fid + + # SDK surface (accessed as client.folders, client.uploads, …) + + @property + def folders(self) -> _Folders: + return _Folders(self) + + @property + def uploads(self) -> _Uploads: + return _Uploads(self) + + @property + def files(self) -> _Files: + return _Files(self) + + @property + def downloads(self) -> _Downloads: + return _Downloads(self) + + +class _Folders: + def __init__(self, c: FakeBoxClient) -> None: + self._c = c + + def get_folder_items(self, folder_id: str, **_kw): + self._c.calls.append(("folders.get_folder_items", folder_id)) + entries = list(self._c._tree.get(folder_id, [])) + return SimpleNamespace(entries=entries, next_marker=None) + + def create_folder(self, name: str, parent): + self._c.calls.append(("folders.create_folder", name, parent.id)) + fid = self._c._new_id("folder") + item = _Item(fid, name, FolderBaseTypeField.FOLDER) + self._c._tree[parent.id].append(item) + self._c._tree[fid] = [] + return item + + +class _Uploads: + def __init__(self, c: FakeBoxClient) -> None: + self._c = c + + def preflight_file_upload_check(self, name: str, size: int, parent): + self._c.calls.append(("uploads.preflight", name, parent.id)) + for item in self._c._tree.get(parent.id, []): + if item.name == name: + raise _Conflict(item.id) + return SimpleNamespace(upload_url="https://upload.box.com/fake") + + def upload_file(self, attributes, file): + folder_id = attributes.parent.id + name = attributes.name + self._c.calls.append(("uploads.upload_file", name, folder_id)) + fid = self._c._new_id("file") + content = file.read() if hasattr(file, "read") else b"" + item = _Item(fid, name, FileBaseTypeField.FILE) + self._c._tree[folder_id].append(item) + self._c._content[fid] = content + return SimpleNamespace(entries=[item]) + + +class _Files: + def __init__(self, c: FakeBoxClient) -> None: + self._c = c + + def get_file_by_id(self, file_id: str): + self._c.calls.append(("files.get_file_by_id", file_id)) + for items in self._c._tree.values(): + for item in items: + if item.id == file_id and item.type == FileBaseTypeField.FILE: + return SimpleNamespace(name=item.name) + raise RuntimeError(f"No file with id {file_id!r} in fake filesystem") + + def delete_file_by_id(self, file_id: str): + self._c.calls.append(("files.delete_file_by_id", file_id)) + for items in self._c._tree.values(): + for item in list(items): + if item.id == file_id: + items.remove(item) + self._c._content.pop(file_id, None) + return + raise RuntimeError(f"No file with id {file_id!r} to delete") + + +class _Downloads: + def __init__(self, c: FakeBoxClient) -> None: + self._c = c + + def download_file(self, file_id: str): + self._c.calls.append(("downloads.download_file", file_id)) + content = self._c._content.get(file_id) + return io.BytesIO(content) if content is not None else None + + +# Test cases + +class BoxStorageBackendTests(TestCase): + """BoxStorageBackend behaviour verified against the fake client.""" + + def setUp(self): + storage_module._box_item_cache.clear() + self.fs = FakeBoxClient(root_folder_id=ROOT_FOLDER) + self._patches = [ + patch("archive.storage._get_box_client", return_value=self.fs), + patch("BFD9000.settings.BOX_FOLDER_ID", ROOT_FOLDER), + ] + for p in self._patches: + p.start() + self.backend = BoxStorageBackend() + + def tearDown(self): + for p in self._patches: + p.stop() + storage_module._box_item_cache.clear() + + # helpers ----------------------------------------------------------------- + + def _call_names(self) -> list[str]: + return [c[0] for c in self.fs.calls] + + def test_upload_file_to_root_returns_box_link(self): + """Uploading a file returns a box:// link and stores the content.""" + link = self.backend.upload(io.BytesIO(b"hello"), "scan.jpg") + + self.assertTrue(link.startswith("box://"), link) + file_id = link[len("box://"):] + self.assertEqual(self.fs._content[file_id], b"hello") + + def test_upload_issues_preflight_then_upload(self): + """A successful upload always runs preflight before upload_file.""" + self.backend.upload(io.BytesIO(b"x"), "scan.jpg") + + names = self._call_names() + self.assertIn("uploads.preflight", names) + self.assertIn("uploads.upload_file", names) + self.assertLess( + names.index("uploads.preflight"), + names.index("uploads.upload_file"), + ) + + def test_upload_creates_missing_intermediate_folders(self): + """Uploading to a nested path creates every folder that is absent.""" + self.backend.upload(io.BytesIO(b"img"), "patient/scan/image.tif") + + create_calls = [c for c in self.fs.calls if c[0] == "folders.create_folder"] + created_names = [c[1] for c in create_calls] + self.assertEqual(created_names, ["patient", "scan"]) + + def test_upload_reuses_existing_folders(self): + """Uploading to a path whose folders already exist never calls create_folder.""" + patient_id = self.fs.seed_folder(ROOT_FOLDER, "patient") + + self.backend.upload(io.BytesIO(b"img"), "patient/scan.jpg") + + create_calls = [c for c in self.fs.calls if c[0] == "folders.create_folder"] + self.assertEqual(create_calls, []) + # File ended up in the pre-existing patient folder + file_items = [ + i for i in self.fs._tree[patient_id] if i.type == FileBaseTypeField.FILE + ] + self.assertEqual(len(file_items), 1) + + def test_upload_conflict_deletes_old_file_then_reuploads(self): + """A 409 conflict on preflight causes the old file to be deleted and re-uploaded.""" + old_id = self.fs.seed_file(ROOT_FOLDER, "scan.jpg", b"old content") + + link = self.backend.upload(io.BytesIO(b"new content"), "scan.jpg") + + # Old file removed + self.assertNotIn(old_id, self.fs._content) + # New file present with correct content + new_id = link[len("box://"):] + self.assertEqual(self.fs._content[new_id], b"new content") + # delete_file_by_id was called exactly once with the old id + deletes = [c for c in self.fs.calls if c[0] == "files.delete_file_by_id"] + self.assertEqual(len(deletes), 1) + self.assertEqual(deletes[0][1], old_id) + + def test_upload_large_file_logs_warning(self): + """Files over 50 MB log a warning (chunked upload not yet implemented).""" + big = io.BytesIO(b"\x00" * 50_000_001) + with self.assertLogs("archive.storage", level="WARNING") as cm: + self.backend.upload(big, "big.bin") + self.assertTrue(any("50 MB" in line for line in cm.output)) + + def test_list_empty_folder_returns_empty(self): + self.fs.seed_folder(ROOT_FOLDER, "empty") + self.assertCountEqual(self.backend.list("empty"), []) + + def test_list_returns_box_links_for_files(self): + folder_id = self.fs.seed_folder(ROOT_FOLDER, "scans") + fid1 = self.fs.seed_file(folder_id, "a.jpg") + fid2 = self.fs.seed_file(folder_id, "b.jpg") + + links = self.backend.list("scans") + + self.assertCountEqual(links, [f"box://{fid1}", f"box://{fid2}"]) + + def test_list_navigates_nested_path(self): + patient_id = self.fs.seed_folder(ROOT_FOLDER, "patient") + scan_id = self.fs.seed_folder(patient_id, "scan") + fid = self.fs.seed_file(scan_id, "img.jpg") + + links = self.backend.list("patient/scan") + + self.assertCountEqual(links, [f"box://{fid}"]) + + def test_list_missing_path_returns_empty(self): + self.assertCountEqual(self.backend.list("does/not/exist"), []) + + def test_list_excludes_subfolders(self): + """Only file entries are included; sub-folders are omitted.""" + folder_id = self.fs.seed_folder(ROOT_FOLDER, "parent") + self.fs.seed_folder(folder_id, "child_dir") + fid = self.fs.seed_file(folder_id, "file.jpg") + + links = self.backend.list("parent") + + self.assertCountEqual(links, [f"box://{fid}"]) + + def test_download_returns_correct_stream_and_filename(self): + fid = self.fs.seed_file(ROOT_FOLDER, "photo.jpg", b"pixels") + + stream, filename = self.backend.download(f"box://{fid}") + + self.assertEqual(filename, "photo.jpg") + self.assertEqual(stream.read(), b"pixels") + + def test_download_issues_get_file_info_then_stream(self): + """Download always fetches file metadata before streaming content.""" + fid = self.fs.seed_file(ROOT_FOLDER, "x.jpg", b"x") + + self.backend.download(f"box://{fid}") + + self.assertEqual( + self._call_names(), + ["files.get_file_by_id", "downloads.download_file"], + ) + + def test_download_raises_when_stream_is_none(self): + """RuntimeError is raised when Box returns no byte stream.""" + fid = self.fs.seed_file(ROOT_FOLDER, "ghost.jpg", b"will be removed") + del self.fs._content[fid] # simulate Box returning None for the body + + with self.assertRaises(RuntimeError): + self.backend.download(f"box://{fid}") diff --git a/bfd9000_web/archive/urls.py b/bfd9000_web/archive/urls.py index 475498e..d2e4e55 100644 --- a/bfd9000_web/archive/urls.py +++ b/bfd9000_web/archive/urls.py @@ -64,6 +64,8 @@ path('records//', views.record_detail, name='record_detail'), path('physical-records/', views.physical_records, name='physical_records'), path('api/scan/tiff-preview/', views.scan_tiff_preview, name='scan_tiff_preview'), + path('box/oauth/start/', views.box_oauth_start, name='box_oauth_start'), + path('box/oauth/callback/', views.box_oauth_callback, name='box_oauth_callback'), # API routes path('api/', include(router.urls)), path('api/', include(subjects_router.urls)), diff --git a/bfd9000_web/archive/views.py b/bfd9000_web/archive/views.py index 14042c9..be6ebf9 100644 --- a/bfd9000_web/archive/views.py +++ b/bfd9000_web/archive/views.py @@ -5,24 +5,32 @@ for subjects, encounters, records, and related medical entities. It also includes custom actions for file serving and valueset retrieval. """ +import logging +import mimetypes import os +import secrets from typing import Any, Dict, List, Optional, Type +logger = logging.getLogger(__name__) + from PIL import Image from rest_framework import viewsets, serializers, filters from rest_framework.decorators import action from rest_framework.generics import get_object_or_404 from rest_framework.permissions import IsAuthenticated + +from archive.storage import Storage from .permissions import CuratorOrSuperuserEditPermission, RecordPermission from rest_framework.request import Request from rest_framework.response import Response from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema from django.conf import settings +from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.decorators import login_required from django.db.models import Case, CharField, Count, OuterRef, QuerySet, Subquery, When, Prefetch -from django.http import FileResponse, HttpResponse, JsonResponse -from django.shortcuts import render +from django.http import FileResponse, HttpRequest, HttpResponse, JsonResponse +from django.shortcuts import redirect, render from django.views.decorators.http import require_POST from .models import ( Coding, Identifier, Address, Collection, Subject, @@ -402,8 +410,11 @@ def image(self, request: Request, pk: Optional[int] = None, **kwargs: Any) -> An digital_record = self.get_object() if not getattr(digital_record, 'source_file', None): return Response({"error": "No image file available"}, status=404) - source_file = digital_record.source_file - return FileResponse(source_file.open('rb')) + uri = digital_record.source_file.name + + stream, filename = Storage().download(uri) + content_type, _ = mimetypes.guess_type(filename) + return FileResponse(stream, content_type=content_type or "application/octet-stream", filename=filename) @extend_schema( responses={ @@ -534,3 +545,109 @@ def scan_tiff_preview(request): return HttpResponse(png_bytes, content_type="image/png") except Exception as exc: # pylint: disable=broad-exception-caught return JsonResponse({"error": f"Failed to convert TIFF: {exc}"}, status=400) + + +# ── Box OAuth 2.0 views ─────────────────────────────────────────────────────── + +_BOX_OAUTH_STATE_SESSION_KEY = "_box_oauth_state" + + +@staff_member_required +def box_oauth_start(request: HttpRequest) -> HttpResponse: + """Redirect a staff user to Box's OAuth 2.0 authorization page. + + Stores a CSRF ``state`` token in the session so the callback can verify + the response came from Box and not a hijacked request. + """ + from box_sdk_gen import BoxOAuth, OAuthConfig + from box_sdk_gen.box.oauth import GetAuthorizeUrlOptions + from box_sdk_gen.box.token_storage import FileTokenStorage + from django.urls import reverse + from BFD9000.settings import ( + BOX_OAUTH_CLIENT_ID, + BOX_OAUTH_CLIENT_SECRET, + BOX_OAUTH_REDIRECT_URI, + BOX_TOKEN_STORAGE_PATH, + ) + + if not BOX_OAUTH_CLIENT_ID or not BOX_OAUTH_CLIENT_SECRET: + return HttpResponse( + "Box OAuth is not configured. Set BOX_OAUTH_CLIENT_ID and BOX_OAUTH_CLIENT_SECRET.", + status=503, + ) + + state: str = secrets.token_urlsafe(32) + request.session[_BOX_OAUTH_STATE_SESSION_KEY] = state + + redirect_uri: str = BOX_OAUTH_REDIRECT_URI or request.build_absolute_uri( + reverse("archive:box_oauth_callback") + ) + + token_storage = FileTokenStorage(filename=BOX_TOKEN_STORAGE_PATH) + auth = BoxOAuth( + OAuthConfig( + client_id=BOX_OAUTH_CLIENT_ID, + client_secret=BOX_OAUTH_CLIENT_SECRET, + token_storage=token_storage, + ) + ) + auth_url: str = auth.get_authorize_url( + options=GetAuthorizeUrlOptions(redirect_uri=redirect_uri, state=state) + ) + logger.info("Initiating Box OAuth flow for user %s", request.user) + return redirect(auth_url) + + +@staff_member_required +def box_oauth_callback(request: HttpRequest) -> HttpResponse: + """Handle the Box OAuth 2.0 redirect callback. + + Verifies the ``state`` parameter, exchanges the authorization code for + tokens, and persists them via ``FileTokenStorage``. + """ + from box_sdk_gen import BoxOAuth, OAuthConfig + from box_sdk_gen.box.token_storage import FileTokenStorage + from BFD9000.settings import ( + BOX_OAUTH_CLIENT_ID, + BOX_OAUTH_CLIENT_SECRET, + BOX_TOKEN_STORAGE_PATH, + ) + + expected_state: str | None = request.session.pop(_BOX_OAUTH_STATE_SESSION_KEY, None) + received_state: str | None = request.GET.get("state") + if not expected_state or expected_state != received_state: + logger.warning("Box OAuth state mismatch (possible CSRF attempt)") + return HttpResponse("OAuth state mismatch. Please try again.", status=400) + + if not BOX_OAUTH_CLIENT_ID or not BOX_OAUTH_CLIENT_SECRET: + return HttpResponse( + "Box OAuth is not configured. Set BOX_OAUTH_CLIENT_ID and BOX_OAUTH_CLIENT_SECRET.", + status=503, + ) + + error: str | None = request.GET.get("error") + if error: + description: str = request.GET.get("error_description", "") + logger.warning("Box OAuth denied: %s %s", error, description) + return HttpResponse(f"Box authorization denied: {error} — {description}", status=400) + + code: str | None = request.GET.get("code") + if not code: + return HttpResponse("No authorization code received from Box.", status=400) + + token_storage = FileTokenStorage(filename=BOX_TOKEN_STORAGE_PATH) + auth = BoxOAuth( + OAuthConfig( + client_id=BOX_OAUTH_CLIENT_ID, + client_secret=BOX_OAUTH_CLIENT_SECRET, + token_storage=token_storage, + ) + ) + try: + auth.get_tokens_authorization_code_grant(code) + except Exception as exc: + logger.error("Box OAuth token exchange failed: %s", exc, exc_info=True) + return HttpResponse(f"Token exchange failed: {exc}", status=500) + + logger.info("Box OAuth tokens stored successfully for user %s", request.user) + return redirect("archive:index") diff --git a/bfd9000_web/docker-compose.yml b/bfd9000_web/docker-compose.yml index 50255b4..0ae1d97 100644 --- a/bfd9000_web/docker-compose.yml +++ b/bfd9000_web/docker-compose.yml @@ -58,6 +58,23 @@ services: - SCANNER_API_BASE=http://127.0.0.1:9010 + # Box.com integration — use exactly one auth method (precedence: token > JWT > OAuth). + # Credentials are read from Docker secrets (/run/secrets/); env vars take precedence. + # Create the secrets directory and populate the files you need: + # mkdir -p secrets + # echo "your_developer_token" > secrets/BOX_DEVELOPER_TOKEN # Option A (dev only) + # echo "100123456789" > secrets/BOX_FOLDER_ID + # echo "your_client_id" > secrets/BOX_OAUTH_CLIENT_ID # Option C + # echo "your_client_secret" > secrets/BOX_OAUTH_CLIENT_SECRET + # - BOX_DEVELOPER_TOKEN=your_developer_token_here # env-var alternative (dev only) + # - BOX_OAUTH_CLIENT_ID=your_client_id_here # env-var alternative + secrets: + - BOX_DEVELOPER_TOKEN + - BOX_FOLDER_ID + - BOX_OAUTH_CLIENT_ID + - BOX_OAUTH_CLIENT_SECRET + # - BOX_JWT_CONFIG_FILE # path inside container; volume-mount the JSON file separately + bfd9020: image: ghcr.io/open-ortho/edu.case.bfd9020:local container_name: bfd9020 @@ -80,3 +97,13 @@ services: volumes: media_volume: postgres_data: + +secrets: + BOX_DEVELOPER_TOKEN: + file: ./secrets/BOX_DEVELOPER_TOKEN # Option A: echo "your_token" > secrets/BOX_DEVELOPER_TOKEN + BOX_FOLDER_ID: + file: ./secrets/BOX_FOLDER_ID # echo "100123456789" > secrets/BOX_FOLDER_ID + BOX_OAUTH_CLIENT_ID: + file: ./secrets/BOX_OAUTH_CLIENT_ID # Option C: echo "your_client_id" > secrets/BOX_OAUTH_CLIENT_ID + BOX_OAUTH_CLIENT_SECRET: + file: ./secrets/BOX_OAUTH_CLIENT_SECRET # echo "your_client_secret" > secrets/BOX_OAUTH_CLIENT_SECRET diff --git a/bfd9000_web/dot-env.example b/bfd9000_web/dot-env.example deleted file mode 100644 index 07cc734..0000000 --- a/bfd9000_web/dot-env.example +++ /dev/null @@ -1,4 +0,0 @@ -DEBUG=True -SECRET_KEY=change-me -DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 -CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 diff --git a/bfd9000_web/requirements-dev.txt b/bfd9000_web/requirements-dev.txt index ae431d0..ab23a39 100644 --- a/bfd9000_web/requirements-dev.txt +++ b/bfd9000_web/requirements-dev.txt @@ -4,3 +4,5 @@ django-types>=0.20 pytest>=8.0,<10.0 pytest-django>=4.10,<5.0 +django-stubs>=4.2,<5.0 +djangorestframework-stubs>=3.14,<4.0 diff --git a/bfd9000_web/requirements.txt b/bfd9000_web/requirements.txt index 14019fb..22c7a31 100644 --- a/bfd9000_web/requirements.txt +++ b/bfd9000_web/requirements.txt @@ -9,6 +9,8 @@ drf-spectacular>=0.27,<1.0 gunicorn>=21.0,<22.0 whitenoise>=6.6,<7.0 openpyxl>=3.1,<4.0 +python-dotenv>=1.2.1,<2.0 +box_sdk_gen>=1.17.0 pydicom>=3.0,<4.0 cryptography>=44.0,<45.0 psycopg2-binary>=2.9,<3.0 diff --git a/flake.nix b/flake.nix index 9c3c193..844b5b0 100644 --- a/flake.nix +++ b/flake.nix @@ -20,6 +20,33 @@ let pkgs = import nixpkgs { inherit system; }; python = pkgs.python311; + + # Custom build for boxsdk + box_sdk_gen = python.pkgs.buildPythonPackage rec { + pname = "box_sdk_gen"; + version = "1.17.0"; + format = "setuptools"; + + src = pkgs.fetchPypi { + inherit pname version; + sha256 = "sha256-UgDqRRA/Ag0iRzxQKasQLay7A0xF7XmJWi45tMVfXi4="; + }; + + propagatedBuildInputs = with python.pkgs; [ + requests + requests_toolbelt + pyjwt + cryptography + ]; + + doCheck = false; + + meta = with pkgs.lib; { + description = "Official Box Python SDK"; + homepage = "https://github.com/box/box-python-sdk"; + license = licenses.asl20; + }; + }; in { default = pkgs.mkShell { @@ -28,6 +55,8 @@ buildInputs = [ python python.pkgs.pip + python.pkgs.python-dotenv + box_sdk_gen pkgs.watchman ];