Skip to content

Commit 98be5fc

Browse files
feat: implement file support with configurable storage [FC-73]
Add support for submitting files for remote grading. This is a capability that XQueue currently has, and we're matching that functionality here as part of XQueue's deprecation process. Details: - Create SubmissionFile model - Document architectural decisions with ADR - Add test queue folder in .gitignore - Implement flexible storage configuration via EDX_SUBMISSIONS['MEDIA'] dictionary - Configure InMemoryStorage for tests to ensure reliable test execution - Version bump to 3.11.0
1 parent 1096759 commit 98be5fc

9 files changed

Lines changed: 608 additions & 8 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,4 @@ test.txt.tmp
6363

6464
# VSCode
6565
.vscode
66+
test_queue/*
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
2. File Handling Implementation for Submission System
2+
#####################################################
3+
4+
Status
5+
******
6+
7+
**Provisional** *2025-02-14*
8+
9+
Implemented by https://github.com/openedx/edx-submissions/pull/286
10+
11+
Context
12+
*******
13+
14+
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.
15+
16+
### Current Limitations
17+
18+
1. **Inadequate File Management**: XQueue's approach relies on JSON strings in character fields, with size constraints and manual URL manipulation for file handling.
19+
20+
2. **Process Inefficiencies**: The current system uses synchronous HTTP for file retrieval, lacks proper validation, and tightly couples submission processing with file handling.
21+
22+
3. **Integration Challenges**: External graders depend on specific URL formats with HTTP-based retrieval, embedding file information directly in submission payloads.
23+
24+
Decision
25+
********
26+
27+
We will implement a dedicated file management system for the assessment submission process, focusing on workflow and educational needs:
28+
29+
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.
30+
31+
2. **Streamlined Workflow**: Design a clear process where files are automatically processed during submission creation, securely stored, and efficiently delivered to grading systems.
32+
33+
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.
34+
35+
Consequences
36+
************
37+
38+
Positive:
39+
---------
40+
41+
1. **Architecture**: Clean separation of concerns, improved maintainability, better error handling, optimized database access
42+
43+
2. **Integration**: Seamless xqueue-watcher compatibility, support for existing workflows, minimal client code changes
44+
45+
3. **Operations**: Robust file validation, improved tracking, better error visibility, simplified lifecycle management
46+
47+
Negative:
48+
---------
49+
50+
1. **Technical**: Additional database structures
51+
52+
2. **Migration**: Temporary system complexity, additional testing needs
53+
54+
3. **Performance**: File processing overhead
55+
56+
References
57+
**********
58+
59+
Current System Documentation:
60+
* XQueue Repository: https://github.com/openedx/xqueue
61+
* XQueue Watcher Repository: https://github.com/openedx/xqueue-watcher
62+
63+
Related Repositories:
64+
* edx-submissions: https://github.com/openedx/edx-submissions
65+
* edx-platform: https://github.com/openedx/edx-platform
66+
* XQueue Repository: https://github.com/openedx/xqueue
67+
68+
Related Documentation:
69+
* ADR 0001: Creation of ExternalGraderDetail Model for XQueue Migration
70+
71+
Future Event Integration:
72+
* Open edX Events Framework: https://github.com/openedx/openedx-events
73+
* Event Bus Documentation: https://openedx.atlassian.net/wiki/spaces/AC/pages/124125264/Event+Bus
74+
75+
Related Architecture Documents:
76+
* Open edX Architecture Guidelines: https://openedx.atlassian.net/wiki/spaces/AC/pages/124125264/Architecture+Guidelines
77+

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/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
""" API for creating submissions and scores. """
2-
__version__ = '3.10.1'
2+
__version__ = '3.11.0'

submissions/api.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
# SubmissionError imported so that code importing this api has access
1616
from submissions.errors import ( # pylint: disable=unused-import
1717
ExternalGraderQueueEmptyError,
18-
SubmissionError,
1918
SubmissionInternalError,
2019
SubmissionNotFoundError,
2120
SubmissionRequestError
@@ -28,6 +27,7 @@
2827
ScoreSummary,
2928
StudentItem,
3029
Submission,
30+
SubmissionFile,
3131
score_reset,
3232
score_set
3333
)
@@ -49,7 +49,6 @@
4949
TOP_SUBMISSIONS_CACHE_TIMEOUT = 300
5050

5151

52-
# pylint: disable=unused-argument
5352
def create_external_grader_detail(student_item_dict,
5453
answer,
5554
queue_name: str,
@@ -58,7 +57,7 @@ def create_external_grader_detail(student_item_dict,
5857
**external_grader_additional_data
5958
):
6059
"""
61-
Creates a submission and an associated ExternalGraderDetail record.
60+
Creates a submission and an associated ExternalGraderDetail record with optional file attachments.
6261
6362
Args:
6463
student_item_dict (dict): The student_item this submission is associated with.
@@ -73,13 +72,28 @@ def create_external_grader_detail(student_item_dict,
7372
points_possible (int, optional): The maximum possible points for this submission. Defaults to 1.
7473
7574
external_grader_additional_data: Additional keyword arguments that may be used for the external grader.
75+
If a 'files' dictionary is included, files will be processed and stored as SubmissionFile objects.
76+
The 'files' dictionary should map filenames to file objects (typically FileObjForWebobFiles).
7677
7778
Returns:
7879
ExternalGraderDetail: The created external grader detail record that references the submission.
80+
Any processed files are accessible through the `files` related_name on this object.
7981
8082
Raises:
8183
ExternalGraderQueueEmptyError: If queue_name is empty.
8284
SubmissionInternalError: If there's an error creating the submission or external grader detail.
85+
86+
Example:
87+
>>> files = {'assignment.py': file_obj}
88+
>>> external_grader = create_external_grader_detail(
89+
... student_item_dict,
90+
... answer,
91+
... 'python_grader',
92+
... files=files
93+
... )
94+
>>> # Access files through external_grader.files.all()
95+
>>> # Get URLs for external systems:
96+
>>> urls = {f.original_filename: f.xqueue_url for f in external_grader.files.all()}
8397
"""
8498

8599
submission = create_submission(student_item_dict, answer)
@@ -93,9 +107,20 @@ def create_external_grader_detail(student_item_dict,
93107
submission_uuid=submission_uuid,
94108
queue_name=queue_name,
95109
grader_file_name=grader_file_name,
96-
points_possible=points_possible,
110+
points_possible=points_possible)
111+
112+
files_dict = external_grader_additional_data.get('files')
113+
if files_dict:
114+
115+
files_urls = {}
116+
for filename, file_obj in files_dict.items():
117+
submission_file = SubmissionFile.objects.create(
118+
external_grader=instance,
119+
file=file_obj.file,
120+
original_filename=filename
121+
)
122+
files_urls[filename] = submission_file.xqueue_url
97123

98-
)
99124
return instance
100125

101126
except DatabaseError as error:
@@ -106,6 +131,16 @@ def create_external_grader_detail(student_item_dict,
106131
raise SubmissionInternalError(error_message) from error
107132

108133

134+
def get_files_for_grader(external_grader):
135+
"""
136+
Returns files in format expected by xqueue-watcher when is calling by get_submission endpoint.
137+
"""
138+
return {
139+
file.original_filename: file.file.url
140+
for file in external_grader.files.all()
141+
}
142+
143+
109144
def create_submission(
110145
student_item_dict,
111146
answer,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 4.2.19 on 2025-05-15 14:14
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import django.utils.timezone
6+
import submissions.models
7+
import uuid
8+
9+
10+
class Migration(migrations.Migration):
11+
12+
dependencies = [
13+
('submissions', '0004_externalgraderdetail'),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='SubmissionFile',
19+
fields=[
20+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21+
('uuid', models.UUIDField(default=uuid.uuid4, editable=False)),
22+
('file', models.FileField(max_length=512, storage=submissions.models.get_storage,
23+
upload_to=submissions.models.submission_file_path)),
24+
('original_filename', models.CharField(max_length=255)),
25+
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
26+
('external_grader', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,
27+
related_name='files', to='submissions.externalgraderdetail')),
28+
],
29+
options={
30+
'indexes': [models.Index(fields=['external_grader', 'uuid'], name='submissions_externa_ff8089_idx')],
31+
},
32+
),
33+
]

submissions/models.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,19 @@
99
./manage.py makemigrations submissions
1010
"""
1111

12+
import functools
1213
import logging
14+
import os
1315
from datetime import timedelta
1416
from uuid import uuid4
1517

1618
from django.conf import settings
1719
from django.contrib import auth
20+
from django.core.files.storage import default_storage
1821
from django.db import DatabaseError, models, transaction
1922
from django.db.models.signals import post_save, pre_save
2023
from django.dispatch import Signal, receiver
24+
from django.utils.module_loading import import_string
2125
from django.utils.timezone import now
2226
from jsonfield import JSONField
2327
from model_utils.models import TimeStampedModel
@@ -693,3 +697,113 @@ def update_status(self, new_status):
693697
def create_from_uuid(cls, submission_uuid, **kwargs):
694698
submission = Submission.objects.get(uuid=submission_uuid)
695699
return cls.objects.create(submission=submission, **kwargs)
700+
701+
702+
def submission_file_path(instance, _):
703+
"""
704+
Generate file path for submission files.
705+
Format: queue_name/uuid
706+
The filename is replaced with the UUID to ensure uniqueness without preserving extension.
707+
"""
708+
return os.path.join(
709+
instance.external_grader.queue_name,
710+
f"{instance.uuid}"
711+
)
712+
713+
714+
@functools.cache
715+
def _get_storage_cached():
716+
"""
717+
Cached implementation to get the configured storage backend.
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.
725+
"""
726+
edx_submissions_config = getattr(settings, 'EDX_SUBMISSIONS', {})
727+
storage_config = edx_submissions_config.get('MEDIA')
728+
729+
if storage_config:
730+
storage_cls = import_string(storage_config['BACKEND'])
731+
options = storage_config.get('OPTIONS', {})
732+
return storage_cls(**options)
733+
734+
return default_storage
735+
736+
737+
def get_storage():
738+
"""
739+
Get the configured storage backend or fallback to default storage.
740+
741+
This function checks for a storage configuration in the Django settings.
742+
It first looks for 'MEDIA' in the 'EDX_SUBMISSIONS' configuration dictionary.
743+
744+
The function uses an internal cached implementation while remaining
745+
serializable for Django migrations, avoiding "Cannot serialize" errors.
746+
747+
Returns:
748+
Storage instance: Returns the configured storage if found in EDX_SUBMISSIONS['MEDIA'],
749+
otherwise returns Django's default_storage.
750+
751+
Example:
752+
# In settings.py
753+
EDX_SUBMISSIONS = {
754+
'MEDIA': {
755+
'BACKEND': 'storages.backends.s3boto3.S3Boto3Storage',
756+
'OPTIONS': {
757+
'bucket_name': 'my-bucket'
758+
}
759+
}
760+
}
761+
762+
# Then get_storage() will return an S3Boto3Storage instance
763+
"""
764+
return _get_storage_cached() # For performance while keeping this function serializable for migrations
765+
766+
767+
class SubmissionFile(models.Model):
768+
"""
769+
Model to handle files associated with submissions
770+
"""
771+
uuid = models.UUIDField(default=uuid4, editable=False) # legacy S3 key
772+
external_grader = models.ForeignKey(
773+
'submissions.ExternalGraderDetail',
774+
on_delete=models.SET_NULL,
775+
related_name='files',
776+
null=True,
777+
)
778+
file = models.FileField(
779+
upload_to=submission_file_path,
780+
max_length=512,
781+
storage=get_storage
782+
)
783+
original_filename = models.CharField(max_length=255) # This is necessary to send file name to xqueue-watcher
784+
created_at = models.DateTimeField(default=now)
785+
786+
class Meta:
787+
indexes = [
788+
models.Index(fields=['external_grader', 'uuid']),
789+
]
790+
791+
@property
792+
def xqueue_url(self):
793+
"""
794+
Returns a URL in the XQueue-compatible format: /queue_name/uuid
795+
796+
This format is used for file references in both the legacy XQueue system
797+
and the new integrated standard. It maintains backward compatibility
798+
while supporting the migration from the external XQueue API to the
799+
integrated Open edX solution.
800+
801+
The URL follows the pattern: /{queue_name}/{submission_uuid}
802+
where:
803+
- queue_name: identifies the external grader queue
804+
- uuid: uniquely identifies this submission (legacy S3 key)
805+
806+
Returns:
807+
str: Formatted URL path following XQueue conventions
808+
"""
809+
return f"/{self.external_grader.queue_name}/{self.uuid}"

0 commit comments

Comments
 (0)