# S3/MinIO File Storage Pattern v1.0.0 Standardizes how Django apps in Spelunker store, read, and reference files in S3/MinIO, covering upload paths, model metadata fields, storage-agnostic I/O, and test isolation. ## 🐾 Red Panda Approvalβ„’ This pattern follows Red Panda Approval standards. --- ## Why a Pattern, Not a Shared Implementation Each Django app stores files for a different domain purpose with different path conventions, processing workflows, and downstream consumers, making a single shared model impractical. - The **rfp_manager** app needs files scoped under an RFP ID (info docs, question spreadsheets, generated exports), with no embedding β€” only LLM summarization - The **solution_library** app needs files tied to vendor/solution hierarchies, with full text embedding and chunk storage, plus scraped documents that have no Django `FileField` at all - The **rag** app needs to programmatically write chunk texts to S3 during embedding and read them back for search context - The **core** app needs a simple image upload for organization logos without any processing pipeline Instead, this pattern defines: - **Required fields** β€” the minimum every file-backed model must have - **Recommended fields** β€” metadata most implementations should track - **Standard path conventions** β€” bucket key prefixes each domain owns - **Storage-agnostic I/O** β€” how to read and write files so tests work without a real S3 bucket --- ## Required Fields Every model that stores a file in S3/MinIO must have at minimum: ```python from django.core.validators import FileExtensionValidator from django.db import models def my_domain_upload_path(instance, filename): """Return a scoped S3 key for this domain.""" return f'my_domain/{instance.parent_id}/{filename}' class MyDocument(models.Model): file = models.FileField( upload_to=my_domain_upload_path, # or a string prefix validators=[FileExtensionValidator(allowed_extensions=[...])], ) file_type = models.CharField(max_length=100, blank=True) # extension without dot file_size = models.PositiveIntegerField(null=True, blank=True) # bytes ``` --- ## Standard Path Conventions Use these exact key prefixes so buckets stay organized and IAM policies can target prefixes. | App / Purpose | S3 Key Prefix | |--------------------------------|--------------------------------------------| | Solution library documents | `documents/` | | Scraped documentation sources | `scraped/{source_id}/{filename}` | | Embedding chunk texts | `chunks/{document_id}/chunk_{index}.txt` | | RFP information documents | `rfp_info_documents/{rfp_id}/{filename}` | | RFP question spreadsheets | `rfp_question_documents/{rfp_id}/{filename}` | | RFP generated exports | `rfp_exports/{rfp_id}/{filename}` | | Organization logos | `orgs/logos/` | --- ## Recommended Fields and Behaviors Most file-backed models should also include these and populate them automatically. ```python class MyDocument(models.Model): # ... required fields above ... # Recommended: explicit S3 key for programmatic access and admin visibility s3_key = models.CharField(max_length=500, blank=True) def save(self, *args, **kwargs): """Auto-populate file metadata on every save.""" if self.file: self.s3_key = self.file.name if hasattr(self.file, 'size'): self.file_size = self.file.size if self.file.name and '.' in self.file.name: self.file_type = self.file.name.rsplit('.', 1)[-1].lower() super().save(*args, **kwargs) ``` --- ## Pattern Variant 1: FileField Upload (User-Initiated Upload) Used by `rfp_manager.RFPInformationDocument`, `rfp_manager.RFPQuestionDocument`, `rfp_manager.RFPExport`, `solution_library.Document`, and `core.Organization`. The user (or Celery task generating an export) provides a file. Django's `FileField` handles the upload to S3 automatically via the configured storage backend. ```python import os from django.core.validators import FileExtensionValidator from django.db import models def rfp_info_document_path(instance, filename): """Scope uploads under the parent RFP's ID to keep the bucket organized.""" return f'rfp_info_documents/{instance.rfp.id}/{filename}' class RFPInformationDocument(models.Model): file = models.FileField( upload_to=rfp_info_document_path, validators=[FileExtensionValidator( allowed_extensions=['pdf', 'doc', 'docx', 'txt', 'md'] )], ) title = models.CharField(max_length=500) file_type = models.CharField(max_length=100, blank=True) file_size = models.PositiveIntegerField(null=True, blank=True) def save(self, *args, **kwargs): if self.file: if hasattr(self.file, 'size'): self.file_size = self.file.size if self.file.name: self.file_type = os.path.splitext(self.file.name)[1].lstrip('.') super().save(*args, **kwargs) ``` --- ## Pattern Variant 2: Programmatic Write (Code-Generated Content) Used by `rag.services.embeddings` (chunk texts) and `solution_library.services.sync` (scraped documents). Content is generated or fetched in code and written directly to S3 using `default_storage.save()` with a `ContentFile`. The model records the resulting S3 key for later retrieval. ```python from django.core.files.base import ContentFile from django.core.files.storage import default_storage def store_chunk(document_id: int, chunk_index: int, text: str) -> str: """ Store an embedding chunk in S3 and return the saved key. Returns: The actual S3 key (may differ from requested if file_overwrite=False) """ s3_key = f'chunks/{document_id}/chunk_{chunk_index}.txt' saved_key = default_storage.save(s3_key, ContentFile(text.encode('utf-8'))) return saved_key def store_scraped_document(source_id: int, filename: str, content: str) -> str: """Store scraped document content in S3 and return the saved key.""" s3_key = f'scraped/{source_id}/{filename}' return default_storage.save(s3_key, ContentFile(content.encode('utf-8'))) ``` When creating the model record after a programmatic write, use `s3_key` rather than a `FileField`: ```python Document.objects.create( title=filename, s3_key=saved_key, file_size=len(content), file_type='md', # Note: `file` field is intentionally empty β€” this is a scraped document ) ``` --- ## Pattern Variant 3: Storage-Agnostic Read Used by `rfp_manager.services.excel_processor`, `rag.services.embeddings._read_document_content`, and `solution_library.models.DocumentEmbedding.get_chunk_text`. Always read via `default_storage.open()` so the same code works against S3 in production and `FileSystemStorage` in tests. Never construct a filesystem path from `settings.MEDIA_ROOT`. ```python from django.core.files.storage import default_storage from io import BytesIO def load_binary_from_storage(file_path: str) -> BytesIO: """ Read a binary file from storage into a BytesIO buffer. Works against S3/MinIO in production and FileSystemStorage in tests. """ with default_storage.open(file_path, 'rb') as f: return BytesIO(f.read()) def read_text_from_storage(s3_key: str) -> str: """Read a text file from storage.""" with default_storage.open(s3_key, 'r') as f: return f.read() ``` When a model has both a `file` field (user upload) and a bare `s3_key` (scraped/programmatic), check which path applies: ```python def _read_document_content(self, document) -> str: if document.s3_key and not document.file: # Scraped document: no FileField, read by key with default_storage.open(document.s3_key, 'r') as f: return f.read() # Uploaded document: use the FileField with document.file.open('r') as f: return f.read() ``` --- ## Pattern Variant 4: S3 Connectivity Validation Used by `solution_library.models.Document.clean()` and `solution_library.services.sync.sync_documentation_source`. Validate that the bucket is reachable before attempting an upload or sync. This surfaces credential errors with a user-friendly message rather than a cryptic 500. ```python from botocore.exceptions import ClientError, NoCredentialsError from django.core.exceptions import ValidationError from django.core.files.storage import default_storage def validate_s3_connectivity(): """ Raise ValidationError if S3/MinIO bucket is not accessible. Only call on new uploads or at the start of a background sync. """ if not hasattr(default_storage, 'bucket'): return # Not an S3 backend (e.g., tests), skip validation try: default_storage.bucket.meta.client.head_bucket( Bucket=default_storage.bucket_name ) except ClientError as e: code = e.response.get('Error', {}).get('Code', '') if code == '403': raise ValidationError( "S3/MinIO credentials are invalid or permissions are insufficient." ) elif code == '404': raise ValidationError( f"Bucket '{default_storage.bucket_name}' does not exist." ) raise ValidationError(f"S3/MinIO error ({code}): {e}") except NoCredentialsError: raise ValidationError("S3/MinIO credentials are not configured.") ``` In a model's `clean()`, guard with `not self.pk` to avoid checking on every update: ```python def clean(self): super().clean() if self.file and not self.pk: # New uploads only validate_s3_connectivity() ``` --- ## Domain Extension Examples ### rfp_manager App RFP documents are scoped under the RFP ID for isolation and easy cleanup. The app uses three document types (info, question, export), each with its own callable path function to keep the bucket navigation clear. ```python def rfp_export_path(instance, filename): return f'rfp_exports/{instance.rfp.id}/{filename}' class RFPExport(models.Model): export_file = models.FileField(upload_to=rfp_export_path) version = models.CharField(max_length=50) file_size = models.PositiveIntegerField(null=True, blank=True) question_count = models.IntegerField() answered_count = models.IntegerField() # No s3_key field - export files are always accessed via FileField ``` ### solution_library App Solution library documents track an explicit `s3_key` because the app supports two document origins: user uploads (with `FileField`) and scraped documents (programmatic write only, no `FileField`). For embedding, chunk texts are stored separately in S3 and referenced from `DocumentEmbedding` via `chunk_s3_key`. ```python class Document(models.Model): file = models.FileField(upload_to='documents/', blank=True) # blank=True: scraped docs s3_key = models.CharField(max_length=500, blank=True) # always populated content_hash = models.CharField(max_length=64, blank=True, db_index=True) class DocumentEmbedding(models.Model): document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name='embeddings') chunk_s3_key = models.CharField(max_length=500) # e.g. chunks/42/chunk_7.txt chunk_index = models.IntegerField() chunk_size = models.PositiveIntegerField() embedding = VectorField(null=True, blank=True) # pgvector column def get_chunk_text(self) -> str: from django.core.files.storage import default_storage with default_storage.open(self.chunk_s3_key, 'r') as f: return f.read() ``` --- ## Anti-Patterns - ❌ Don't build filesystem paths with `os.path.join(settings.MEDIA_ROOT, ...)` β€” always read through `default_storage.open()` - ❌ Don't store file content as a `TextField` or `BinaryField` in the database - ❌ Don't use `default_acl='public-read'` β€” all Spelunker buckets use `private` ACL with `querystring_auth=True` (pre-signed URLs) - ❌ Don't skip `FileExtensionValidator` on upload fields β€” it is the first line of defence against unexpected file types - ❌ Don't call `document.file.storage.size()` or `.exists()` in hot paths β€” these make network round-trips; use the `s3_key` and metadata fields for display purposes - ❌ Don't make S3 API calls in tests without first overriding `STORAGES` in `test_settings.py` - ❌ Don't use `file_overwrite=True` β€” the global setting `file_overwrite=False` ensures Django auto-appends a unique suffix rather than silently overwriting existing objects --- ## Settings ```python # spelunker/settings.py STORAGES = { "default": { "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", "OPTIONS": { "access_key": env('S3_ACCESS_KEY'), "secret_key": env('S3_SECRET_KEY'), "bucket_name": env('S3_BUCKET_NAME'), "endpoint_url": env('S3_ENDPOINT_URL'), # Use for MinIO or non-AWS S3 "use_ssl": env('S3_USE_SSL'), "default_acl": env('S3_DEFAULT_ACL'), # Must be 'private' "region_name": env('S3_REGION_NAME'), "file_overwrite": False, # Prevent silent overwrites "querystring_auth": True, # Pre-signed URLs for all access "verify": env.bool('S3_VERIFY_SSL', default=True), } }, "staticfiles": { # Static files are served locally (nginx), never from S3 "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", }, } ``` Environment variables (see `.env.example`): ```bash S3_ACCESS_KEY= S3_SECRET_KEY= S3_BUCKET_NAME=spelunker-documents S3_ENDPOINT_URL=http://localhost:9000 # MinIO local dev S3_USE_SSL=False S3_VERIFY_SSL=False S3_DEFAULT_ACL=private S3_REGION_NAME=us-east-1 ``` Test override (disables all S3 calls): ```python # spelunker/test_settings.py STORAGES = { "default": { "BACKEND": "django.core.files.storage.FileSystemStorage", "OPTIONS": {"location": "/tmp/test_media/"}, }, "staticfiles": { "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", }, } ``` --- ## Testing Standard test cases every file-backed implementation should cover. ```python import os import tempfile from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, override_settings @override_settings( STORAGES={ "default": { "BACKEND": "django.core.files.storage.FileSystemStorage", "OPTIONS": {"location": tempfile.mkdtemp()}, }, "staticfiles": { "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", }, } ) class MyDocumentStorageTest(TestCase): def test_file_metadata_populated_on_save(self): """file_type and file_size are auto-populated from the uploaded file.""" uploaded = SimpleUploadedFile("report.pdf", b"%PDF-1.4 content", content_type="application/pdf") doc = MyDocument.objects.create(file=uploaded, title="Test") self.assertEqual(doc.file_type, "pdf") self.assertGreater(doc.file_size, 0) def test_upload_path_includes_parent_id(self): """upload_to callable scopes the key under the parent ID.""" uploaded = SimpleUploadedFile("q.xlsx", b"PK content") doc = MyDocument.objects.create(file=uploaded, title="Questions", rfp=self.rfp) self.assertIn(str(self.rfp.id), doc.file.name) def test_rejected_extension(self): """FileExtensionValidator rejects disallowed file types.""" from django.core.exceptions import ValidationError uploaded = SimpleUploadedFile("hack.exe", b"MZ") doc = MyDocument(file=uploaded, title="Bad") with self.assertRaises(ValidationError): doc.full_clean() def test_storage_agnostic_read(self): """Reading via default_storage.open() works against FileSystemStorage.""" from django.core.files.base import ContentFile from django.core.files.storage import default_storage key = default_storage.save("test/hello.txt", ContentFile(b"hello world")) with default_storage.open(key, 'r') as f: content = f.read() self.assertEqual(content, "hello world") default_storage.delete(key) ```