Repository: openedx/edx-submissions
Pull request #286: FC-73 Xqu add submission file (Feature: Submission File System - Enhanced File Management for Graded Submissions)
Author: login: leoaulasneo98
Labels: name: open-source-contribution
description: PR author is not from Axim or 2U
name: FC
description: Relates to an Axim Funded Contribution project
Created: 2025-02-24T15:19:23Z | Merged: 2025-05-19T17:28:35Z

# FC-73 Feature: Submission File System - Enhanced File Management for Graded Submissions

## Description

This pull request implements a comprehensive file management system for submissions through the introduction of the `SubmissionFile` model and its associated `SubmissionFileManager`. This enhancement supports proper storage, retrieval, and processing of files attached to submissions within the Open edX ecosystem.

## Motivation

The existing submission system requires enhanced file handling capabilities to:
* Provide seamless transition for existing services (xwatcher)
* Establish reliable file storage with appropriate metadata
* Integrate seamlessly with the new submission queue architecture

Previously, the XQueue server managed files for submissions that required document attachments. However, this was implemented inefficiently using plain text fields (s3_keys and s3_urls) without proper structure or validation. This approach led to inconsistent file handling, potential security issues, and maintenance challenges as the system scaled.

## Key Improvements

### Model Enhancements
* Introduced `SubmissionFile` model with:
  * Secure file storage mechanisms
  * Proper association with submission queue records
  * Standardized URL generation for grader access
  * Original filename preservation

### File Processing System
* Developed `SubmissionFileManager` with capabilities for:
  * Processing multiple file formats (bytes, file objects)
  * Robust error handling for file processing issues
  * Consistent URL generation for xqueue compatibility
  * Efficient retrieval methods for external grader integration

### Error Handling
* Implemented comprehensive error handling for:
  * Invalid file objects
  * IO and OS exceptions during file reading
  * Unicode decoding errors
  * Non-standard file formats

## Technical Details

### File Processing
* Support for multiple input types including:
  * Raw bytes
  * Django ContentFile objects
  * UploadedFile instances
  * Custom file-like objects with read() methods

### Integration Points
* Seamless connection with `ExternalGraderDetail` for complete submission workflow
* Compatible interface for existing xqueue consumers

## Testing Strategy
Extensive test coverage includes:
* Various file input types
* Complete processing workflow verification
* Edge case handling for invalid files
* Error handling for exceptional conditions
* File retrieval in grader-compatible format

## Open edX Compliance
This implementation adheres to Open edX standards through:
* Modular design with clear separation of concerns
* Comprehensive test coverage of edge cases
* Well-documented interfaces
* Consistent error handling patterns

## Performance Considerations
* Efficient file storage with minimal overhead
* Optimized database access patterns
* Careful handling of file objects to prevent memory issues

**BREAKING CHANGES**: None. Designed for full compatibility with existing systems.


Diff:
diff --git a/.gitignore b/.gitignore
index e5e8139..17a7739 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,3 +63,4 @@ test.txt.tmp
 
 # VSCode
 .vscode
+test_queue/*
diff --git a/docs/source/decisions/0002-add-submission-file-model.rst b/docs/source/decisions/0002-add-submission-file-model.rst
new file mode 100644
index 0000000..bb6cade
--- /dev/null
+++ b/docs/source/decisions/0002-add-submission-file-model.rst
@@ -0,0 +1,77 @@
+2. File Handling Implementation for Submission System
+#####################################################
+
+Status
+******
+
+**Provisional** *2025-02-14*
+
+Implemented by https://github.com/openedx/edx-submissions/pull/286
+
+Context
+*******
+
+As part of the XQueue migration effort detailed in ADR 0001, we need to implement a file handling system within edx-submissions. Currently, XQueue manages file submissions through a tightly coupled approach.
+
+### Current Limitations
+
+1. **Inadequate File Management**: XQueue's approach relies on JSON strings in character fields, with size constraints and manual URL manipulation for file handling.
+
+2. **Process Inefficiencies**: The current system uses synchronous HTTP for file retrieval, lacks proper validation, and tightly couples submission processing with file handling.
+
+3. **Integration Challenges**: External graders depend on specific URL formats with HTTP-based retrieval, embedding file information directly in submission payloads.
+
+Decision
+********
+
+We will implement a dedicated file management system for the assessment submission process, focusing on workflow and educational needs:
+
+1. **Centralized Storage**: Create a unified repository for student-submitted files, ensuring they are properly associated with their assessments and accessible throughout the grading process.
+
+2. **Streamlined Workflow**: Design a clear process where files are automatically processed during submission creation, securely stored, and efficiently delivered to grading systems.
+
+3. **Consistent Experience**: Maintain compatibility with existing systems to ensure a smooth transition, allowing instructors and external graders to access files without changes to their established workflows.
+
+Consequences
+************
+
+Positive:
+---------
+
+1. **Architecture**: Clean separation of concerns, improved maintainability, better error handling, optimized database access
+
+2. **Integration**: Seamless xqueue-watcher compatibility, support for existing workflows, minimal client code changes
+
+3. **Operations**: Robust file validation, improved tracking, better error visibility, simplified lifecycle management
+
+Negative:
+---------
+
+1. **Technical**: Additional database structures
+
+2. **Migration**: Temporary system complexity, additional testing needs
+
+3. **Performance**: File processing overhead
+
+References
+**********
+
+Current System Documentation:
+   * XQueue Repository: https://github.com/openedx/xqueue
+   * XQueue Watcher Repository: https://github.com/openedx/xqueue-watcher
+
+Related Repositories:
+   * edx-submissions: https://github.com/openedx/edx-submissions
+   * edx-platform: https://github.com/openedx/edx-platform
+   * XQueue Repository: https://github.com/openedx/xqueue
+
+Related Documentation:
+   * ADR 0001: Creation of ExternalGraderDetail Model for XQueue Migration
+
+Future Event Integration:
+   * Open edX Events Framework: https://github.com/openedx/openedx-events
+   * Event Bus Documentation: https://openedx.atlassian.net/wiki/spaces/AC/pages/124125264/Event+Bus
+
+Related Architecture Documents:
+   * Open edX Architecture Guidelines: https://openedx.atlassian.net/wiki/spaces/AC/pages/124125264/Architecture+Guidelines
+
diff --git a/settings.py b/settings.py
index d718f05..07ec8b7 100644
--- a/settings.py
+++ b/settings.py
@@ -82,3 +82,11 @@
 )
 
 TEST_APPS = ('submissions',)
+
+EDX_SUBMISSIONS = {
+    'MEDIA': {
+        'BACKEND': 'django.core.files.storage.InMemoryStorage',
+        'OPTIONS': {
+        }
+    }
+}
diff --git a/submissions/__init__.py b/submissions/__init__.py
index 9f433d6..9ac2b64 100644
--- a/submissions/__init__.py
+++ b/submissions/__init__.py
@@ -1,2 +1,2 @@
 """ API for creating submissions and scores. """
-__version__ = '3.10.1'
+__version__ = '3.11.0'
diff --git a/submissions/api.py b/submissions/api.py
index d5eb4cd..40f4698 100644
--- a/submissions/api.py
+++ b/submissions/api.py
@@ -15,7 +15,6 @@
 # SubmissionError imported so that code importing this api has access
 from submissions.errors import (  # pylint: disable=unused-import
     ExternalGraderQueueEmptyError,
-    SubmissionError,
     SubmissionInternalError,
     SubmissionNotFoundError,
     SubmissionRequestError
@@ -28,6 +27,7 @@
     ScoreSummary,
     StudentItem,
     Submission,
+    SubmissionFile,
     score_reset,
     score_set
 )
@@ -49,7 +49,6 @@
 TOP_SUBMISSIONS_CACHE_TIMEOUT = 300
 
 
-# pylint: disable=unused-argument
 def create_external_grader_detail(student_item_dict,
                                   answer,
                                   queue_name: str,
@@ -58,7 +57,7 @@ def create_external_grader_detail(student_item_dict,
                                   **external_grader_additional_data
                                   ):
     """
-    Creates a submission and an associated ExternalGraderDetail record.
+    Creates a submission and an associated ExternalGraderDetail record with optional file attachments.
 
     Args:
         student_item_dict (dict): The student_item this submission is associated with.
@@ -73,13 +72,28 @@ def create_external_grader_detail(student_item_dict,
         points_possible (int, optional): The maximum possible points for this submission. Defaults to 1.
 
         external_grader_additional_data: Additional keyword arguments that may be used for the external grader.
+            If a 'files' dictionary is included, files will be processed and stored as SubmissionFile objects.
+            The 'files' dictionary should map filenames to file objects (typically FileObjForWebobFiles).
 
     Returns:
         ExternalGraderDetail: The created external grader detail record that references the submission.
+            Any processed files are accessible through the `files` related_name on this object.
 
     Raises:
         ExternalGraderQueueEmptyError: If queue_name is empty.
         SubmissionInternalError: If there's an error creating the submission or external grader detail.
+
+    Example:
+        >>> files = {'assignment.py': file_obj}
+        >>> external_grader = create_external_grader_detail(
+        ...     student_item_dict,
+        ...     answer,
+        ...     'python_grader',
+        ...     files=files
+        ... )
+        >>> # Access files through external_grader.files.all()
+        >>> # Get URLs for external systems:
+        >>> urls = {f.original_filename: f.xqueue_url for f in external_grader.files.all()}
     """
 
     submission = create_submission(student_item_dict, answer)
@@ -93,9 +107,20 @@ def create_external_grader_detail(student_item_dict,
             submission_uuid=submission_uuid,
             queue_name=queue_name,
             grader_file_name=grader_file_name,
-            points_possible=points_possible,
+            points_possible=points_possible)
+
+        files_dict = external_grader_additional_data.get('files')
+        if files_dict:
+
+            files_urls = {}
+            for filename, file_obj in files_dict.items():
+                submission_file = SubmissionFile.objects.create(
+                    external_grader=instance,
+                    file=file_obj.file,
+                    original_filename=filename
+                )
+                files_urls[filename] = submission_file.xqueue_url
 
-        )
         return instance
 
     except DatabaseError as error:
@@ -106,6 +131,16 @@ def create_external_grader_detail(student_item_dict,
         raise SubmissionInternalError(error_message) from error
 
 
+def get_files_for_grader(external_grader):
+    """
+    Returns files in format expected by xqueue-watcher when is calling by get_submission endpoint.
+    """
+    return {
+        file.original_filename: file.file.url
+        for file in external_grader.files.all()
+    }
+
+
 def create_submission(
     student_item_dict,
     answer,
diff --git a/submissions/migrations/0005_submissionfile.py b/submissions/migrations/0005_submissionfile.py
new file mode 100644
index 0000000..ddb5e70
--- /dev/null
+++ b/submissions/migrations/0005_submissionfile.py
@@ -0,0 +1,33 @@
+# Generated by Django 4.2.19 on 2025-05-15 14:14
+
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import submissions.models
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('submissions', '0004_externalgraderdetail'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='SubmissionFile',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('uuid', models.UUIDField(default=uuid.uuid4, editable=False)),
+                ('file', models.FileField(max_length=512, storage=submissions.models.get_storage,
+                                          upload_to=submissions.models.submission_file_path)),
+                ('original_filename', models.CharField(max_length=255)),
+                ('created_at', models.DateTimeField(default=django.utils.timezone.now)),
+                ('external_grader', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,
+                                                      related_name='files', to='submissions.externalgraderdetail')),
+            ],
+            options={
+                'indexes': [models.Index(fields=['external_grader', 'uuid'], name='submissions_externa_ff8089_idx')],
+            },
+        ),
+    ]
diff --git a/submissions/models.py b/submissions/models.py
index 25bbfa0..78d3cdd 100644
--- a/submissions/models.py
+++ b/submissions/models.py
@@ -9,15 +9,19 @@
     ./manage.py makemigrations submissions
 """
 
+import functools
 import logging
+import os
 from datetime import timedelta
 from uuid import uuid4
 
 from django.conf import settings
 from django.contrib import auth
+from django.core.files.storage import default_storage
 from django.db import DatabaseError, models, transaction
 from django.db.models.signals import post_save, pre_save
 from django.dispatch import Signal, receiver
+from django.utils.module_loading import import_string
 from django.utils.timezone import now
 from jsonfield import JSONField
 from model_utils.models import TimeStampedModel
@@ -693,3 +697,113 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uuid}"
+    )
+
+
+@functools.cache
+def _get_storage_cached():
+    """
+    Cached implementation to get the configured storage backend.
+
+    This private function loads storage configuration from settings and
+    dynamically instantiates the specified storage backend. It expects
+    EDX_SUBMISSIONS['MEDIA'] to be a dict with 'BACKEND' (string path to
+    storage class) and optional 'OPTIONS' (dict of parameters).
+
+    This function is for internal use only and is cached to improve performance.
+    """
+    edx_submissions_config = getattr(settings, 'EDX_SUBMISSIONS', {})
+    storage_config = edx_submissions_config.get('MEDIA')
+
+    if storage_config:
+        storage_cls = import_string(storage_config['BACKEND'])
+        options = storage_config.get('OPTIONS', {})
+        return storage_cls(**options)
+
+    return default_storage
+
+
+def get_storage():
+    """
+    Get the configured storage backend or fallback to default storage.
+
+    This function checks for a storage configuration in the Django settings.
+    It first looks for 'MEDIA' in the 'EDX_SUBMISSIONS' configuration dictionary.
+
+    The function uses an internal cached implementation while remaining
+    serializable for Django migrations, avoiding "Cannot serialize" errors.
+
+    Returns:
+        Storage instance: Returns the configured storage if found in EDX_SUBMISSIONS['MEDIA'],
+                         otherwise returns Django's default_storage.
+
+    Example:
+        # In settings.py
+        EDX_SUBMISSIONS = {
+            'MEDIA': {
+                'BACKEND': 'storages.backends.s3boto3.S3Boto3Storage',
+                'OPTIONS': {
+                    'bucket_name': 'my-bucket'
+                }
+            }
+        }
+
+        # Then get_storage() will return an S3Boto3Storage instance
+    """
+    return _get_storage_cached()  # For performance while keeping this function serializable for migrations
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uuid = models.UUIDField(default=uuid4, editable=False)  # legacy S3 key
+    external_grader = models.ForeignKey(
+        'submissions.ExternalGraderDetail',
+        on_delete=models.SET_NULL,
+        related_name='files',
+        null=True,
+    )
+    file = models.FileField(
+        upload_to=submission_file_path,
+        max_length=512,
+        storage=get_storage
+    )
+    original_filename = models.CharField(max_length=255)  # This is necessary to send file name to xqueue-watcher
+    created_at = models.DateTimeField(default=now)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['external_grader', 'uuid']),
+        ]
+
+    @property
+    def xqueue_url(self):
+        """
+        Returns a URL in the XQueue-compatible format: /queue_name/uuid
+
+        This format is used for file references in both the legacy XQueue system
+        and the new integrated standard. It maintains backward compatibility
+        while supporting the migration from the external XQueue API to the
+        integrated Open edX solution.
+
+        The URL follows the pattern: /{queue_name}/{submission_uuid}
+        where:
+        - queue_name: identifies the external grader queue
+        - uuid: uniquely identifies this submission (legacy S3 key)
+
+        Returns:
+            str: Formatted URL path following XQueue conventions
+        """
+        return f"/{self.external_grader.queue_name}/{self.uuid}"
diff --git a/submissions/tests/test_api.py b/submissions/tests/test_api.py
index a64b659..58a2b44 100644
--- a/submissions/tests/test_api.py
+++ b/submissions/tests/test_api.py
@@ -10,6 +10,8 @@
 import pytz
 # Django imports
 from django.core.cache import cache
+from django.core.files.base import ContentFile
+from django.core.files.uploadedfile import SimpleUploadedFile
 from django.db import DatabaseError, IntegrityError, connection, transaction
 from django.test import TestCase
 from django.utils.timezone import now
@@ -17,8 +19,17 @@
 
 # Local imports
 from submissions import api
+from submissions.api import create_external_grader_detail, get_files_for_grader
 from submissions.errors import ExternalGraderQueueEmptyError, SubmissionInternalError
-from submissions.models import ExternalGraderDetail, ScoreAnnotation, ScoreSummary, StudentItem, Submission, score_set
+from submissions.models import (
+    ExternalGraderDetail,
+    ScoreAnnotation,
+    ScoreSummary,
+    StudentItem,
+    Submission,
+    SubmissionFile,
+    score_set
+)
 from submissions.serializers import StudentItemSerializer
 
 STUDENT_ITEM = {
@@ -923,3 +934,188 @@ def test_create_multiple_submission_external_grader_details(self):
         self.assertEqual(submission2.answer, ANSWER_TWO)
 
         self.assertNotEqual(external_grader_detail1.submission.uuid, external_grader_detail2.submission.uuid)
+
+    def test_create_external_grader_detail_with_files(self):
+        """Test creating a queue record with file handling."""
+
+        class MockFileObj:
+            def __init__(self, file_content):
+                self.file = SimpleUploadedFile(
+                    "test.txt",
+                    file_content,
+                    content_type="text/plain"
+                )
+
+        test_file = MockFileObj(b"test content")
+
+        event_data = {
+            'queue_name': 'test_queue',
+            'files': {'test.txt': test_file}
+        }
+
+        external_grader_detail = api.create_external_grader_detail(STUDENT_ITEM, ANSWER_ONE, **event_data)
+
+        self.assertEqual(external_grader_detail.queue_name, 'test_queue')
+        self.assertEqual(external_grader_detail.files.count(), 1)
+        submission_file = external_grader_detail.files.first()
+        self.assertEqual(submission_file.original_filename, 'test.txt')
+
+    def test_create_external_grader_detail_with_multiple_files(self):
+        """Test creating a queue record with multiple files."""
+
+        class MockFileObj:
+            def __init__(self, filename, content):
+                self.file = SimpleUploadedFile(
+                    filename,
+                    content,
+                    content_type="text/plain"
+                )
+
+        test_files = {
+            'test1.txt': MockFileObj("test1.txt", b"test content 1"),
+            'test2.txt': MockFileObj("test2.txt", b"test content 2")
+        }
+
+        external_grader_detail = {
+            'queue_name': 'test_queue',
+            'files': test_files
+        }
+        external_grader_detail = api.create_external_grader_detail(STUDENT_ITEM, ANSWER_ONE, **external_grader_detail)
+
+        self.assertEqual(external_grader_detail.files.count(), 2)
+        filenames = set(external_grader_detail.files.values_list('original_filename', flat=True))
+        self.assertEqual(filenames, {'test1.txt', 'test2.txt'})
+
+    def test_create_external_grader_detail_without_files(self):
+        """Test creating a queue record without any files still works."""
+        external_grader_instance = api.create_external_grader_detail(STUDENT_ITEM, ANSWER_ONE, queue_name="test_queue")
+        self.assertEqual(external_grader_instance.queue_name, 'test_queue')
+        self.assertEqual(external_grader_instance.files.count(), 0)
+
+
+class TestExternalGraderFileProcessing(TestCase):
+    """
+    Test the file processing functionality for external graders.
+    """
+
+    def setUp(self):
+        """Set up common test data."""
+        self.student_item_dict = {
+            "student_id": "test_student",
+            "course_id": "test_course",
+            "item_id": "test_item",
+            "item_type": "test_type"
+        }
+        self.answer = "test answer"
+        self.queue_name = "test_queue"
+
+    def test_create_external_grader_with_files(self):
+        """Test processing files within create_external_grader_detail."""
+
+        class FileObjWithFileAttr:
+            def __init__(self):
+                self.file = SimpleUploadedFile("test.txt", b"test content")
+
+        file_obj = FileObjWithFileAttr()
+        files_dict = {"test.txt": file_obj}
+
+        # Create external grader with files
+        instance = create_external_grader_detail(
+            student_item_dict=self.student_item_dict,
+            answer=self.answer,
+            queue_name=self.queue_name,
+            files=files_dict
+        )
+
+        # Verify SubmissionFile was created correctly
+        submission_file = SubmissionFile.objects.get(
+            external_grader=instance,
+            original_filename="test.txt"
+        )
+        self.assertTrue(submission_file.xqueue_url.startswith(f"/{self.queue_name}/"))
+
+    def test_get_files_for_grader(self):
+        """Test the standalone get_files_for_grader function."""
+
+        class FileObjWithFileAttr:
+            def __init__(self):
+                self.file = SimpleUploadedFile("test.txt", b"test content")
+
+        # Create external grader with a file
+        instance = create_external_grader_detail(
+            student_item_dict=self.student_item_dict,
+            answer=self.answer,
+            queue_name=self.queue_name,
+            files={"test.txt": FileObjWithFileAttr()}
+        )
+
+        # Test get_files_for_grader function
+        grader_files = get_files_for_grader(instance)
+        self.assertEqual(len(grader_files), 1)
+        self.assertIn("test.txt", grader_files)
+        self.assertTrue(isinstance(grader_files["test.txt"], str))
+
+    def test_multiple_files_processing(self):
+        """Test processing multiple files through create_external_grader_detail."""
+
+        class FileObjWithFileAttr1:
+            def __init__(self):
+                self.file = SimpleUploadedFile("test1.txt", b"content1")
+
+        class FileObjWithFileAttr2:
+            def __init__(self):
+                self.file = SimpleUploadedFile("test2.txt", b"content2")
+
+        files_dict = {
+            "test1.txt": FileObjWithFileAttr1(),
+            "test2.txt": FileObjWithFileAttr2()
+        }
+
+        # Create external grader with multiple files
+        instance = create_external_grader_detail(
+            student_item_dict=self.student_item_dict,
+            answer=self.answer,
+            queue_name=self.queue_name,
+            files=files_dict
+        )
+
+        # Verify both files were created
+        submission_files = SubmissionFile.objects.filter(external_grader=instance)
+        self.assertEqual(submission_files.count(), 2)
+
+        # Test get_files_for_grader
+        grader_files = get_files_for_grader(instance)
+        self.assertEqual(len(grader_files), 2)
+        self.assertIn("test1.txt", grader_files)
+        self.assertIn("test2.txt", grader_files)
+
+    def test_complete_file_processing_flow(self):
+        """
+        Test the complete flow of processing a file through to SubmissionFile creation.
+        """
+
+        class FileObjWithFileAttr:
+            def __init__(self):
+                self.file = ContentFile(b"test binary content", name="complete_test.txt")
+
+        files_dict = {"complete_test.txt": FileObjWithFileAttr()}
+
+        # Create external grader with file
+        instance = create_external_grader_detail(
+            student_item_dict=self.student_item_dict,
+            answer=self.answer,
+            queue_name=self.queue_name,
+            files=files_dict
+        )
+
+        # Verify SubmissionFile was created correctly
+        submission_file = SubmissionFile.objects.get(
+            external_grader=instance,
+            original_filename="complete_test.txt"
+        )
+
+        # Verify URL format
+        self.assertTrue(submission_file.xqueue_url.startswith(f"/{self.queue_name}/"))
+
+        # Verify file content is accessible
+        self.assertEqual(submission_file.file.read(), b"test binary content")
diff --git a/submissions/tests/test_models.py b/submissions/tests/test_models.py
index ddce5a0..30ff51c 100644
--- a/submissions/tests/test_models.py
+++ b/submissions/tests/test_models.py
@@ -4,9 +4,12 @@
 import time
 from datetime import datetime, timedelta
 from unittest import mock
+from unittest.mock import patch
 
 import pytest
 from django.contrib import auth
+from django.core.files.storage import default_storage
+from django.core.files.uploadedfile import SimpleUploadedFile
 from django.test import TestCase
 from django.utils.timezone import now
 from pytz import UTC
@@ -20,7 +23,10 @@
     ScoreSummary,
     StudentItem,
     Submission,
-    TeamSubmission
+    SubmissionFile,
+    TeamSubmission,
+    _get_storage_cached,
+    get_storage
 )
 
 User = auth.get_user_model()
@@ -687,3 +693,133 @@ def test_submission_mutability(self):
         self.assertEqual(re_fetched.answer, new_answer)
         self.assertEqual(re_fetched.attempt_number, new_attempt)
         self.assertEqual(re_fetched.submitted_at, new_time)
+
+
+class TestSubmissionFile(TestCase):
+    """
+    Test the SubmissionFile model functionality.
+    """
+
+    def setUp(self):
+        """Set up common test data."""
+
+        self.student_item = StudentItem.objects.create(
+            student_id="test_student",
+            course_id="test_course",
+            item_id="test_item"
+        )
+        self.submission = Submission.objects.create(
+            student_item=self.student_item,
+            answer="test answer",
+            attempt_number=1
+        )
+        self.external_grader_detail = ExternalGraderDetail.objects.create(
+            submission=self.submission,
+            queue_name="test_queue"
+        )
+
+        self.test_file = SimpleUploadedFile(
+            "test_file.txt",
+            b"test content",
+            content_type="text/plain"
+        )
+
+        self.submission_file = SubmissionFile.objects.create(
+            external_grader=self.external_grader_detail,
+            file=self.test_file,
+            original_filename="test_file.txt"
+        )
+
+    def test_create_submission_file(self):
+        """Test basic submission file creation."""
+        self.assertIsNotNone(self.submission_file.uuid)
+        self.assertEqual(self.submission_file.original_filename, "test_file.txt")
+        self.assertEqual(self.submission_file.external_grader, self.external_grader_detail)
+        self.assertIsNotNone(self.submission_file.created_at)
+
+    def test_xqueue_url_format(self):
+        """Test that xqueue_url property returns the correct format."""
+        expected_url = f"/{self.external_grader_detail.queue_name}/{self.submission_file.uuid}"
+        self.assertEqual(self.submission_file.xqueue_url, expected_url)
+
+    def test_multiple_files_per_submission(self):
+        """Test that multiple files can be associated with a single submission."""
+        second_file = SimpleUploadedFile(
+            "second_file.txt",
+            b"more content",
+            content_type="text/plain"
+        )
+
+        second_submission_file = SubmissionFile.objects.create(
+            external_grader=self.external_grader_detail,
+            file=second_file,
+            original_filename="second_file.txt"
+        )
+
+        files = self.external_grader_detail.files.all()
+        self.assertEqual(files.count(), 2)
+        self.assertIn(self.submission_file, files)
+        self.assertIn(second_submission_file, files)
+
+    def test_file_path_generation(self):
+        """Test that files are stored with the correct path structure."""
+        file_path = self.submission_file.file.name
+        expected_parts = [
+            self.external_grader_detail.queue_name,
+            str(self.submission_file.uuid)
+        ]
+
+        for part in expected_parts:
+            self.assertIn(part, file_path)
+
+    def test_unique_uids(self):
+        """Test that each file gets a unique UUID."""
+        second_file = SimpleUploadedFile(
+            "another_file.txt",
+            b"content",
+            content_type="text/plain"
+        )
+
+        second_submission_file = SubmissionFile.objects.create(
+            external_grader=self.external_grader_detail,
+            file=second_file,
+            original_filename="another_file.txt"
+        )
+
+        self.assertNotEqual(
+            self.submission_file.uuid,
+            second_submission_file.uuid
+        )
+
+    def test_related_name_access(self):
+        """Test accessing files through the submission queue record."""
+        files = self.external_grader_detail.files.all()
+        self.assertEqual(files.count(), 1)
+        self.assertEqual(files.first(), self.submission_file)
+
+    def test_ordering(self):
+        """Test that files are ordered by created_at."""
+        past_time = now() - timedelta(hours=1)
+
+        older_file = SimpleUploadedFile(
+            "older_file.txt",
+            b"old content",
+            content_type="text/plain"
+        )
+        older_submission_file = SubmissionFile.objects.create(
+            external_grader=self.external_grader_detail,
+            file=older_file,
+            original_filename="older_file.txt",
+            created_at=past_time
+        )
+
+        files = self.external_grader_detail.files.all()
+        self.assertEqual(files[0], self.submission_file)
+        self.assertEqual(files[1], older_submission_file)
+
+    def test_get_storage_default_with_mock(self):
+        """Test that get_storage returns default_storage when no custom configuration exists."""
+        _get_storage_cached.cache_clear()
+
+        with patch('submissions.models.getattr', return_value={}):
+            self.assertEqual(get_storage(), default_storage)


Reviews:
user: login: angonz
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-03T19:59:56Z
user: login: angonz
body: Ok on my side. Please open for Axim's review.
state: APPROVED
author_association: NONE
submitted_at: 2025-04-07T14:13:40Z
user: login: ormsbee
body: Some comments and questions. I just want to make sure I'm understanding the intentions of this code properly. Thank you.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T00:15:44Z
user: login: leoaulasneo98
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T13:37:13Z
user: login: leoaulasneo98
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T13:46:37Z
user: login: leoaulasneo98
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T13:54:47Z
user: login: leoaulasneo98
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T14:05:16Z
user: login: leoaulasneo98
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T14:30:11Z
user: login: leoaulasneo98
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T16:22:14Z
user: login: ormsbee
body: High level security question: Am I understanding correctly that this is storing things in the default Django storages backend, and if so, does that mean it will be publicly readable (though obscured because of the randomly generated UUIDs)? 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T18:06:30Z
user: login: ormsbee
body: Please also include a [version bump](https://github.com/openedx/edx-submissions/blob/master/submissions/__init__.py) as part of this PR. Thank you.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-03T20:12:06Z
user: login: ormsbee
body: Thank you for addressing my concerns about bytes/file input handling.

Two blockers on this at the moment:

1. Storage configuration, which I've tried to give more explicit instructions about.
2. It looks like all the requirements files have been changed to have absolute paths from your local environment.

Would you like to schedule some time on Friday to go through any of this together?

Thank you.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T06:13:11Z
user: login: leoaulasneo98
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T15:09:17Z
user: login: leoaulasneo98
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T15:10:14Z
user: login: ormsbee
body: In addition to the code comments below, please also do the following:

1. Update [the version](https://github.com/openedx/edx-submissions/blob/master/submissions/__init__.py#L2) to 3.11.0
2. When you push your changes, please don't squash your commits. We can squash at the end once everything is approved, but it makes it easier/faster to review I can just "view changes since last review", and that functionality breaks if you squash.

Thank you.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T21:28:36Z
user: login: leoaulasneo98
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T01:13:23Z
user: login: leoaulasneo98
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T01:16:49Z
user: login: leoaulasneo98
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T01:20:04Z
user: login: ormsbee
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T17:20:13Z
user: login: timmc-edx
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-23T15:36:34Z
user: login: ormsbee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T19:37:15Z
user: login: leoaulasneo98
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T12:26:36Z
user: login: timmc-edx
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-27T11:08:46Z
user: login: ormsbee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T12:12:18Z

Review comments:
path: docs/source/decisions/0002-add-submission-file-model.rst
user: login: angonz
body: Update author of cange
Add link to first ADR
Summarize and simplify.
created_at: 2025-04-03T19:44:40Z
author_association: NONE
diff_hunk: @@ -693,3 +696,102 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.submission_queue.queue_name,
+        f"{instance.uid}"
+    )
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uid = models.UUIDField(default=uuid4, editable=False)
+    # legacy S3 key
+    submission_queue = models.ForeignKey(
+        'submissions.ExternalGraderDetail',
+        on_delete=models.SET_NULL,
+        related_name='files',
+        null=True,
+    )
+    file = models.FileField(
+        upload_to=submission_file_path,
+        max_length=512
+    )
+    original_filename = models.CharField(max_length=255)  # This is necessary to send file name to xqueue-watcher
+    created_at = models.DateTimeField(default=now)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['submission_queue', 'uid']),
+        ]
+
+    @property
+    def xqueue_url(self):
+        """
+        Returns URL in xqueue format: /queue_name/uid
+        """
+        return f"/{self.submission_queue.queue_name}/{self.uid}"
+
+
+class SubmissionFileManager:
+    """
+    Manages file operations for submissions
+    """
+
+    def __init__(self, submission_queue):
+        self.submission_queue = submission_queue
+
+    def process_files(self, files_dict):
+        """
+        Process uploaded files.
+        Returns URLs in xqueue compatible format.
+        """
+        files_urls = {}
+        for filename, file_obj in files_dict.items():
+            if not (isinstance(file_obj, (bytes, ContentFile, SimpleUploadedFile)) or hasattr(file_obj, 'read')):
path: submissions/models.py
user: login: angonz
body: Why check for `hasattr(file_obj, 'read')`?
created_at: 2025-04-03T19:51:53Z
author_association: NONE
diff_hunk: @@ -693,3 +696,102 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.submission_queue.queue_name,
+        f"{instance.uid}"
+    )
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uid = models.UUIDField(default=uuid4, editable=False)
+    # legacy S3 key
+    submission_queue = models.ForeignKey(
+        'submissions.ExternalGraderDetail',
+        on_delete=models.SET_NULL,
+        related_name='files',
+        null=True,
+    )
+    file = models.FileField(
+        upload_to=submission_file_path,
+        max_length=512
+    )
+    original_filename = models.CharField(max_length=255)  # This is necessary to send file name to xqueue-watcher
+    created_at = models.DateTimeField(default=now)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['submission_queue', 'uid']),
+        ]
+
+    @property
+    def xqueue_url(self):
+        """
+        Returns URL in xqueue format: /queue_name/uid
+        """
+        return f"/{self.submission_queue.queue_name}/{self.uid}"
+
+
+class SubmissionFileManager:
+    """
+    Manages file operations for submissions
+    """
+
+    def __init__(self, submission_queue):
+        self.submission_queue = submission_queue
+
+    def process_files(self, files_dict):
+        """
+        Process uploaded files.
+        Returns URLs in xqueue compatible format.
+        """
+        files_urls = {}
+        for filename, file_obj in files_dict.items():
+            if not (isinstance(file_obj, (bytes, ContentFile, SimpleUploadedFile)) or hasattr(file_obj, 'read')):
+                logger.warning(f"Invalid file object type for {filename}")
+                continue
+
+            if hasattr(file_obj, 'read'):
path: submissions/models.py
user: login: angonz
body: Please explain this workflow

created_at: 2025-04-03T19:56:54Z
author_association: NONE
diff_hunk: @@ -693,3 +702,136 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uid}"
+    )
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uid = models.UUIDField(default=uuid4, editable=False)  # legacy S3 key
path: submissions/models.py
user: login: ormsbee
body: ```suggestion
    uuid = models.UUIDField(default=uuid4, editable=False)  # legacy S3 key
```
A number of other platform models use "uuid" as the conventional name for this field.
created_at: 2025-04-16T20:02:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +702,136 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uid}"
+    )
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uid = models.UUIDField(default=uuid4, editable=False)  # legacy S3 key
+    external_grader = models.ForeignKey(
+        'submissions.ExternalGraderDetail',
+        on_delete=models.SET_NULL,
+        related_name='files',
+        null=True,
+    )
+    file = models.FileField(
+        upload_to=submission_file_path,
+        max_length=512
+    )
+    original_filename = models.CharField(max_length=255)  # This is necessary to send file name to xqueue-watcher
+    created_at = models.DateTimeField(default=now)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['external_grader', 'uid']),
+        ]
+
+    @property
+    def xqueue_url(self):
+        """
+        Returns URL in xqueue format: /queue_name/uid
+        """
+        return f"/{self.external_grader.queue_name}/{self.uid}"
+
+
+class SubmissionFileManager:
path: submissions/models.py
user: login: ormsbee
body: It's confusing to have something called a "xxxxManager" class in a `models.py` file, when it isn't a Django ORM [Manager](https://docs.djangoproject.com/en/4.2/topics/db/managers/). Can you make these into api.py functions instead?
created_at: 2025-04-16T22:17:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +702,136 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uid}"
+    )
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uid = models.UUIDField(default=uuid4, editable=False)  # legacy S3 key
+    external_grader = models.ForeignKey(
+        'submissions.ExternalGraderDetail',
+        on_delete=models.SET_NULL,
+        related_name='files',
+        null=True,
+    )
+    file = models.FileField(
+        upload_to=submission_file_path,
+        max_length=512
+    )
+    original_filename = models.CharField(max_length=255)  # This is necessary to send file name to xqueue-watcher
+    created_at = models.DateTimeField(default=now)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['external_grader', 'uid']),
+        ]
+
+    @property
+    def xqueue_url(self):
+        """
+        Returns URL in xqueue format: /queue_name/uid
+        """
+        return f"/{self.external_grader.queue_name}/{self.uid}"
+
+
+class SubmissionFileManager:
+    """
+    Manages file operations for submissions
+    """
+
+    def __init__(self, external_grader):
+        self.external_grader = external_grader
+
+    def process_files(self, files_dict):
+        """
+        Process uploaded files from an Open edX environment and store them as SubmissionFile objects.
+
+        This method handles various file object types that might be received from Open edX, including:
+        - Native Open edX FileObjForWebobFiles objects
+        - Bytes objects
+        - ContentFile objects
+        - SimpleUploadedFile objects
+        - Any object with a 'read' method
path: submissions/models.py
user: login: ormsbee
body: Why does this function need to support all these?
created_at: 2025-04-16T23:58:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +702,136 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uid}"
+    )
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uid = models.UUIDField(default=uuid4, editable=False)  # legacy S3 key
+    external_grader = models.ForeignKey(
+        'submissions.ExternalGraderDetail',
+        on_delete=models.SET_NULL,
+        related_name='files',
+        null=True,
+    )
+    file = models.FileField(
+        upload_to=submission_file_path,
+        max_length=512
+    )
+    original_filename = models.CharField(max_length=255)  # This is necessary to send file name to xqueue-watcher
+    created_at = models.DateTimeField(default=now)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['external_grader', 'uid']),
+        ]
+
+    @property
+    def xqueue_url(self):
+        """
+        Returns URL in xqueue format: /queue_name/uid
+        """
+        return f"/{self.external_grader.queue_name}/{self.uid}"
+
+
+class SubmissionFileManager:
+    """
+    Manages file operations for submissions
+    """
+
+    def __init__(self, external_grader):
+        self.external_grader = external_grader
+
+    def process_files(self, files_dict):
+        """
+        Process uploaded files from an Open edX environment and store them as SubmissionFile objects.
+
+        This method handles various file object types that might be received from Open edX, including:
+        - Native Open edX FileObjForWebobFiles objects
+        - Bytes objects
+        - ContentFile objects
+        - SimpleUploadedFile objects
+        - Any object with a 'read' method
+
+        The method performs the following operations:
+        1. Validates each file object type
+        2. Reads content from file-like objects
+        3. Converts byte content to ContentFile objects
+        4. Creates SubmissionFile records in the database
+        5. Returns URLs in xqueue-compatible format
+
+        Args:
+            files_dict (dict): Dictionary mapping filenames to file objects.
+                              Format: {filename: file_object, ...}
+
+        Returns:
+            dict: Dictionary mapping original filenames to xqueue URLs.
+                  Format: {filename: "/queue_name/uuid", ...}
+
+        Raises:
+            InvalidFileTypeError: If a file object has an unsupported type.
+            FileProcessingError: If there's an error reading from a file object,
+                                including I/O errors or Unicode decoding errors.
+
+        Example:
+            >>> file_manager = SubmissionFileManager(external_grader)
+            >>> files = {'assignment.py': file_obj}
+            >>> urls = file_manager.process_files(files)
+            >>> print(urls)
+            {'assignment.py': '/my_queue/550e8400-e29b-41d4-a716-446655440000'}
+        """
+        files_urls = {}
+        for filename, file_obj in files_dict.items():
+            # Validate file object type
+            if not (isinstance(file_obj, (bytes, ContentFile, SimpleUploadedFile)) or hasattr(file_obj, 'read')):
+                logger.error(f"Invalid file object type for {filename}")
+                raise InvalidFileTypeError(f"Invalid file object type for {filename}")
path: submissions/models.py
user: login: ormsbee
body: Error should say what the type actually was. I also don't think it's really necessary to create a new error for this, since this is exactly what `TypeError` is for.
created_at: 2025-04-17T00:01:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +702,136 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uid}"
+    )
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uid = models.UUIDField(default=uuid4, editable=False)  # legacy S3 key
+    external_grader = models.ForeignKey(
+        'submissions.ExternalGraderDetail',
+        on_delete=models.SET_NULL,
+        related_name='files',
+        null=True,
+    )
+    file = models.FileField(
+        upload_to=submission_file_path,
+        max_length=512
+    )
+    original_filename = models.CharField(max_length=255)  # This is necessary to send file name to xqueue-watcher
+    created_at = models.DateTimeField(default=now)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['external_grader', 'uid']),
+        ]
+
+    @property
+    def xqueue_url(self):
+        """
+        Returns URL in xqueue format: /queue_name/uid
path: submissions/models.py
user: login: ormsbee
body: What does "xqueue format" mean?
created_at: 2025-04-17T00:02:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +702,136 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uid}"
+    )
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uid = models.UUIDField(default=uuid4, editable=False)  # legacy S3 key
+    external_grader = models.ForeignKey(
+        'submissions.ExternalGraderDetail',
+        on_delete=models.SET_NULL,
+        related_name='files',
+        null=True,
+    )
+    file = models.FileField(
+        upload_to=submission_file_path,
+        max_length=512
+    )
+    original_filename = models.CharField(max_length=255)  # This is necessary to send file name to xqueue-watcher
+    created_at = models.DateTimeField(default=now)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['external_grader', 'uid']),
path: submissions/models.py
user: login: ormsbee
body: Do we ever look up by just uuid?
created_at: 2025-04-17T00:08:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +702,136 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uid}"
+    )
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uid = models.UUIDField(default=uuid4, editable=False)  # legacy S3 key
+    external_grader = models.ForeignKey(
+        'submissions.ExternalGraderDetail',
+        on_delete=models.SET_NULL,
+        related_name='files',
+        null=True,
+    )
+    file = models.FileField(
+        upload_to=submission_file_path,
+        max_length=512
+    )
+    original_filename = models.CharField(max_length=255)  # This is necessary to send file name to xqueue-watcher
+    created_at = models.DateTimeField(default=now)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['external_grader', 'uid']),
+        ]
+
+    @property
+    def xqueue_url(self):
+        """
+        Returns URL in xqueue format: /queue_name/uid
+        """
+        return f"/{self.external_grader.queue_name}/{self.uid}"
+
+
+class SubmissionFileManager:
+    """
+    Manages file operations for submissions
+    """
+
+    def __init__(self, external_grader):
+        self.external_grader = external_grader
+
+    def process_files(self, files_dict):
+        """
+        Process uploaded files from an Open edX environment and store them as SubmissionFile objects.
+
+        This method handles various file object types that might be received from Open edX, including:
+        - Native Open edX FileObjForWebobFiles objects
+        - Bytes objects
+        - ContentFile objects
+        - SimpleUploadedFile objects
+        - Any object with a 'read' method
+
+        The method performs the following operations:
+        1. Validates each file object type
+        2. Reads content from file-like objects
+        3. Converts byte content to ContentFile objects
+        4. Creates SubmissionFile records in the database
+        5. Returns URLs in xqueue-compatible format
+
+        Args:
+            files_dict (dict): Dictionary mapping filenames to file objects.
+                              Format: {filename: file_object, ...}
+
+        Returns:
+            dict: Dictionary mapping original filenames to xqueue URLs.
+                  Format: {filename: "/queue_name/uuid", ...}
+
+        Raises:
+            InvalidFileTypeError: If a file object has an unsupported type.
+            FileProcessingError: If there's an error reading from a file object,
+                                including I/O errors or Unicode decoding errors.
+
+        Example:
+            >>> file_manager = SubmissionFileManager(external_grader)
+            >>> files = {'assignment.py': file_obj}
+            >>> urls = file_manager.process_files(files)
+            >>> print(urls)
+            {'assignment.py': '/my_queue/550e8400-e29b-41d4-a716-446655440000'}
path: submissions/models.py
user: login: ormsbee
body: Is this a real example of something we'd expect to see in a capa problem code submission? If not, is it possible to make this example as realistic as possible?
created_at: 2025-04-17T00:13:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +702,136 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uid}"
+    )
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uid = models.UUIDField(default=uuid4, editable=False)  # legacy S3 key
+    external_grader = models.ForeignKey(
+        'submissions.ExternalGraderDetail',
+        on_delete=models.SET_NULL,
+        related_name='files',
+        null=True,
+    )
+    file = models.FileField(
+        upload_to=submission_file_path,
+        max_length=512
+    )
+    original_filename = models.CharField(max_length=255)  # This is necessary to send file name to xqueue-watcher
+    created_at = models.DateTimeField(default=now)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['external_grader', 'uid']),
+        ]
+
+    @property
+    def xqueue_url(self):
+        """
+        Returns URL in xqueue format: /queue_name/uid
+        """
+        return f"/{self.external_grader.queue_name}/{self.uid}"
+
+
+class SubmissionFileManager:
+    """
+    Manages file operations for submissions
+    """
+
+    def __init__(self, external_grader):
+        self.external_grader = external_grader
+
+    def process_files(self, files_dict):
+        """
+        Process uploaded files from an Open edX environment and store them as SubmissionFile objects.
+
+        This method handles various file object types that might be received from Open edX, including:
+        - Native Open edX FileObjForWebobFiles objects
+        - Bytes objects
+        - ContentFile objects
+        - SimpleUploadedFile objects
+        - Any object with a 'read' method
+
+        The method performs the following operations:
+        1. Validates each file object type
+        2. Reads content from file-like objects
+        3. Converts byte content to ContentFile objects
+        4. Creates SubmissionFile records in the database
+        5. Returns URLs in xqueue-compatible format
+
+        Args:
+            files_dict (dict): Dictionary mapping filenames to file objects.
+                              Format: {filename: file_object, ...}
+
+        Returns:
+            dict: Dictionary mapping original filenames to xqueue URLs.
+                  Format: {filename: "/queue_name/uuid", ...}
+
+        Raises:
+            InvalidFileTypeError: If a file object has an unsupported type.
+            FileProcessingError: If there's an error reading from a file object,
+                                including I/O errors or Unicode decoding errors.
+
+        Example:
+            >>> file_manager = SubmissionFileManager(external_grader)
+            >>> files = {'assignment.py': file_obj}
+            >>> urls = file_manager.process_files(files)
+            >>> print(urls)
+            {'assignment.py': '/my_queue/550e8400-e29b-41d4-a716-446655440000'}
path: submissions/models.py
user: login: leoaulasneo98
body:  Yes, it's a real example. I attach an image of the debug.
 <img width="883" alt="image" src="https://github.com/user-attachments/assets/6234f91e-0f82-4fc8-ae06-b88d8b6f7908" />

created_at: 2025-04-21T13:37:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +702,136 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uid}"
+    )
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uid = models.UUIDField(default=uuid4, editable=False)  # legacy S3 key
+    external_grader = models.ForeignKey(
+        'submissions.ExternalGraderDetail',
+        on_delete=models.SET_NULL,
+        related_name='files',
+        null=True,
+    )
+    file = models.FileField(
+        upload_to=submission_file_path,
+        max_length=512
+    )
+    original_filename = models.CharField(max_length=255)  # This is necessary to send file name to xqueue-watcher
+    created_at = models.DateTimeField(default=now)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['external_grader', 'uid']),
+        ]
+
+    @property
+    def xqueue_url(self):
+        """
+        Returns URL in xqueue format: /queue_name/uid
path: submissions/models.py
user: login: leoaulasneo98
body: This is the format in which the xqueue-watcher receives the files. That is, the format in which the legacy XqueueServer delivers the files. I've corrected this to make it clearer.
created_at: 2025-04-21T13:46:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +702,136 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uid}"
+    )
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uid = models.UUIDField(default=uuid4, editable=False)  # legacy S3 key
+    external_grader = models.ForeignKey(
+        'submissions.ExternalGraderDetail',
+        on_delete=models.SET_NULL,
+        related_name='files',
+        null=True,
+    )
+    file = models.FileField(
+        upload_to=submission_file_path,
+        max_length=512
+    )
+    original_filename = models.CharField(max_length=255)  # This is necessary to send file name to xqueue-watcher
+    created_at = models.DateTimeField(default=now)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['external_grader', 'uid']),
+        ]
+
+    @property
+    def xqueue_url(self):
+        """
+        Returns URL in xqueue format: /queue_name/uid
+        """
+        return f"/{self.external_grader.queue_name}/{self.uid}"
+
+
+class SubmissionFileManager:
path: submissions/models.py
user: login: leoaulasneo98
body: Hi Dave,
You're right about the naming confusion. Having a "xxxManager" class in models.py that isn't actually a Django ORM Manager is misleading and could cause confusion.

I've refactored this to follow a clearer naming convention:

Renamed SubmissionFileManager to SubmissionFileProcessor and move to api.py, which better describes its actual purpose - processing file operations for submissions rather than managing model queries.
Updated docstrings to reflect this change.
Kept the functionality encapsulated in a class since it maintains state (the external_grader reference) between operations.

This change preserves the existing functionality while making the code more intuitive. 
created_at: 2025-04-21T13:54:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +702,136 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uid}"
+    )
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uid = models.UUIDField(default=uuid4, editable=False)  # legacy S3 key
+    external_grader = models.ForeignKey(
+        'submissions.ExternalGraderDetail',
+        on_delete=models.SET_NULL,
+        related_name='files',
+        null=True,
+    )
+    file = models.FileField(
+        upload_to=submission_file_path,
+        max_length=512
+    )
+    original_filename = models.CharField(max_length=255)  # This is necessary to send file name to xqueue-watcher
+    created_at = models.DateTimeField(default=now)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['external_grader', 'uid']),
+        ]
+
+    @property
+    def xqueue_url(self):
+        """
+        Returns URL in xqueue format: /queue_name/uid
+        """
+        return f"/{self.external_grader.queue_name}/{self.uid}"
+
+
+class SubmissionFileManager:
+    """
+    Manages file operations for submissions
+    """
+
+    def __init__(self, external_grader):
+        self.external_grader = external_grader
+
+    def process_files(self, files_dict):
+        """
+        Process uploaded files from an Open edX environment and store them as SubmissionFile objects.
+
+        This method handles various file object types that might be received from Open edX, including:
+        - Native Open edX FileObjForWebobFiles objects
+        - Bytes objects
+        - ContentFile objects
+        - SimpleUploadedFile objects
+        - Any object with a 'read' method
+
+        The method performs the following operations:
+        1. Validates each file object type
+        2. Reads content from file-like objects
+        3. Converts byte content to ContentFile objects
+        4. Creates SubmissionFile records in the database
+        5. Returns URLs in xqueue-compatible format
+
+        Args:
+            files_dict (dict): Dictionary mapping filenames to file objects.
+                              Format: {filename: file_object, ...}
+
+        Returns:
+            dict: Dictionary mapping original filenames to xqueue URLs.
+                  Format: {filename: "/queue_name/uuid", ...}
+
+        Raises:
+            InvalidFileTypeError: If a file object has an unsupported type.
+            FileProcessingError: If there's an error reading from a file object,
+                                including I/O errors or Unicode decoding errors.
+
+        Example:
+            >>> file_manager = SubmissionFileManager(external_grader)
+            >>> files = {'assignment.py': file_obj}
+            >>> urls = file_manager.process_files(files)
+            >>> print(urls)
+            {'assignment.py': '/my_queue/550e8400-e29b-41d4-a716-446655440000'}
+        """
+        files_urls = {}
+        for filename, file_obj in files_dict.items():
+            # Validate file object type
+            if not (isinstance(file_obj, (bytes, ContentFile, SimpleUploadedFile)) or hasattr(file_obj, 'read')):
+                logger.error(f"Invalid file object type for {filename}")
+                raise InvalidFileTypeError(f"Invalid file object type for {filename}")
path: submissions/models.py
user: login: leoaulasneo98
body: You're right on both counts. I've updated the code, thanks for the feedback.
created_at: 2025-04-21T14:05:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +702,136 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uid}"
+    )
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uid = models.UUIDField(default=uuid4, editable=False)  # legacy S3 key
+    external_grader = models.ForeignKey(
+        'submissions.ExternalGraderDetail',
+        on_delete=models.SET_NULL,
+        related_name='files',
+        null=True,
+    )
+    file = models.FileField(
+        upload_to=submission_file_path,
+        max_length=512
+    )
+    original_filename = models.CharField(max_length=255)  # This is necessary to send file name to xqueue-watcher
+    created_at = models.DateTimeField(default=now)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['external_grader', 'uid']),
+        ]
+
+    @property
+    def xqueue_url(self):
+        """
+        Returns URL in xqueue format: /queue_name/uid
+        """
+        return f"/{self.external_grader.queue_name}/{self.uid}"
+
+
+class SubmissionFileManager:
+    """
+    Manages file operations for submissions
+    """
+
+    def __init__(self, external_grader):
+        self.external_grader = external_grader
+
+    def process_files(self, files_dict):
+        """
+        Process uploaded files from an Open edX environment and store them as SubmissionFile objects.
+
+        This method handles various file object types that might be received from Open edX, including:
+        - Native Open edX FileObjForWebobFiles objects
+        - Bytes objects
+        - ContentFile objects
+        - SimpleUploadedFile objects
+        - Any object with a 'read' method
path: submissions/models.py
user: login: leoaulasneo98
body: Dave, I understand your point. I initially designed this thinking of a scalable solution for various workflows, but I've reconsidered based on the actual needs of the project.
I've simplified the implementation by using standard TypeError instead of custom exceptions and adjusting the validation to focus on the essentials: bytes or objects with a 'read()' method.
Thanks for your feedback that leads us to more straightforward code.
created_at: 2025-04-21T14:30:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +702,136 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uid}"
+    )
+
+
+class SubmissionFile(models.Model):
+    """
+    Model to handle files associated with submissions
+    """
+    uid = models.UUIDField(default=uuid4, editable=False)  # legacy S3 key
+    external_grader = models.ForeignKey(
+        'submissions.ExternalGraderDetail',
+        on_delete=models.SET_NULL,
+        related_name='files',
+        null=True,
+    )
+    file = models.FileField(
+        upload_to=submission_file_path,
+        max_length=512
+    )
+    original_filename = models.CharField(max_length=255)  # This is necessary to send file name to xqueue-watcher
+    created_at = models.DateTimeField(default=now)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['external_grader', 'uid']),
path: submissions/models.py
user: login: leoaulasneo98
body: we don't currently look up by just uuid. The lookups are always through the external_grader relationship or using both fields. If we need direct uuid lookups in the future, we'll add a separate index for that field.
created_at: 2025-04-21T16:22:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -106,6 +112,101 @@ def create_external_grader_detail(student_item_dict,
         raise SubmissionInternalError(error_message) from error
 
 
+class SubmissionFileProcessor:
+    """
+    Process file operations for submissions
+    """
+
+    def __init__(self, external_grader):
+        self.external_grader = external_grader
+
+    def process_files(self, files_dict):
+        """
+        Process uploaded files from an Open edX environment and store them as SubmissionFile objects.
+
+        This method handles various file object types that might be received from Open edX, including:
+        - Bytes objects
+        - Any object with a 'read' method (including Native Open edX FileObjForWebobFiles objects)
+
+
+        The method performs the following operations:
+        1. Validates each file object typeƒ
path: submissions/api.py
user: login: ormsbee
body: ```suggestion
        1. Validates each file object type
```
created_at: 2025-05-02T20:33:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -106,6 +112,101 @@ def create_external_grader_detail(student_item_dict,
         raise SubmissionInternalError(error_message) from error
 
 
+class SubmissionFileProcessor:
+    """
+    Process file operations for submissions
+    """
+
+    def __init__(self, external_grader):
+        self.external_grader = external_grader
+
+    def process_files(self, files_dict):
+        """
+        Process uploaded files from an Open edX environment and store them as SubmissionFile objects.
+
+        This method handles various file object types that might be received from Open edX, including:
+        - Bytes objects
+        - Any object with a 'read' method (including Native Open edX FileObjForWebobFiles objects)
+
+
+        The method performs the following operations:
+        1. Validates each file object typeƒ
+        2. Reads content from file-like objects
+        3. Converts byte content to ContentFile objects
+        4. Creates SubmissionFile records in the database
+        5. Returns URLs in xqueue-compatible format
+
+        Args:
+            files_dict (dict): Dictionary mapping filenames to file objects.
+                              Format: {filename: file_object, ...}
+
+        Returns:
+            dict: Dictionary mapping original filenames to xqueue URLs.
+                  Format: {filename: "/queue_name/uuid", ...}
+
+        Raises:
+            FileProcessingError: If there's an error reading from a file object,
+                                including I/O errors or Unicode decoding errors.
+
+        Example:
+            >>> external_grader = ExternalGraderDetail.create_from_uuid(
+            submission_uuid=submission_uuid,
+            queue_name=queue_name,
+            grader_file_name=grader_file_name,
+            points_possible=points_possible)
+            >>> file_processor = SubmissionFileProcessor(external_grader)
+            >>> files = {'assignment.py': file_obj}
+            >>> urls = file_processor.process_files(files)
+            >>> print(urls)
+            {'assignment.py': '/my_queue/550e8400-e29b-41d4-a716-446655440000'}
+        """
+        files_urls = {}
+        for filename, file_obj in files_dict.items():
+            # Validate file object type
+            if not (isinstance(file_obj, bytes) or hasattr(file_obj, 'read')):
+                actual_type = type(file_obj).__name__
+                logger.error(f"Invalid file object type for {filename}: got {actual_type}")
+                raise TypeError(
+                    f"Invalid file object type for {filename}: expected bytes or an object with 'read' method, "
+                    f"got {actual_type}")
+
+            # Handle file-like objects by reading their content
+            # This returns a bytes object that will be converted to ContentFile later
+            if hasattr(file_obj, 'read'):
+                try:
+                    file_obj = file_obj.read()  # read() returns a bytes object
+                except (IOError, OSError) as e:
+                    logger.error(f"Error reading file {filename}: {e}")
+                    raise FileProcessingError(f"Error reading file {filename}: {e}") from e
+                except UnicodeDecodeError as e:
path: submissions/api.py
user: login: ormsbee
body: The code above is saying that `read()` returns a bytes object, but the fact that it's capable of throwing a `UnicodeDecodeError` would imply that it can sometimes return a `str`.
created_at: 2025-05-02T20:40:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -106,6 +112,101 @@ def create_external_grader_detail(student_item_dict,
         raise SubmissionInternalError(error_message) from error
 
 
+class SubmissionFileProcessor:
+    """
+    Process file operations for submissions
+    """
+
+    def __init__(self, external_grader):
+        self.external_grader = external_grader
+
+    def process_files(self, files_dict):
+        """
+        Process uploaded files from an Open edX environment and store them as SubmissionFile objects.
+
+        This method handles various file object types that might be received from Open edX, including:
+        - Bytes objects
+        - Any object with a 'read' method (including Native Open edX FileObjForWebobFiles objects)
+
+
+        The method performs the following operations:
+        1. Validates each file object typeƒ
+        2. Reads content from file-like objects
+        3. Converts byte content to ContentFile objects
+        4. Creates SubmissionFile records in the database
+        5. Returns URLs in xqueue-compatible format
+
+        Args:
+            files_dict (dict): Dictionary mapping filenames to file objects.
+                              Format: {filename: file_object, ...}
+
+        Returns:
+            dict: Dictionary mapping original filenames to xqueue URLs.
+                  Format: {filename: "/queue_name/uuid", ...}
+
+        Raises:
+            FileProcessingError: If there's an error reading from a file object,
+                                including I/O errors or Unicode decoding errors.
+
+        Example:
+            >>> external_grader = ExternalGraderDetail.create_from_uuid(
+            submission_uuid=submission_uuid,
+            queue_name=queue_name,
+            grader_file_name=grader_file_name,
+            points_possible=points_possible)
+            >>> file_processor = SubmissionFileProcessor(external_grader)
+            >>> files = {'assignment.py': file_obj}
+            >>> urls = file_processor.process_files(files)
+            >>> print(urls)
+            {'assignment.py': '/my_queue/550e8400-e29b-41d4-a716-446655440000'}
+        """
+        files_urls = {}
+        for filename, file_obj in files_dict.items():
+            # Validate file object type
+            if not (isinstance(file_obj, bytes) or hasattr(file_obj, 'read')):
+                actual_type = type(file_obj).__name__
+                logger.error(f"Invalid file object type for {filename}: got {actual_type}")
+                raise TypeError(
+                    f"Invalid file object type for {filename}: expected bytes or an object with 'read' method, "
+                    f"got {actual_type}")
+
+            # Handle file-like objects by reading their content
+            # This returns a bytes object that will be converted to ContentFile later
+            if hasattr(file_obj, 'read'):
+                try:
+                    file_obj = file_obj.read()  # read() returns a bytes object
+                except (IOError, OSError) as e:
+                    logger.error(f"Error reading file {filename}: {e}")
+                    raise FileProcessingError(f"Error reading file {filename}: {e}") from e
+                except UnicodeDecodeError as e:
+                    logger.error(f"Error decoding file {filename}: {e}")
+                    raise FileProcessingError(f"Error decoding file {filename}: {e}") from e
+
+            # Convert bytes to ContentFile
+            # The read() method from file-like objects returns bytes, which we handle here
+            if isinstance(file_obj, bytes):
path: submissions/api.py
user: login: ormsbee
body: If `file_obj` is a `str` here, then we can pass through this without ever making it into a `ContentFile`.
created_at: 2025-05-03T19:59:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -106,6 +112,101 @@ def create_external_grader_detail(student_item_dict,
         raise SubmissionInternalError(error_message) from error
 
 
+class SubmissionFileProcessor:
+    """
+    Process file operations for submissions
+    """
+
+    def __init__(self, external_grader):
+        self.external_grader = external_grader
+
+    def process_files(self, files_dict):
+        """
+        Process uploaded files from an Open edX environment and store them as SubmissionFile objects.
+
+        This method handles various file object types that might be received from Open edX, including:
+        - Bytes objects
+        - Any object with a 'read' method (including Native Open edX FileObjForWebobFiles objects)
+
+
+        The method performs the following operations:
+        1. Validates each file object typeƒ
+        2. Reads content from file-like objects
+        3. Converts byte content to ContentFile objects
+        4. Creates SubmissionFile records in the database
+        5. Returns URLs in xqueue-compatible format
+
+        Args:
+            files_dict (dict): Dictionary mapping filenames to file objects.
+                              Format: {filename: file_object, ...}
+
+        Returns:
+            dict: Dictionary mapping original filenames to xqueue URLs.
+                  Format: {filename: "/queue_name/uuid", ...}
+
+        Raises:
+            FileProcessingError: If there's an error reading from a file object,
+                                including I/O errors or Unicode decoding errors.
+
+        Example:
+            >>> external_grader = ExternalGraderDetail.create_from_uuid(
+            submission_uuid=submission_uuid,
+            queue_name=queue_name,
+            grader_file_name=grader_file_name,
+            points_possible=points_possible)
+            >>> file_processor = SubmissionFileProcessor(external_grader)
+            >>> files = {'assignment.py': file_obj}
+            >>> urls = file_processor.process_files(files)
+            >>> print(urls)
+            {'assignment.py': '/my_queue/550e8400-e29b-41d4-a716-446655440000'}
+        """
+        files_urls = {}
+        for filename, file_obj in files_dict.items():
+            # Validate file object type
+            if not (isinstance(file_obj, bytes) or hasattr(file_obj, 'read')):
+                actual_type = type(file_obj).__name__
+                logger.error(f"Invalid file object type for {filename}: got {actual_type}")
+                raise TypeError(
+                    f"Invalid file object type for {filename}: expected bytes or an object with 'read' method, "
+                    f"got {actual_type}")
+
+            # Handle file-like objects by reading their content
+            # This returns a bytes object that will be converted to ContentFile later
+            if hasattr(file_obj, 'read'):
+                try:
+                    file_obj = file_obj.read()  # read() returns a bytes object
+                except (IOError, OSError) as e:
+                    logger.error(f"Error reading file {filename}: {e}")
+                    raise FileProcessingError(f"Error reading file {filename}: {e}") from e
+                except UnicodeDecodeError as e:
path: submissions/api.py
user: login: ormsbee
body: I get that you're trying to be permissive here to make this friendlier for use in the platform, but please just require that the files here be instances of Django's [File](https://docs.djangoproject.com/en/5.2/ref/files/file/#the-file-object) object (including subclasses), and raise a `TypeError` if you find ones that are not. Let the caller worry about making sure the data exists there, and let it raise the exception when saving to `SubmisssionFile` if anything goes wrong. If someone has a `bytes` instance, it's easy enough for them to convert that to a `ContentFile` before calling this function. This "is it bytes or str or file" stuff is cluttering up the logic here without really giving much benefit, and bugs are more likely to slip in because of it.
created_at: 2025-05-03T20:09:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +697,76 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uuid}"
+    )
+
+
+# pylint: disable=abstract-method
+class SubmissionFileStorage(S3Boto3Storage):
+    """
+    Custom storage class for submission files that uses S3 in production with ORA bucket
+    and FileSystemStorage during tests.
+
+    This class automatically detects test environments and changes behavior accordingly.
+    """
+    def __new__(cls, *args, **kwargs):
+        if 'pytest' in sys.modules or any('test' in arg for arg in sys.argv):
+            return FileSystemStorage(*args, **kwargs)
+        return super().__new__(cls)  # pragma: no cover
+
+    bucket_name = getattr(settings, "FILE_UPLOAD_STORAGE_BUCKET_NAME", "openedx")
path: submissions/models.py
user: login: ormsbee
body: > However, @angonz and I have a question arising from your inquiry: the security for delivering the image depends on the bucket being used. Currently, they are stored in the default OpenEdX bucket, which means it adapts to its existing policies. But we want to know if it's appropriate to use this default bucket or if it would be more convenient to use another pre-existing bucket like the ORA-2 bucket (openedx-learning). Could you share your opinion on this point?

Sorry for not replying to this in my last review pass. Let's do this: have it look for a specific named storages defined like [how openedx-learning does it](https://github.com/openedx/openedx-learning/blob/655c0e350d3b275d19efa6393ec8822b4d944610/test_settings.py#L78C1-L86C1), but named `EDX_SUBMISSIONS['MEDIA']`. If that configuration value is not found (either `EDX_SUBMISSIONS` doesn't exist at all or `EDX_SUBMISSIONS['MEDIA']` doesn't), have it fall back to using the default Django storages. Please remember that you can set the `storage` parameter of a `FileField` to be any callable that returns a Storage class, so you can switch between storage configurations that way.

So please do the following:

1. Make a new function `get_storage()` and use the `functools.cache` decorator on it to cache it for future use.
2. Have `get_storage()` search for EDX_SUBMISSIONS configuration for the storage, but have it also gracefully fall back to the default storage backend if none is configured.
3. Make sure that `get_storage()` loads the storage configuration dynamically [like this](https://github.com/openedx/openedx-learning/blob/main/openedx_learning/apps/authoring/contents/models.py#L44-L47), and don't hardcode it to any specific storage type at this layer.
4. When creating the SubmissionFile.file FileField, set `storage=get_storage`.

As a general rule, try not to do smart switching behavior at this layer. Aside from making it hard to configure and override at the settings layer for testing, it's also just really confusing to have the `__new__` on a class return an instance of an entirely different class.
created_at: 2025-05-15T06:06:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,42 +1,51 @@
 #
-# This file is autogenerated by pip-compile with Python 3.12
+# This file is autogenerated by pip-compile with Python 3.11
 # by the following command:
 #
 #    make upgrade
 #
 accessible-pygments==0.0.5
     # via
-    #   -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt
+    #   -r /Users/leonardoberoes/Documents/Aulasneo/Projects/edx-submissions/requirements/docs.txt
     #   pydata-sphinx-theme
 alabaster==1.0.0
     # via
-    #   -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt
+    #   -r /Users/leonardoberoes/Documents/Aulasneo/Projects/edx-submissions/requirements/docs.txt
     #   sphinx
 asgiref==3.8.1
     # via
-    #   -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt
-    #   -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt
+    #   -r /Users/leonardoberoes/Documents/Aulasneo/Projects/edx-submissions/requirements/base.txt
+    #   -r /Users/leonardoberoes/Documents/Aulasneo/Projects/edx-submissions/requirements/docs.txt
path: requirements/test.txt
user: login: ormsbee
body: These requirements shouldn't have your environment specific paths in them.
created_at: 2025-05-15T06:07:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3,6 +3,16 @@
 # See BOM-2721 for more details.
 # Below is the copied and edited version of common_constraints
 
+# This is a temporary solution to override the real common_constraints.txt
+# In edx-lint, until the pyjwt constraint in edx-lint has been removed.
+# See BOM-2721 for more details.
+# Below is the copied and edited version of common_constraints
+
+# This is a temporary solution to override the real common_constraints.txt
+# In edx-lint, until the pyjwt constraint in edx-lint has been removed.
+# See BOM-2721 for more details.
+# Below is the copied and edited version of common_constraints
+
path: requirements/common_constraints.txt
user: login: ormsbee
body: It looks like these should just be deleted since they're copies of the first paragraph?
created_at: 2025-05-15T06:09:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,42 +1,51 @@
 #
-# This file is autogenerated by pip-compile with Python 3.12
+# This file is autogenerated by pip-compile with Python 3.11
 # by the following command:
 #
 #    make upgrade
 #
 accessible-pygments==0.0.5
     # via
-    #   -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt
+    #   -r /Users/leonardoberoes/Documents/Aulasneo/Projects/edx-submissions/requirements/docs.txt
     #   pydata-sphinx-theme
 alabaster==1.0.0
     # via
-    #   -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt
+    #   -r /Users/leonardoberoes/Documents/Aulasneo/Projects/edx-submissions/requirements/docs.txt
     #   sphinx
 asgiref==3.8.1
     # via
-    #   -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt
-    #   -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt
+    #   -r /Users/leonardoberoes/Documents/Aulasneo/Projects/edx-submissions/requirements/base.txt
+    #   -r /Users/leonardoberoes/Documents/Aulasneo/Projects/edx-submissions/requirements/docs.txt
path: requirements/test.txt
user: login: leoaulasneo98
body: Sorry, Dave. This was generated by compile requirements. It's also no longer necessary to add dependencies with the new approach.
created_at: 2025-05-15T15:09:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +697,76 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uuid}"
+    )
+
+
+# pylint: disable=abstract-method
+class SubmissionFileStorage(S3Boto3Storage):
+    """
+    Custom storage class for submission files that uses S3 in production with ORA bucket
+    and FileSystemStorage during tests.
+
+    This class automatically detects test environments and changes behavior accordingly.
+    """
+    def __new__(cls, *args, **kwargs):
+        if 'pytest' in sys.modules or any('test' in arg for arg in sys.argv):
+            return FileSystemStorage(*args, **kwargs)
+        return super().__new__(cls)  # pragma: no cover
+
+    bucket_name = getattr(settings, "FILE_UPLOAD_STORAGE_BUCKET_NAME", "openedx")
path: submissions/models.py
user: login: leoaulasneo98
body: I've already applied the changes. It's made things much easier. Thanks for the help.
created_at: 2025-05-15T15:10:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -40,6 +40,17 @@ class ExternalGraderQueueEmptyError(SubmissionError):
     """
 
 
+class FileProcessingError(SubmissionError):
+    """
+    Exception raised when there's an error reading or processing a file.
+
+    This exception is raised when file operations fail, such as:
+    - I/O errors when reading file content
+    - OS errors during file operations
+    - Unicode decoding errors when processing file content
+    """
path: submissions/errors.py
user: login: ormsbee
body: Can we remove this now that it's no longer being used?
created_at: 2025-05-15T20:46:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +696,99 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uuid}"
+    )
+
+
+@functools.cache
+def _get_storage_cached():
+    """
+    Cached implementation to get the configured storage backend.
+    This function is for internal use only.
+    """
+    edx_submissions_config = getattr(settings, 'EDX_SUBMISSIONS', {})
+    storage_config = edx_submissions_config.get('MEDIA')
+
+    if storage_config:
+        return storage_config
+
+    return default_storage
+
+
+def get_storage():
+    """
+    Get the configured storage backend or fallback to default storage.
+    Private helper with caching to avoid Django migration serialization errors.
+
+    This function checks for a storage configuration in the Django settings.
+    It first looks for 'MEDIA' in the 'EDX_SUBMISSIONS' configuration dictionary.
+
+    Returns:
+        Storage instance: Returns the configured storage if found in EDX_SUBMISSIONS['MEDIA'],
+                         otherwise returns Django's default_storage.
+
+    Example:
+        # In settings
+        from storages.backends.s3boto3 import S3Boto3Storage
+        EDX_SUBMISSIONS = {
+            'MEDIA': S3Boto3Storage(bucket_name='my-bucket')
+        }
+
+        # Then get_storage() will return the S3Boto3Storage instance
+    """
+    return _get_storage_cached()  # For performance while keeping this function serializable for migrations
path: submissions/models.py
user: login: ormsbee
body: Very helpful/informative comment, thank you.
created_at: 2025-05-15T20:47:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +696,99 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uuid}"
+    )
+
+
+@functools.cache
+def _get_storage_cached():
+    """
+    Cached implementation to get the configured storage backend.
+    This function is for internal use only.
+    """
+    edx_submissions_config = getattr(settings, 'EDX_SUBMISSIONS', {})
+    storage_config = edx_submissions_config.get('MEDIA')
+
+    if storage_config:
+        return storage_config
path: submissions/models.py
user: login: ormsbee
body: Wait, it really works to just pass back the dict directly? I thought we'd have to instantiate the storage class here, like [this example](https://github.com/openedx/openedx-learning/blob/655c0e350d3b275d19efa6393ec8822b4d944610/openedx_learning/apps/authoring/contents/models.py#L45-L47). So something like:

```python
# Somewhere much higher:
from django.utils.module_loading import import_string

if storage_config:
    storage_cls = import_string(storage_config['BACKEND'])
    options = storage_config.get('OPTIONS', {})
    return storage_cls(**options)
```
created_at: 2025-05-15T20:53:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +696,99 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uuid}"
+    )
+
+
+@functools.cache
+def _get_storage_cached():
+    """
+    Cached implementation to get the configured storage backend.
+    This function is for internal use only.
+    """
+    edx_submissions_config = getattr(settings, 'EDX_SUBMISSIONS', {})
+    storage_config = edx_submissions_config.get('MEDIA')
+
+    if storage_config:
+        return storage_config
+
+    return default_storage
+
+
+def get_storage():
+    """
+    Get the configured storage backend or fallback to default storage.
+    Private helper with caching to avoid Django migration serialization errors.
+
+    This function checks for a storage configuration in the Django settings.
+    It first looks for 'MEDIA' in the 'EDX_SUBMISSIONS' configuration dictionary.
+
+    Returns:
+        Storage instance: Returns the configured storage if found in EDX_SUBMISSIONS['MEDIA'],
+                         otherwise returns Django's default_storage.
+
+    Example:
+        # In settings
+        from storages.backends.s3boto3 import S3Boto3Storage
+        EDX_SUBMISSIONS = {
+            'MEDIA': S3Boto3Storage(bucket_name='my-bucket')
path: submissions/models.py
user: login: ormsbee
body: Oh, I see where my misunderstanding of the code in `_get_storage_cached()` comes from. We shouldn't actually instantiate or even import the storage class in the settings files, only specify the `EDX_SUBMISSIONS['MEDIA']` configuration dict in the standard convention that Django uses for media config, which specifies the path of the class we'll need to load later as a string.

The reason has to do with when these things get loaded in the startups cycle. Settings are often overridden in multiple places (e.g. `common.py`, `prod.py`, `tutor.py`, etc). But there's a certain amount of caching/bootstrapping that happens when these storage backends get imported and initialized. For example, there was this issue:

https://github.com/overhangio/tutor-minio/pull/25

> The value for AWS_S3_ENDPOINT_URL set in openedx-development-settings specifies port 9000, and it was being written to Studio and LMS settings correctly. But this is the sequence of what was really happening:
> 
> 1. openedx-common-settings first sets AWS_S3_ENDPOINT_URL WITHOUT port
>    info, since this is how it's used in prod deployments.
> 2. openedx-common-settings imports S3Boto3Storage because it wants to
>    make a subclass to use for USER_TASKS_ARTIFACT_STORAGE.
> 3. The act of importing that package has the side-effect of initializing
>    the Django storages (django.core.files.storage.default_storage).
> 4. Because the DEFAULT_FILE_STORAGE class is S3Boto3Storage, it then
>    reads the value of AWS_S3_ENDPOINT_URL (which has no port info), and
>    sets this as the S3 conneciton information for the S3Boto3Storage
>    instance at that time.
> 5. openedx-development-settings then comes along and changes the value
>    of AWS_S3_ENDPOINT_URL to specify port 9000, but by this point it's
>    too late to affect django-storages (though it will confuse folks who
>    try to debug).

Leaving it in string form avoids this problem. The settings can get overridden as much as people need, and the storage class won't be instantiated until after it's all been set up properly. Please assume the configuration will be something like:

```python
EDX_SUBMISSIONS = {
    'MEDIA': {
        'BACKEND': 'django.core.files.storage.InMemoryStorage',
        'OPTIONS': {
        }
    }
}
```

Also, please try specifying such a backend for the [test settings](https://github.com/openedx/edx-submissions/blob/master/settings.py), to make sure it works properly.
created_at: 2025-05-15T21:24:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -40,6 +40,17 @@ class ExternalGraderQueueEmptyError(SubmissionError):
     """
 
 
+class FileProcessingError(SubmissionError):
+    """
+    Exception raised when there's an error reading or processing a file.
+
+    This exception is raised when file operations fail, such as:
+    - I/O errors when reading file content
+    - OS errors during file operations
+    - Unicode decoding errors when processing file content
+    """
path: submissions/errors.py
user: login: leoaulasneo98
body: Ok Dave, understood
created_at: 2025-05-16T01:13:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +696,99 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uuid}"
+    )
+
+
+@functools.cache
+def _get_storage_cached():
+    """
+    Cached implementation to get the configured storage backend.
+    This function is for internal use only.
+    """
+    edx_submissions_config = getattr(settings, 'EDX_SUBMISSIONS', {})
+    storage_config = edx_submissions_config.get('MEDIA')
+
+    if storage_config:
+        return storage_config
path: submissions/models.py
user: login: leoaulasneo98
body: Hi Dave, I had the same question while reading your previous instructions and preferred to keep things simple. But I completely understand what you're saying, and with that piece of code you've made my job much easier. I had previously tried instantiating the storage class, but the result wasn't as clean as I would have liked, so I went with the solution you reviewed a few days ago.
created_at: 2025-05-16T01:16:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -693,3 +696,99 @@ def update_status(self, new_status):
     def create_from_uuid(cls, submission_uuid, **kwargs):
         submission = Submission.objects.get(uuid=submission_uuid)
         return cls.objects.create(submission=submission, **kwargs)
+
+
+def submission_file_path(instance, _):
+    """
+    Generate file path for submission files.
+    Format: queue_name/uuid
+    The filename is replaced with the UUID to ensure uniqueness without preserving extension.
+    """
+    return os.path.join(
+        instance.external_grader.queue_name,
+        f"{instance.uuid}"
+    )
+
+
+@functools.cache
+def _get_storage_cached():
+    """
+    Cached implementation to get the configured storage backend.
+    This function is for internal use only.
+    """
+    edx_submissions_config = getattr(settings, 'EDX_SUBMISSIONS', {})
+    storage_config = edx_submissions_config.get('MEDIA')
+
+    if storage_config:
+        return storage_config
+
+    return default_storage
+
+
+def get_storage():
+    """
+    Get the configured storage backend or fallback to default storage.
+    Private helper with caching to avoid Django migration serialization errors.
+
+    This function checks for a storage configuration in the Django settings.
+    It first looks for 'MEDIA' in the 'EDX_SUBMISSIONS' configuration dictionary.
+
+    Returns:
+        Storage instance: Returns the configured storage if found in EDX_SUBMISSIONS['MEDIA'],
+                         otherwise returns Django's default_storage.
+
+    Example:
+        # In settings
+        from storages.backends.s3boto3 import S3Boto3Storage
+        EDX_SUBMISSIONS = {
+            'MEDIA': S3Boto3Storage(bucket_name='my-bucket')
path: submissions/models.py
user: login: leoaulasneo98
body: Understood Dave, I'll try to have the changes made before the meeting.
created_at: 2025-05-16T01:20:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,7 +15,6 @@
 # SubmissionError imported so that code importing this api has access
 from submissions.errors import (  # pylint: disable=unused-import
     ExternalGraderQueueEmptyError,
-    SubmissionError,
path: submissions/api.py
user: login: timmc-edx
body: This removal appears to be causing a failure in edx-platform when upgrading to edx-submissions 3.11:

https://github.com/openedx/edx-platform/actions/runs/15213754304/job/42794120855?pr=36786


created_at: 2025-05-23T15:36:34Z
author_association: NONE
diff_hunk: @@ -15,7 +15,6 @@
 # SubmissionError imported so that code importing this api has access
 from submissions.errors import (  # pylint: disable=unused-import
     ExternalGraderQueueEmptyError,
-    SubmissionError,
path: submissions/api.py
user: login: ormsbee
body: @timmc-edx: Thank you! @leoaulasneo98: Can you please fix this and do a patch version bump?
created_at: 2025-05-23T19:37:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,7 +15,6 @@
 # SubmissionError imported so that code importing this api has access
 from submissions.errors import (  # pylint: disable=unused-import
     ExternalGraderQueueEmptyError,
-    SubmissionError,
path: submissions/api.py
user: login: leoaulasneo98
body: Hi Dave, I'll take care of it. Sorry for the mistake. I'll add the import again and do the corresponding PR.
created_at: 2025-05-26T12:26:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,7 +15,6 @@
 # SubmissionError imported so that code importing this api has access
 from submissions.errors import (  # pylint: disable=unused-import
     ExternalGraderQueueEmptyError,
-    SubmissionError,
path: submissions/api.py
user: login: timmc-edx
body: I went ahead and created a PR for it: https://github.com/openedx/edx-submissions/pull/303

(I'd like to get this merged soon so that edx-platform requirements updates are unblocked -- otherwise, it would be best to temporarily pin the version to <3.11.0 in edx-platform.)
created_at: 2025-05-27T11:08:46Z
author_association: NONE
diff_hunk: @@ -15,7 +15,6 @@
 # SubmissionError imported so that code importing this api has access
 from submissions.errors import (  # pylint: disable=unused-import
     ExternalGraderQueueEmptyError,
-    SubmissionError,
path: submissions/api.py
user: login: ormsbee
body: I merged https://github.com/openedx/edx-submissions/pull/301 and made a 3.11.1 release.
created_at: 2025-05-27T12:12:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: openedx-webhooks
created_at: 2025-02-24T15:19:29Z
body: Thanks for the pull request, @leoaulasneo98!


This repository is currently maintained by `@openedx/committers-edx-submissions`.


Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

<details><summary>:radio_button: Get product approval</summary>

If you haven't already, [check this list](https://openedx.atlassian.net/wiki/spaces/COMM/pages/3875962884/How+to+submit+an+open+source+contribution+for+Product+Review#Does-my-contribution-require-Product-Review%3F) to see if your contribution needs to go through the product review process.

- If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the [Product Working Group](https://openedx.atlassian.net/wiki/spaces/COMM/pages/3449028609/Product+Working+Group).
    - This process (including the steps you'll need to take) is documented [here](https://openedx.atlassian.net/wiki/spaces/COMM/pages/3875962884/How+to+submit+an+open+source+contribution+for+Product+Review#Product-Review-Process).
- If it doesn't, simply proceed with the next step.
</details>

<details><summary>:radio_button: Provide context</summary>

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

- Dependencies
  > This PR must be merged before / after / at the same time as ...
- Blockers
  > This PR is waiting for OEP-1234 to be accepted.
- Timeline information
  > This PR must be merged by XX date **because** ...
- Partner information
  > This is for a course on edx.org.
- Supporting documentation
- Relevant [Open edX discussion forum](https://discuss.openedx.org/) threads
</details>





<details><summary>:radio_button: Get a green build</summary>

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.
</details><details>


---

<details><summary>Where can I find more information?</summary>

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

- [Overview of Review Process for Community Contributions](https://docs.openedx.org/en/latest/developers/references/developer_guide/process/FAQ-about-pull-requests.html)
- [Pull Request Status Guide](https://docs.openedx.org/en/latest/developers/references/developer_guide/process/pull-request-statuses.html)
- [Making changes to your pull request](https://docs.openedx.org/en/latest/documentors/how-tos/make_changes_to_your_pull_request.html)
</details>

<details><summary>When can I expect my changes to be merged?</summary>

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

- The size and impact of the changes that it introduces
- The need for product review
- Maintenance status of the parent repository

:bulb: *As a result it may take up to several weeks or months to complete a review and merge your PR.*
</details>
<!-- comment:external_pr -->
<!-- data: eyJkcmFmdCI6IGZhbHNlfQ== -->

author_association: NONE
user: login: codecov[bot]
created_at: 2025-02-24T15:20:57Z
body: ## [Codecov](https://app.codecov.io/gh/openedx/edx-submissions/pull/286?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) Report
:white_check_mark: All modified and coverable lines are covered by tests.
:white_check_mark: Project coverage is 94.79%. Comparing base ([`1096759`](https://app.codecov.io/gh/openedx/edx-submissions/commit/109675969d1d2c7b59f2409bbd018941316ba00e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx)) to head ([`275b6e7`](https://app.codecov.io/gh/openedx/edx-submissions/commit/275b6e7bfd98459708556b9b9119ee71150c3c42?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx)).
:warning: Report is 5 commits behind head on master.

<details><summary>Additional details and impacted files</summary>



```diff
@@            Coverage Diff             @@
##           master     #286      +/-   ##
==========================================
+ Coverage   94.43%   94.79%   +0.36%     
==========================================
  Files          18       18              
  Lines        2263     2423     +160     
  Branches       95       99       +4     
==========================================
+ Hits         2137     2297     +160     
  Misses        115      115              
  Partials       11       11              
```

| [Flag](https://app.codecov.io/gh/openedx/edx-submissions/pull/286/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/openedx/edx-submissions/pull/286/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) | `94.79% <100.00%> (+0.36%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx#carryforward-flags-in-the-pull-request-comment) to find out more.
</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/openedx/edx-submissions/pull/286?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx).
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: angonz
created_at: 2025-03-06T13:22:36Z
body: Review should only start after PR #283 is merged.
author_association: NONE
user: login: leoaulasneo98
created_at: 2025-04-25T15:54:37Z
body: > High level security question: Am I understanding correctly that this is storing things in the default Django storages backend, and if so, does that mean it will be publicly readable (though obscured because of the randomly generated UUIDs)?

Hi @ormsbee ,
The implemented flow uses Django file fields, which means security policies depend on the Bucket policies and Django configuration and integration. Here's a breakdown for the standard Tutor development setup using Minio:

You can access a resource if you have its complete URL.
You cannot access resources on ports 9000 or 9001 without credentials, except for resources with a complete, valid URL.
Even if you obtain a URL, you'll still need to decode it or know the file extension to access the resource. This is a mechanism inherited from XqueueServerLegacy.
Currently, there are no signature mechanisms to restrict client-only access. This is because the XqueueWatcher has broad capabilities, and the actual image processing is configured by the grader, not the XqueueWatcher itself.

If you wanted to implement a signature mechanism to restrict resource access, you would need to configure both the edX platform and the XqueueWatcher through the grader.
However, @angonz  and I have a question arising from your inquiry: the security for delivering the image depends on the bucket being used. Currently, they are stored in the default OpenEdX bucket, which means it adapts to its existing policies. But we want to know if it's appropriate to use this default bucket or if it would be more convenient to use another pre-existing bucket like the ORA-2 bucket (openedx-learning). Could you share your opinion on this point?

<img width="1069" alt="image" src="https://github.com/user-attachments/assets/7f86a771-4803-4d3a-b870-2b513c090016" />


author_association: CONTRIBUTOR
user: login: sarina
created_at: 2025-05-13T14:20:24Z
body: @leoaulasneo98 could you please rebase and resolve conflicts?
author_association: CONTRIBUTOR
user: login: leoaulasneo98
created_at: 2025-05-13T15:28:25Z
body: > @leoaulasneo98 could you please rebase and resolve conflicts?

Hi Sarina, good afternoon. Ready.

author_association: CONTRIBUTOR
user: login: angonz
created_at: 2025-05-15T12:36:48Z
body: Hi Dave, I've sent an invitation for Monday as Friday Leonardo will be out of the office.
author_association: NONE