Skip to content

Commit d99a8f2

Browse files
committed
feat: Implement submission file system with configurable storage
- Replace SubmissionFileProcessor with direct file handling in create_external_grader_detail - Implement flexible storage configuration via EDX_SUBMISSIONS['MEDIA'] dictionary - Support dynamic backend loading with proper Django settings conventions - Add cached storage implementation with serialization-safe public interface - Configure InMemoryStorage for tests to ensure reliable test execution - Update tests to accommodate new storage patterns and configuration - Ensure backward compatibility with existing file operations This implementation follows Django best practices for storage configuration while maintaining performance through caching and avoiding serialization issues with migrations.
1 parent 108a7a9 commit d99a8f2

6 files changed

Lines changed: 153 additions & 134 deletions

File tree

settings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,11 @@
8282
)
8383

8484
TEST_APPS = ('submissions',)
85+
86+
EDX_SUBMISSIONS = {
87+
'MEDIA': {
88+
'BACKEND': 'django.core.files.storage.InMemoryStorage',
89+
'OPTIONS': {
90+
}
91+
}
92+
}

submissions/api.py

Lines changed: 31 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def create_external_grader_detail(student_item_dict,
5959
**external_grader_additional_data
6060
):
6161
"""
62-
Creates a submission and an associated ExternalGraderDetail record.
62+
Creates a submission and an associated ExternalGraderDetail record with optional file attachments.
6363
6464
Args:
6565
student_item_dict (dict): The student_item this submission is associated with.
@@ -74,13 +74,28 @@ def create_external_grader_detail(student_item_dict,
7474
points_possible (int, optional): The maximum possible points for this submission. Defaults to 1.
7575
7676
external_grader_additional_data: Additional keyword arguments that may be used for the external grader.
77+
If a 'files' dictionary is included, files will be processed and stored as SubmissionFile objects.
78+
The 'files' dictionary should map filenames to file objects (typically FileObjForWebobFiles).
7779
7880
Returns:
7981
ExternalGraderDetail: The created external grader detail record that references the submission.
82+
Any processed files are accessible through the `files` related_name on this object.
8083
8184
Raises:
8285
ExternalGraderQueueEmptyError: If queue_name is empty.
8386
SubmissionInternalError: If there's an error creating the submission or external grader detail.
87+
88+
Example:
89+
>>> files = {'assignment.py': file_obj}
90+
>>> external_grader = create_external_grader_detail(
91+
... student_item_dict,
92+
... answer,
93+
... 'python_grader',
94+
... files=files
95+
... )
96+
>>> # Access files through external_grader.files.all()
97+
>>> # Get URLs for external systems:
98+
>>> urls = {f.original_filename: f.xqueue_url for f in external_grader.files.all()}
8499
"""
85100

86101
submission = create_submission(student_item_dict, answer)
@@ -98,8 +113,15 @@ def create_external_grader_detail(student_item_dict,
98113

99114
files_dict = external_grader_additional_data.get('files')
100115
if files_dict:
101-
file_processor = SubmissionFileProcessor(instance)
102-
file_processor.process_files(files_dict)
116+
117+
files_urls = {}
118+
for filename, file_obj in files_dict.items():
119+
submission_file = SubmissionFile.objects.create(
120+
external_grader=instance,
121+
file=file_obj.file,
122+
original_filename=filename
123+
)
124+
files_urls[filename] = submission_file.xqueue_url
103125

104126
return instance
105127

@@ -111,63 +133,14 @@ def create_external_grader_detail(student_item_dict,
111133
raise SubmissionInternalError(error_message) from error
112134

113135

114-
class SubmissionFileProcessor:
136+
def get_files_for_grader(external_grader):
115137
"""
116-
Process file operations for submissions
138+
Returns files in format expected by xqueue-watcher when is calling by get_submission endpoint.
117139
"""
118-
119-
def __init__(self, external_grader):
120-
self.external_grader = external_grader
121-
122-
def process_files(self, files_dict):
123-
"""
124-
Process uploaded files from an Open edX environment and store them as SubmissionFile objects.
125-
126-
This method specifically handles native OpenedX FileObjForWebobFiles objects.
127-
128-
The method performs the following operations:
129-
1. Stores each file directly as a SubmissionFile object in the database
130-
2. Returns URLs in xqueue-compatible format
131-
132-
Args:
133-
files_dict (dict): Dictionary mapping filenames to file objects.
134-
Format: {filename: file_object, ...}
135-
136-
Returns:
137-
dict: Dictionary mapping original filenames to xqueue URLs.
138-
Format: {filename: "/queue_name/uuid", ...}
139-
140-
Example:
141-
>>> external_grader = ExternalGraderDetail.create_from_uuid(
142-
submission_uuid=submission_uuid,
143-
queue_name=queue_name,
144-
grader_file_name=grader_file_name,
145-
points_possible=points_possible)
146-
>>> file_processor = SubmissionFileProcessor(external_grader)
147-
>>> files = {'assignment.py': file_obj}
148-
>>> urls = file_processor.process_files(files)
149-
>>> print(urls)
150-
{'assignment.py': '/my_queue/550e8400-e29b-41d4-a716-446655440000'}
151-
"""
152-
files_urls = {}
153-
for filename, file_obj in files_dict.items():
154-
submission_file = SubmissionFile.objects.create(
155-
external_grader=self.external_grader,
156-
file=file_obj.file,
157-
original_filename=filename
158-
)
159-
files_urls[filename] = submission_file.xqueue_url
160-
161-
return files_urls
162-
163-
def get_files_for_grader(self):
164-
"""
165-
Returns files in format expected by xqueue-watcher
166-
"""
167-
return {
168-
file.original_filename: file.file.url
169-
for file in self.external_grader.files.all()
170-
}
140+
return {
141+
file.original_filename: file.file.url
142+
for file in external_grader.files.all()
143+
}
171144

172145

173146
def create_submission(

submissions/errors.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,6 @@ class ExternalGraderQueueEmptyError(SubmissionError):
4040
"""
4141

4242

43-
class FileProcessingError(SubmissionError):
44-
"""
45-
Exception raised when there's an error reading or processing a file.
46-
47-
This exception is raised when file operations fail, such as:
48-
- I/O errors when reading file content
49-
- OS errors during file operations
50-
- Unicode decoding errors when processing file content
51-
"""
52-
53-
5443
class SubmissionRequestError(SubmissionError):
5544
"""
5645
This error is raised when there was a request-specific error

submissions/models.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from django.db import DatabaseError, models, transaction
2222
from django.db.models.signals import post_save, pre_save
2323
from django.dispatch import Signal, receiver
24+
from django.utils.module_loading import import_string
2425
from django.utils.timezone import now
2526
from jsonfield import JSONField
2627
from model_utils.models import TimeStampedModel
@@ -714,37 +715,51 @@ def submission_file_path(instance, _):
714715
def _get_storage_cached():
715716
"""
716717
Cached implementation to get the configured storage backend.
717-
This function is for internal use only.
718+
719+
This private function loads storage configuration from settings and
720+
dynamically instantiates the specified storage backend. It expects
721+
EDX_SUBMISSIONS['MEDIA'] to be a dict with 'BACKEND' (string path to
722+
storage class) and optional 'OPTIONS' (dict of parameters).
723+
724+
This function is for internal use only and is cached to improve performance.
718725
"""
719726
edx_submissions_config = getattr(settings, 'EDX_SUBMISSIONS', {})
720727
storage_config = edx_submissions_config.get('MEDIA')
721728

722729
if storage_config:
723-
return storage_config
730+
storage_cls = import_string(storage_config['BACKEND'])
731+
options = storage_config.get('OPTIONS', {})
732+
return storage_cls(**options)
724733

725734
return default_storage
726735

727736

728737
def get_storage():
729738
"""
730739
Get the configured storage backend or fallback to default storage.
731-
Private helper with caching to avoid Django migration serialization errors.
732740
733741
This function checks for a storage configuration in the Django settings.
734742
It first looks for 'MEDIA' in the 'EDX_SUBMISSIONS' configuration dictionary.
735743
744+
The function uses an internal cached implementation while remaining
745+
serializable for Django migrations, avoiding "Cannot serialize" errors.
746+
736747
Returns:
737748
Storage instance: Returns the configured storage if found in EDX_SUBMISSIONS['MEDIA'],
738749
otherwise returns Django's default_storage.
739750
740751
Example:
741-
# In settings
742-
from storages.backends.s3boto3 import S3Boto3Storage
752+
# In settings.py
743753
EDX_SUBMISSIONS = {
744-
'MEDIA': S3Boto3Storage(bucket_name='my-bucket')
754+
'MEDIA': {
755+
'BACKEND': 'storages.backends.s3boto3.S3Boto3Storage',
756+
'OPTIONS': {
757+
'bucket_name': 'my-bucket'
758+
}
759+
}
745760
}
746761
747-
# Then get_storage() will return the S3Boto3Storage instance
762+
# Then get_storage() will return an S3Boto3Storage instance
748763
"""
749764
return _get_storage_cached() # For performance while keeping this function serializable for migrations
750765

submissions/tests/test_api.py

Lines changed: 84 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
# Local imports
2121
from submissions import api
22-
from submissions.api import SubmissionFileProcessor
22+
from submissions.api import create_external_grader_detail, get_files_for_grader
2323
from submissions.errors import ExternalGraderQueueEmptyError, SubmissionInternalError
2424
from submissions.models import (
2525
ExternalGraderDetail,
@@ -993,44 +993,71 @@ def test_create_external_grader_detail_without_files(self):
993993
self.assertEqual(external_grader_instance.files.count(), 0)
994994

995995

996-
class TestSubmissionFileProcessor(TestCase):
996+
class TestExternalGraderFileProcessing(TestCase):
997997
"""
998-
Test the SubmissionFileProcessor functionality.
998+
Test the file processing functionality for external graders.
999999
"""
10001000

10011001
def setUp(self):
10021002
"""Set up common test data."""
1003-
self.student_item = StudentItem.objects.create(
1004-
student_id="test_student",
1005-
course_id="test_course",
1006-
item_id="test_item"
1007-
)
1008-
self.submission = Submission.objects.create(
1009-
student_item=self.student_item,
1010-
answer="test answer",
1011-
attempt_number=1
1012-
)
1013-
self.external_grader_detail = ExternalGraderDetail.objects.create(
1014-
submission=self.submission,
1015-
queue_name="test_queue"
1016-
)
1017-
self.processor = SubmissionFileProcessor(self.external_grader_detail)
1003+
self.student_item_dict = {
1004+
"student_id": "test_student",
1005+
"course_id": "test_course",
1006+
"item_id": "test_item",
1007+
"item_type": "test_type"
1008+
}
1009+
self.answer = "test answer"
1010+
self.queue_name = "test_queue"
10181011

1019-
def test_process_files_with_file_objects(self):
1020-
"""Test processing files with file objects that have a file attribute."""
1012+
def test_create_external_grader_with_files(self):
1013+
"""Test processing files within create_external_grader_detail."""
10211014

10221015
class FileObjWithFileAttr:
10231016
def __init__(self):
10241017
self.file = SimpleUploadedFile("test.txt", b"test content")
10251018

10261019
file_obj = FileObjWithFileAttr()
10271020
files_dict = {"test.txt": file_obj}
1028-
urls = self.processor.process_files(files_dict)
1029-
self.assertEqual(len(urls), 1)
1030-
self.assertTrue(urls["test.txt"].startswith(f"/{self.external_grader_detail.queue_name}/"))
1021+
1022+
# Create external grader with files
1023+
instance = create_external_grader_detail(
1024+
student_item_dict=self.student_item_dict,
1025+
answer=self.answer,
1026+
queue_name=self.queue_name,
1027+
files=files_dict
1028+
)
1029+
1030+
# Verify SubmissionFile was created correctly
1031+
submission_file = SubmissionFile.objects.get(
1032+
external_grader=instance,
1033+
original_filename="test.txt"
1034+
)
1035+
self.assertTrue(submission_file.xqueue_url.startswith(f"/{self.queue_name}/"))
10311036

10321037
def test_get_files_for_grader(self):
1033-
"""Test retrieving files in xwatcher format."""
1038+
"""Test the standalone get_files_for_grader function."""
1039+
1040+
class FileObjWithFileAttr:
1041+
def __init__(self):
1042+
self.file = SimpleUploadedFile("test.txt", b"test content")
1043+
1044+
# Create external grader with a file
1045+
instance = create_external_grader_detail(
1046+
student_item_dict=self.student_item_dict,
1047+
answer=self.answer,
1048+
queue_name=self.queue_name,
1049+
files={"test.txt": FileObjWithFileAttr()}
1050+
)
1051+
1052+
# Test get_files_for_grader function
1053+
grader_files = get_files_for_grader(instance)
1054+
self.assertEqual(len(grader_files), 1)
1055+
self.assertIn("test.txt", grader_files)
1056+
self.assertTrue(isinstance(grader_files["test.txt"], str))
1057+
1058+
def test_multiple_files_processing(self):
1059+
"""Test processing multiple files through create_external_grader_detail."""
1060+
10341061
class FileObjWithFileAttr1:
10351062
def __init__(self):
10361063
self.file = SimpleUploadedFile("test1.txt", b"content1")
@@ -1039,16 +1066,30 @@ class FileObjWithFileAttr2:
10391066
def __init__(self):
10401067
self.file = SimpleUploadedFile("test2.txt", b"content2")
10411068

1042-
self.processor.process_files({
1069+
files_dict = {
10431070
"test1.txt": FileObjWithFileAttr1(),
10441071
"test2.txt": FileObjWithFileAttr2()
1045-
})
1072+
}
10461073

1047-
grader_files = self.processor.get_files_for_grader()
1074+
# Create external grader with multiple files
1075+
instance = create_external_grader_detail(
1076+
student_item_dict=self.student_item_dict,
1077+
answer=self.answer,
1078+
queue_name=self.queue_name,
1079+
files=files_dict
1080+
)
1081+
1082+
# Verify both files were created
1083+
submission_files = SubmissionFile.objects.filter(external_grader=instance)
1084+
self.assertEqual(submission_files.count(), 2)
1085+
1086+
# Test get_files_for_grader
1087+
grader_files = get_files_for_grader(instance)
10481088
self.assertEqual(len(grader_files), 2)
1049-
self.assertTrue(all(isinstance(url, str) for url in grader_files.values()))
1089+
self.assertIn("test1.txt", grader_files)
1090+
self.assertIn("test2.txt", grader_files)
10501091

1051-
def test_process_files_complete_flow(self):
1092+
def test_complete_file_processing_flow(self):
10521093
"""
10531094
Test the complete flow of processing a file through to SubmissionFile creation.
10541095
"""
@@ -1059,17 +1100,22 @@ def __init__(self):
10591100

10601101
files_dict = {"complete_test.txt": FileObjWithFileAttr()}
10611102

1062-
# Process the file
1063-
urls = self.processor.process_files(files_dict)
1064-
1065-
# Verify URL was created
1066-
self.assertEqual(len(urls), 1)
1067-
file_url = urls["complete_test.txt"]
1068-
self.assertTrue(file_url.startswith(f"/{self.external_grader_detail.queue_name}/"))
1103+
# Create external grader with file
1104+
instance = create_external_grader_detail(
1105+
student_item_dict=self.student_item_dict,
1106+
answer=self.answer,
1107+
queue_name=self.queue_name,
1108+
files=files_dict
1109+
)
10691110

10701111
# Verify SubmissionFile was created correctly
10711112
submission_file = SubmissionFile.objects.get(
1072-
external_grader=self.external_grader_detail,
1113+
external_grader=instance,
10731114
original_filename="complete_test.txt"
10741115
)
1075-
self.assertEqual(submission_file.xqueue_url, file_url)
1116+
1117+
# Verify URL format
1118+
self.assertTrue(submission_file.xqueue_url.startswith(f"/{self.queue_name}/"))
1119+
1120+
# Verify file content is accessible
1121+
self.assertEqual(submission_file.file.read(), b"test binary content")

0 commit comments

Comments
 (0)