From bd7a08a538fb89c7e4cef70f3d71e3a459082ed2 Mon Sep 17 00:00:00 2001 From: Leonardo Beroes Date: Tue, 20 May 2025 09:21:35 -0400 Subject: [PATCH 1/4] feat: implement XqueueViewSet with full xqueue-watcher compatibility - Add XqueueViewSet with complete xqueue-watcher service compatibility - Implement get_submission service for retrieving pending submissions - Add put_result service with row-level locking (select_for_update(nowait=True)) to prevent race conditions - Ensure concurrent xqueue-watcher instances process each submission exactly once, even under high load - Implement standardized response format for backward compatibility - Add session management and authentication handling for XWatcher clients - Add comprehensive test coverage for core interactions --- ...-views-for-external-grader-integration.rst | 143 +++++ submissions/api.py | 2 +- submissions/serializers.py | 17 +- submissions/tests/factories.py | 17 + submissions/tests/test_viewsets.py | 491 ++++++++++++++++++ submissions/urls.py | 15 + submissions/{views.py => views/__init__.py} | 0 submissions/views/xqueue.py | 306 +++++++++++ 8 files changed, 989 insertions(+), 2 deletions(-) create mode 100644 docs/source/decisions/0003-implementation-of-views-for-external-grader-integration.rst create mode 100644 submissions/tests/test_viewsets.py create mode 100644 submissions/urls.py rename submissions/{views.py => views/__init__.py} (100%) create mode 100644 submissions/views/xqueue.py diff --git a/docs/source/decisions/0003-implementation-of-views-for-external-grader-integration.rst b/docs/source/decisions/0003-implementation-of-views-for-external-grader-integration.rst new file mode 100644 index 00000000..a8017cbe --- /dev/null +++ b/docs/source/decisions/0003-implementation-of-views-for-external-grader-integration.rst @@ -0,0 +1,143 @@ +3. Implementation of XQueue Compatible Views for External Grader Integration +############################################################################ + +Status +****** + +**Provisional** *2025-02-21* + +Implemented by https://github.com/openedx/edx-submissions/pull/284 + +Context +******* + +Following the creation of ExternalGraderDetail (ADR 1) and SubmissionFile (ADR 2) models, we need to implement the API +endpoints that will allow external graders (XWatcher) to interact with the system. The current XQueue implementation +provides three critical endpoints that need to be replicated: + +1. Authentication Service: + - Secure login mechanism for external graders + - Session management + - CSRF handling for specific endpoints + +2. Submission Retrieval (get_submission): + - Queue-based submission distribution + - Status tracking and locking mechanism + - File information packaging for graders + +3. Result Processing (put_result): + - Score validation and processing + - Status updates + - Error handling and retry mechanisms + +The current XQueue implementation has these services spread across multiple systems, requiring complex HTTP communication +and session management. The existing workflow: + +1. Authentication Flow: + - Basic username/password authentication + - Session-based token management + - Manual CSRF handling for specific endpoints + +2. Submission Processing: + - Manual queue status checks + - Complex state transitions + - Synchronous HTTP-based file retrieval + +3. Result Handling: + - Direct database updates + - Limited error recovery + - Complex retry logic + +Decision +******** + +We will implement a unified service approach that brings together all the functionality needed for external grader integration. This approach will: + +1. Simplify Authentication: + - Create a more straightforward login process for external graders + - Make session handling more reliable + - Ensure security while reducing technical complexity + +2. Improve Submission Handling: + - Create a more efficient way to distribute submissions to graders + - Track the status of submissions more reliably + - Provide all necessary information to graders in a consistent format + +3. Enhance Results Processing: + - Process scores more reliably + - Handle errors gracefully + - Provide better feedback to both graders and the platform + +The solution will be built as a consolidated service that external graders can interact with through standard REST API endpoints. This will make integration easier for external services while providing better monitoring and control for the platform. + +Key Benefits: + +1. External graders will have a single, consistent interface +2. The platform will have better visibility into the grading process +3. Error handling will be improved across the entire workflow +4. Security will be enhanced through better authentication practices + +This approach connects directly with the previously created data models for external graders and submission files, providing a complete end-to-end solution for the grading workflow. + +Consequences +************ + +Positive: +--------- + +1. Architecture: + - Consolidated service endpoints + - Clean separation of concerns + - Improved error handling + - Better session management + +2. Security: + - Robust authentication + - Secure file handling + - Protected state transitions + +3. Operations: + - Simplified deployment + - Better monitoring capabilities + - Improved error visibility + - Automatic retry handling + +Negative: +--------- + +1. Complexity: + - More complex session management + - Additional state validation required + - Complex transaction handling + +2. Performance: + - Additional database operations + - Session verification overhead + +3. Migration: + - Changes required in external graders + - New deployment procedures needed + +References +********** + +Implementation References: + +* XQueue ViewSet Implementation: Link to PR +* External Grader Integration Guide: Link to documentation + +Related ADRs: + +* ADR 1: Creation of ExternalGraderDetail Model +* ADR 2: File Handling Implementation + +Documentation: + +* XQueue API Specification +* External Grader Integration Guide +* Session Management Documentation + +Architecture Guidelines: + +* Django REST Framework Best Practices +* Open edX API Guidelines \ No newline at end of file diff --git a/submissions/api.py b/submissions/api.py index 40f46985..357d5be6 100644 --- a/submissions/api.py +++ b/submissions/api.py @@ -13,7 +13,7 @@ from django.db import DatabaseError, IntegrityError, transaction # SubmissionError imported so that code importing this api has access -from submissions.errors import ( # pylint: disable=unused-import +from submissions.errors import ( ExternalGraderQueueEmptyError, SubmissionInternalError, SubmissionNotFoundError, diff --git a/submissions/serializers.py b/submissions/serializers.py index 4d8610af..aa0cdeda 100644 --- a/submissions/serializers.py +++ b/submissions/serializers.py @@ -8,7 +8,7 @@ from rest_framework import serializers from rest_framework.fields import DateTimeField, Field, IntegerField -from submissions.models import Score, ScoreAnnotation, StudentItem, Submission, TeamSubmission +from submissions.models import ExternalGraderDetail, Score, ScoreAnnotation, StudentItem, Submission, TeamSubmission class RawField(Field): @@ -221,3 +221,18 @@ class Meta: 'submission_uuid', 'annotations', ) + + +class SubmissionListSerializer(serializers.ModelSerializer): + class Meta: + model = Submission + fields = '__all__' + + +class ExternalGraderDetailSerializer(serializers.ModelSerializer): + """ Serializer for ExternalGraderDetail """ + submission = SubmissionListSerializer(read_only=True) + + class Meta: + model = ExternalGraderDetail + fields = '__all__' diff --git a/submissions/tests/factories.py b/submissions/tests/factories.py index 43c63a6e..519babd2 100644 --- a/submissions/tests/factories.py +++ b/submissions/tests/factories.py @@ -4,11 +4,13 @@ import factory from django.contrib import auth +from django.utils import timezone from django.utils.timezone import now from factory.django import DjangoModelFactory from pytz import UTC from submissions import models +from submissions.models import ExternalGraderDetail User = auth.get_user_model() @@ -71,3 +73,18 @@ class Meta: item_id = factory.Faker('sha1') team_id = factory.Faker('sha1') submitted_by = factory.SubFactory(UserFactory) + + +class ExternalGraderDetailFactory(DjangoModelFactory): + """ + Factory for the ExternalGraderDetail model. + """ + class Meta: + model = ExternalGraderDetail + + submission = factory.SubFactory(SubmissionFactory) + pullkey = factory.Sequence(lambda n: f'test_pull_key_{n}') + status = 'pending' + num_failures = 0 + grader_reply = '' + status_time = factory.LazyFunction(timezone.now) diff --git a/submissions/tests/test_viewsets.py b/submissions/tests/test_viewsets.py new file mode 100644 index 00000000..914777fb --- /dev/null +++ b/submissions/tests/test_viewsets.py @@ -0,0 +1,491 @@ +""" +Tests for XQueue API views. +""" +import json +import uuid +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.core.files.base import ContentFile +from django.test import override_settings +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.test import APITestCase + +from submissions.models import ExternalGraderDetail, SubmissionFile +from submissions.tests.factories import ExternalGraderDetailFactory, SubmissionFactory +from submissions.views.xqueue import XqueueViewSet + +User = get_user_model() + + +@override_settings(ROOT_URLCONF='submissions.urls') +class TestXqueueViewSet(APITestCase): + """ + Test cases for XqueueViewSet endpoints. + """ + + def setUp(self): + """Set up test data.""" + super().setUp() + self.user = User.objects.create_user( + username='testuser', + password='testpass' + ) + self.submission = SubmissionFactory() + self.external_grader = ExternalGraderDetailFactory( + submission=self.submission, + pullkey='test_pull_key', + status='pending', + queue_name='test_queue', + num_failures=0 + ) + self.url = reverse('xqueue-put_result') + self.url_login = reverse('xqueue-login') + self.url_logout = reverse('xqueue-logout') + self.url_status = reverse('xqueue-status') + self.get_submission_url = reverse('xqueue-get_submission') + self.viewset = XqueueViewSet() + + def test_get_submission_missing_queue_name(self): + """Test error when queue_name parameter is missing.""" + self.client.login(username='testuser', password='testpass') + response = self.client.get(self.get_submission_url) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + self.viewset.compose_reply(False, "'get_submission' must provide parameter 'queue_name'") + ) + + def test_get_submission_queue_empty(self): + """Test error when the specified queue is empty.""" + queue_name = 'empty_queue' + self.client.login(username='testuser', password='testpass') + ExternalGraderDetail.objects.filter(queue_name=queue_name).delete() + response = self.client.get(self.get_submission_url, {'queue_name': queue_name}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual( + response.data, + self.viewset.compose_reply(False, f"Queue '{queue_name}' is empty") + ) + + @patch('submissions.views.xqueue.timezone.now', return_value=timezone.now()) + @patch('submissions.views.xqueue.uuid.uuid4', return_value=str(uuid.uuid4())) + def test_get_submission_success(self, mock_uuid, _): + """Test successfully retrieving a submission from the queue.""" + queue_name = 'prueba' + self.client.login(username='testuser', password='testpass') + new_submission = SubmissionFactory() + external_grader = ExternalGraderDetailFactory( + queue_name=queue_name, + status='pending', + submission=new_submission + ) + + response = self.client.get(self.get_submission_url, {'queue_name': queue_name}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = json.loads(response.data['content']) + self.assertEqual(response.data['return_code'], 0) + + xqueue_header = json.loads(content['xqueue_header']) + xqueue_body = json.loads(content['xqueue_body']) + self.assertEqual(xqueue_header['submission_id'], new_submission.id) + self.assertEqual(xqueue_header['submission_key'], str(mock_uuid.return_value)) + self.assertEqual(xqueue_body['student_response'], new_submission.answer) + self.assertEqual(content['xqueue_files'], '{}') + + external_grader.refresh_from_db() + self.assertEqual(external_grader.status, 'pulled') + self.assertEqual(external_grader.pullkey, str(mock_uuid.return_value)) + self.assertIsNotNone(external_grader.status_time) + + @patch('submissions.views.xqueue.ExternalGraderDetail.update_status', + side_effect=ValueError('Invalid transition')) + def test_get_submission_invalid_transition(self, mock_update_status): + """Test get_submission when there is an invalid state transition (ValueError).""" + queue_name = 'prueba' + data = { + 'username': 'testuser', + 'password': 'testpass' + } + response = self.client.post(self.url_login, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + new_submission = SubmissionFactory() + external_grader = ExternalGraderDetailFactory( + submission=new_submission, + queue_name=queue_name, + status='pending' + ) + + response = self.client.get(self.get_submission_url, {'queue_name': queue_name}) + mock_update_status.assert_called_once_with('pulled') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + self.viewset.compose_reply(False, "Error processing submission: Invalid transition") + ) + + external_grader.refresh_from_db() + self.assertEqual(external_grader.status, 'pending') + + def test_put_result_invalid_submission_id(self): + """Test put_result with non-existent submission ID.""" + self.client.login(username='testuser', password='testpass') + response = self.client.post(self.url_status) + self.assertEqual(response.status_code, status.HTTP_200_OK) + payload = { + 'xqueue_header': json.dumps({ + 'submission_id': 99999, + 'submission_key': 'test_pull_key' + }), + 'xqueue_body': json.dumps({'score': 0.8}) + } + + response = self.client.post(self.url, payload, format='json') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + response_data = json.loads(response.content) + self.assertEqual( + response_data, + self.viewset.compose_reply(False, 'Submission does not exist') + ) + + def test_put_result_invalid_key(self): + """Test put_result with incorrect submission key.""" + self.client.login(username='testuser', password='testpass') + response = self.client.post(self.url_status) + self.assertEqual(response.status_code, status.HTTP_200_OK) + payload = { + 'xqueue_header': json.dumps({ + 'submission_id': self.submission.id, + 'submission_key': 'wrong_key' + }), + 'xqueue_body': json.dumps({'score': 0.8}) + } + + response = self.client.post(self.url, payload, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + response_data = json.loads(response.content) + self.assertEqual( + response_data, + self.viewset.compose_reply(False, 'Incorrect key for submission') + ) + + def test_put_result_invalid_format(self): + """Test put_result with malformed request data.""" + self.client.login(username='testuser', password='testpass') + response = self.client.post(self.url_status) + self.assertEqual(response.status_code, status.HTTP_200_OK) + invalid_payloads = [ + {}, + {'xqueue_header': 'not_json'}, + {'xqueue_header': '{}', 'xqueue_body': 'not_json'}, + {'xqueue_body': '{}'}, + {'xqueue_header': '{}'}, + ] + + for payload in invalid_payloads: + response = self.client.post(self.url, payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = json.loads(response.content) + self.assertEqual( + response_data, + self.viewset.compose_reply(False, 'Incorrect reply format') + ) + + def test_put_result_set_score_failure(self): + """ + Test put_result handling when set_score fails. + """ + data = { + 'username': 'testuser', + 'password': 'testpass' + } + response = self.client.post(self.url_login, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = self.client.post(self.url_status) + self.assertEqual(response.status_code, status.HTTP_200_OK) + payload = { + 'xqueue_header': json.dumps({ + 'submission_id': self.submission.id, + 'submission_key': 'test_pull_key' + }), + 'xqueue_body': json.dumps({'score': 0.8}) + } + + with patch('submissions.api.set_score') as mock_set_score: + mock_set_score.side_effect = Exception('Test error') + response = self.client.post(self.url, payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.external_grader.refresh_from_db() + self.assertEqual(self.external_grader.num_failures, 1) + self.assertEqual(self.external_grader.status, 'pending') + + def test_put_result_set_score_fail_30_times(self): + """ + Test put_result handling when set_score by intentionally failing 30 times. + """ + self.client.login(username='testuser', password='testpass') + response = self.client.post(self.url_status) + self.assertEqual(response.status_code, status.HTTP_200_OK) + payload = { + 'xqueue_header': json.dumps({ + 'submission_id': self.submission.id, + 'submission_key': 'test_pull_key' + }), + 'xqueue_body': json.dumps({'score': 0.8}) + } + + for each in range(31): + with patch('submissions.api.set_score') as mock_set_score: + mock_set_score.side_effect = Exception('Test error') + response = self.client.post(self.url, payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.external_grader.refresh_from_db() + self.assertEqual(self.external_grader.num_failures, each+1) + + self.assertEqual(self.external_grader.status, 'failed') + + def test_put_result_auto_retire(self): + """ + Test submission auto-retirement after too many failures. + """ + self.client.login(username='testuser', password='testpass') + response = self.client.post(self.url_status) + self.assertEqual(response.status_code, status.HTTP_200_OK) + initial_failures = 29 + self.external_grader.num_failures = initial_failures + self.external_grader.save() + + payload = { + 'xqueue_header': json.dumps({ + 'submission_id': self.submission.id, + 'submission_key': 'test_pull_key' + }), + 'xqueue_body': json.dumps({'score': 8}) + } + + for _ in range(2): + with patch('submissions.api.set_score') as mock_set_score: + mock_set_score.side_effect = Exception('Test error') + _ = self.client.post(self.url, payload, format='json') + self.external_grader.refresh_from_db() + + self.external_grader.refresh_from_db() + + @patch('submissions.views.xqueue.log') + def test_logging(self, mock_log): + """ + Test that appropriate logging occurs in various scenarios. + """ + payload = { + 'xqueue_header': json.dumps({ + 'submission_id': self.submission.id, + 'submission_key': 'test_pull_key' + }), + 'xqueue_body': json.dumps({'score': 8}) + } + + with patch('submissions.api.set_score') as mock_set_score: + self.client.login(username='testuser', password='testpass') + response = self.client.post(self.url_status) + self.assertEqual(response.status_code, status.HTTP_200_OK) + mock_set_score.return_value = True + self.client.post(self.url, payload, format='json') + + mock_log.info.assert_called_with( + "Successfully updated submission score for submission %s", + self.submission.id + ) + + def test_get_permissions_login(self): + """Test permissions for login endpoint""" + viewset = XqueueViewSet() + viewset.action = 'login' + permissions = viewset.get_permissions() + self.assertTrue(any(isinstance(p, AllowAny) for p in permissions)) + + def test_get_permissions_other_actions(self): + """Test permissions for non-login endpoints""" + viewset = XqueueViewSet() + viewset.action = 'logout' + permissions = viewset.get_permissions() + self.assertTrue(all(isinstance(p, IsAuthenticated) for p in permissions)) + + def test_dispatch_valid_session(self): + """Test dispatch with valid session""" + self.client.login(username='testuser', password='testpass') + response = self.client.post(self.url_status) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_dispatch_invalid_session(self): + """Test dispatch with invalid session""" + # Create invalid session cookie + self.client.cookies['sessionid'] = 'invalid_session_id' + response = self.client.get(self.url_status) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_login_success(self): + """Test successful login""" + data = { + 'username': 'testuser', + 'password': 'testpass' + } + response = self.client.post(self.url_login, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['return_code'], 0) + self.assertIn('sessionid', response.cookies) + + def test_login_missing_credentials(self): + """Test login with missing credentials""" + response = self.client.post(self.url_login, {}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['return_code'], 1) + + def test_login_invalid_credentials(self): + """Test login with invalid credentials""" + data = { + 'username': 'testuser', + 'password': 'wrongpass' + } + response = self.client.post(self.url_login, data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response.data['return_code'], 1) + + def test_logout(self): + """Test logout functionality""" + self.client.login(username='testuser', password='testpass') + response = self.client.post(self.url_logout) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['return_code'], 0) + + def test_validate_grader_reply_valid(self): + """Test _validate_grader_reply with valid data""" + viewset = XqueueViewSet() + external_reply = { + 'xqueue_header': json.dumps({ + 'submission_id': 123, + 'submission_key': 'test_key' + }), + 'xqueue_body': json.dumps({ + 'score': 0.8 + }) + } + valid, sub_id, sub_key, _, points = viewset.validate_grader_reply(external_reply) + self.assertTrue(valid) + self.assertEqual(sub_id, 123) + self.assertEqual(sub_key, 'test_key') + self.assertEqual(points, 0.8) + + def test_validate_grader_reply_invalid(self): + """Test _validate_grader_reply with invalid data""" + viewset = XqueueViewSet() + invalid_replies = [ + None, + {}, + {'xqueue_header': 42, 'xqueue_body': json.dumps({'score': 0.8})}, + {'xqueue_header': 'invalid_json'}, + {'xqueue_header': '{}', 'xqueue_body': 'invalid_json'}, + {'xqueue_header': json.dumps({}), 'xqueue_body': '{}'}, + {'xqueue_header': json.dumps(["item1", "item2", "item3"]), 'xqueue_body': json.dumps({'score': 0.8})} + ] + for reply in invalid_replies: + valid, *_ = viewset.validate_grader_reply(reply) + self.assertFalse(valid) + + def test_compose_reply(self): + """Test _compose_reply method""" + viewset = XqueueViewSet() + success_reply = viewset.compose_reply(True, "Success message") + self.assertEqual(success_reply['return_code'], 0) + self.assertEqual(success_reply['content'], "Success message") + + error_reply = viewset.compose_reply(False, "Error message") + self.assertEqual(error_reply['return_code'], 1) + self.assertEqual(error_reply['content'], "Error message") + + def test_status_endpoint(self): + """Test status endpoint""" + self.client.login(username='testuser', password='testpass') + response = self.client.post(self.url_status) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.content.decode('utf-8') + self.assertIn('return_code', content) + self.assertIn('content', content) + + def test_get_submission_with_files(self): + """Test successfully retrieving a submission with attached files from the queue.""" + self.client.login(username='testuser', password='testpass') + + file_content = b'Test file content' + submission_file = SubmissionFile.objects.create( + external_grader=self.external_grader, + file=ContentFile(file_content, name='test.txt'), + original_filename='test.txt' + ) + + response = self.client.get(self.get_submission_url, {'queue_name': 'test_queue'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = json.loads(response.data['content']) + + xqueue_files = json.loads(content['xqueue_files']) + self.assertIn('test.txt', xqueue_files) + expected_url = f'/test_queue/{submission_file.uuid}' + self.assertEqual(xqueue_files['test.txt'], expected_url) + + def test_get_submission_with_multiple_files(self): + """Test retrieving a submission with multiple attached files.""" + self.client.login(username='testuser', password='testpass') + + files_data = [ + ('file1.txt', b'Content 1'), + ('file2.txt', b'Content 2'), + ('file3.txt', b'Content 3') + ] + + created_files = [] + for filename, content in files_data: + submission_file = SubmissionFile.objects.create( + external_grader=self.external_grader, + file=ContentFile(content, name=filename), + original_filename=filename + ) + created_files.append(submission_file) + + response = self.client.get(self.get_submission_url, {'queue_name': 'test_queue'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = json.loads(response.data['content']) + xqueue_files = json.loads(content['xqueue_files']) + + self.assertEqual(len(xqueue_files), len(files_data)) + for file_obj, (filename, _) in zip(created_files, files_data): + self.assertIn(filename, xqueue_files) + expected_url = f'/test_queue/{file_obj.uuid}' + self.assertEqual(xqueue_files[filename], expected_url) + + def test_get_submission_file_urls_format(self): + """Test that file URLs in the response follow the expected format.""" + self.client.login(username='testuser', password='testpass') + + test_uuid = uuid.uuid4() + _ = SubmissionFile.objects.create( + external_grader=self.external_grader, + file=ContentFile(b'content', name='test.txt'), + original_filename='test.txt', + uuid=test_uuid + ) + + response = self.client.get(self.get_submission_url, {'queue_name': 'test_queue'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = json.loads(response.data['content']) + xqueue_files = json.loads(content['xqueue_files']) + + expected_url = f'/test_queue/{test_uuid}' + self.assertEqual(xqueue_files['test.txt'], expected_url) diff --git a/submissions/urls.py b/submissions/urls.py new file mode 100644 index 00000000..de6321d4 --- /dev/null +++ b/submissions/urls.py @@ -0,0 +1,15 @@ +""" +Submission URLs. +""" + +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from submissions.views.xqueue import XqueueViewSet + +router = DefaultRouter() +router.register(r'', XqueueViewSet, basename='xqueue') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/submissions/views.py b/submissions/views/__init__.py similarity index 100% rename from submissions/views.py rename to submissions/views/__init__.py diff --git a/submissions/views/xqueue.py b/submissions/views/xqueue.py new file mode 100644 index 00000000..f5f6d577 --- /dev/null +++ b/submissions/views/xqueue.py @@ -0,0 +1,306 @@ +"""Xqueue View set""" + +import json +import logging +import uuid + +from django.contrib.auth import authenticate, login, logout +from django.db import transaction +from django.http import HttpResponse +from django.utils import timezone +from rest_framework import status, viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.decorators import action +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response + +from submissions.api import get_files_for_grader, set_score +from submissions.models import ExternalGraderDetail + +log = logging.getLogger(__name__) + + +class XQueueSessionAuthentication(SessionAuthentication): + def enforce_csrf(self, request): + if 'put_result' in request.path: + return None + return super().enforce_csrf(request) + + +class XqueueViewSet(viewsets.ViewSet): + """ + A collection of services for xwatcher interactions and authentication. + + This ViewSet provides endpoints for managing external grader results + and handling user authentication in the system. + + Key features: + - Handles validation of external grader responses + - Processes and updates submission scores + - Provides a secure endpoint for result updates + - Manages user authentication and session handling + + Endpoints: + - put_result: Endpoint for graders to submit their assessment results + - login: Endpoint for user authentication + - logout: Endpoint for ending user sessions + """ + + authentication_classes = [XQueueSessionAuthentication] + + def get_permissions(self): + """ + Override to implement custom permission logic per action. + - Login endpoint is public + - All other endpoints require authentication + """ + if self.action == 'login': + permission_classes = [AllowAny] + else: + permission_classes = [IsAuthenticated] + return [permission() for permission in permission_classes] + + @action(detail=False, methods=['post'], url_name='login') + def login(self, request): + """ + Endpoint for authenticating users and creating sessions. + """ + log.info(f"Login attempt with data: {request.data}") + + if 'username' not in request.data or 'password' not in request.data: + return Response( + {'return_code': 1, 'content': 'Insufficient login info'}, + status=status.HTTP_400_BAD_REQUEST + ) + + user = authenticate( + request, + username=request.data['username'], + password=request.data['password'] + ) + + if user is not None: + login(request, user) + response = Response( + {'return_code': 0, 'content': 'Logged in'}, + status=status.HTTP_200_OK + ) + + return response + + return Response( + {'return_code': 1, 'content': 'Incorrect login credentials'}, + status=status.HTTP_401_UNAUTHORIZED + ) + + @action(detail=False, methods=['post'], url_name='logout') + def logout(self, request): + """ + Endpoint for ending user sessions. + """ + logout(request) + return Response( + self.compose_reply(True, 'Goodbye'), + status=status.HTTP_200_OK + ) + + @action(detail=False, methods=['get'], url_name='get_submission') + @transaction.atomic + def get_submission(self, request): + """ + Endpoint for retrieving a single unprocessed submission from a specified queue. + Query Parameters: + - queue_name (required): Name of the queue to pull from + Returns: + - Submission data with pull information if successful + - Error message if queue is empty or invalid + """ + queue_name = request.query_params.get('queue_name') + + if not queue_name: + return Response( + self.compose_reply(False, "'get_submission' must provide parameter 'queue_name'"), + status=status.HTTP_400_BAD_REQUEST + ) + + submission_record = ExternalGraderDetail.objects.filter( + queue_name=queue_name, + status__in=['pending'] + ).select_related('submission').order_by('status_time').first() + + if not submission_record: + return Response( + self.compose_reply(False, f"Queue '{queue_name}' is empty"), + status=status.HTTP_404_NOT_FOUND + ) + + try: + pull_time = timezone.now() + pullkey = str(uuid.uuid4()) + submission_record.update_status('pulled') + submission_record.pullkey = pullkey + submission_record.status_time = pull_time + submission_record.save(update_fields=['pullkey', 'status_time']) + + ext_header = { + 'submission_id': submission_record.submission.id, + 'submission_key': pullkey + } + answer = submission_record.submission.answer + submission_data = { + "grader_payload": json.dumps({ + "grader": "" + + }), + "student_info": json.dumps({ + "anonymous_student_id": str(submission_record.submission.uuid), + "submission_time": str(int(submission_record.created_at.timestamp())), + "random_seed": 1 + }), + "student_response": answer + } + + payload = { + 'xqueue_header': json.dumps(ext_header), + 'xqueue_body': json.dumps(submission_data), + 'xqueue_files': json.dumps(get_files_for_grader(submission_record)) + } + + return Response( + self.compose_reply(True, content=json.dumps(payload)), + status=status.HTTP_200_OK + ) + + except ValueError as e: + return Response( + self.compose_reply(False, f"Error processing submission: {str(e)}"), + status=status.HTTP_400_BAD_REQUEST + ) + + @action(detail=False, methods=['post'], url_name='put_result') + @transaction.atomic + def put_result(self, request): + """ + Endpoint for graders to post their results and update submission scores. + """ + (reply_valid, submission_id, submission_key, score_msg, points_earned) = ( + self.validate_grader_reply(request.data)) + + if not reply_valid: + log.error("Invalid reply from pull-grader: request.data: %s", + request.data) + return Response( + self.compose_reply(False, 'Incorrect reply format'), + status=status.HTTP_400_BAD_REQUEST + ) + + try: + submission_record = ExternalGraderDetail.objects.select_for_update( + nowait=True).get(submission__id=submission_id) + except ExternalGraderDetail.DoesNotExist: + log.error( + "Grader submission_id refers to nonexistent entry in Submission DB: " + "grader: %s, submission_key: %s, score_msg: %s", + submission_id, + submission_key, + score_msg + ) + return Response( + self.compose_reply(False, 'Submission does not exist'), + status=status.HTTP_404_NOT_FOUND + ) + + if not submission_record.pullkey or submission_key != submission_record.pullkey: + log.error(f"Invalid pullkey: submission key from xwatcher {submission_key} " + f"and submission key stored {submission_record.pullkey} are different") + return Response( + self.compose_reply(False, 'Incorrect key for submission'), + status=status.HTTP_403_FORBIDDEN + ) + + # pylint: disable=broad-exception-caught + try: + log.info("Attempting to set_score...") + set_score(str(submission_record.submission.uuid), + points_earned, + 1 + ) + + submission_record.grader_reply = score_msg + submission_record.status_time = timezone.now() + submission_record.status = "returned" + submission_record.save() + log.info("Successfully updated submission score for submission %s", submission_id) + + except Exception as e: + log.error(f"Error when execute set_score: {e}") + # Keep track of how many times we've failed to set_score a grade for this submission + submission_record.num_failures += 1 + if submission_record.num_failures > 30: + submission_record.status = "failed" + else: + submission_record.status = "pending" + submission_record.save() + + return Response(self.compose_reply(success=True, content='')) + + def validate_grader_reply(self, external_reply): + """ + Validate the format of external grader reply. + + Returns: + tuple: (is_valid, submission_id, submission_key, score_msg) + """ + fail = (False, -1, '', '', '') + + if not isinstance(external_reply, dict): + return fail + + try: + header = external_reply['xqueue_header'] + score_msg = external_reply['xqueue_body'] + except KeyError: + return fail + + try: + header_dict = json.loads(header) + except (TypeError, ValueError): + return fail + + try: + score = json.loads(score_msg) + points_earned = score.get("score") + except (TypeError, ValueError): + return fail + + if not isinstance(header_dict, dict): + return fail + + for tag in ['submission_id', 'submission_key']: + if tag not in header_dict: + return fail + + submission_id = int(header_dict['submission_id']) + submission_key = header_dict['submission_key'] + + return (True, submission_id, submission_key, score_msg, points_earned) + + def compose_reply(self, success, content): + """ + Compose response in Xqueue format. + + Args: + success (bool): Whether the operation was successful + content (str): Response message + + Returns: + dict: Formatted response + """ + return { + 'return_code': 0 if success else 1, + 'content': content + } + + @action(detail=False, methods=['post'], url_name='status') + def status(self, _): + return HttpResponse(self.compose_reply(success=True, content='OK')) From b9672e109037aae95b2bf282f37732615aa24599 Mon Sep 17 00:00:00 2001 From: gabrielC1409 Date: Wed, 19 Mar 2025 17:26:00 -0400 Subject: [PATCH 2/4] feat: Improve transaction handling to prevent concurrent processing and add timeout mechanism (Get Submission) - Implemented locking to ensure submissions are processed by a single xqueue watcher. - Added timeout mechanism for submissions stuck in 'pulled' state. - Updated tests to cover new error scenarios and timeout handling. --- submissions/views/xqueue.py | 118 ++++++++++++++++++++---------------- 1 file changed, 66 insertions(+), 52 deletions(-) diff --git a/submissions/views/xqueue.py b/submissions/views/xqueue.py index f5f6d577..0664f61f 100644 --- a/submissions/views/xqueue.py +++ b/submissions/views/xqueue.py @@ -3,9 +3,11 @@ import json import logging import uuid +from datetime import timedelta from django.contrib.auth import authenticate, login, logout -from django.db import transaction +from django.db import DatabaseError, transaction +from django.db.models import Q from django.http import HttpResponse from django.utils import timezone from rest_framework import status, viewsets @@ -115,6 +117,7 @@ def get_submission(self, request): - Submission data with pull information if successful - Error message if queue is empty or invalid """ + queue_name = request.query_params.get('queue_name') if not queue_name: @@ -123,60 +126,71 @@ def get_submission(self, request): status=status.HTTP_400_BAD_REQUEST ) - submission_record = ExternalGraderDetail.objects.filter( - queue_name=queue_name, - status__in=['pending'] - ).select_related('submission').order_by('status_time').first() - - if not submission_record: - return Response( - self.compose_reply(False, f"Queue '{queue_name}' is empty"), - status=status.HTTP_404_NOT_FOUND - ) - + timeout_threshold = timezone.now() - timedelta(minutes=5) try: - pull_time = timezone.now() - pullkey = str(uuid.uuid4()) - submission_record.update_status('pulled') - submission_record.pullkey = pullkey - submission_record.status_time = pull_time - submission_record.save(update_fields=['pullkey', 'status_time']) - - ext_header = { - 'submission_id': submission_record.submission.id, - 'submission_key': pullkey - } - answer = submission_record.submission.answer - submission_data = { - "grader_payload": json.dumps({ - "grader": "" - - }), - "student_info": json.dumps({ - "anonymous_student_id": str(submission_record.submission.uuid), - "submission_time": str(int(submission_record.created_at.timestamp())), - "random_seed": 1 - }), - "student_response": answer - } - - payload = { - 'xqueue_header': json.dumps(ext_header), - 'xqueue_body': json.dumps(submission_data), - 'xqueue_files': json.dumps(get_files_for_grader(submission_record)) - } - - return Response( - self.compose_reply(True, content=json.dumps(payload)), - status=status.HTTP_200_OK + submission_record = ( + ExternalGraderDetail.objects + .select_for_update(nowait=True) + .filter( + Q(queue_name=queue_name, status='pending') | + Q(queue_name=queue_name, status='pulled', status_time__lt=timeout_threshold) + ) + .select_related('submission') + .order_by('status_time') + .first() ) - - except ValueError as e: + except DatabaseError: return Response( - self.compose_reply(False, f"Error processing submission: {str(e)}"), - status=status.HTTP_400_BAD_REQUEST + self.compose_reply(False, "Submission already in process"), + status=status.HTTP_409_CONFLICT ) + if submission_record: + try: + pull_time = timezone.now() + pullkey = str(uuid.uuid4()) + submission_record.update_status('pulled') + submission_record.pullkey = pullkey + submission_record.status_time = pull_time + submission_record.save(update_fields=['pullkey', 'status_time']) + + submission_data = { + "grader_payload": json.dumps({"grader": submission_record.grader_file_name}), + "student_info": json.dumps({ + "anonymous_student_id": str(submission_record.submission.student_item.student_id), + "submission_time": str(int(submission_record.created_at.timestamp())), + "random_seed": 1 + }), + "student_response": submission_record.submission.answer + } + + file_manager = SubmissionFileManager(submission_record) + + payload = { + 'xqueue_header': json.dumps({ + 'submission_id': submission_record.submission.id, + 'submission_key': pullkey + }), + 'xqueue_body': json.dumps(submission_data), + 'xqueue_files': json.dumps(file_manager.get_files_for_grader()) + } + + return Response( + self.compose_reply(True, content=json.dumps(payload)), + status=status.HTTP_200_OK + ) + + except ValueError as e: + return Response( + self.compose_reply(False, f"Error processing submission: {str(e)}"), + status=status.HTTP_400_BAD_REQUEST + ) + + return Response( + self.compose_reply(False, f"Queue '{queue_name}' is empty"), + status=status.HTTP_404_NOT_FOUND + ) + @action(detail=False, methods=['post'], url_name='put_result') @transaction.atomic def put_result(self, request): @@ -225,10 +239,10 @@ def put_result(self, request): points_earned, 1 ) - + log.info(f"=====> Saving score {score_msg}") submission_record.grader_reply = score_msg submission_record.status_time = timezone.now() - submission_record.status = "returned" + submission_record.update_status('retired') submission_record.save() log.info("Successfully updated submission score for submission %s", submission_id) From e198d564ef933850d654637639f162b8738f7de0 Mon Sep 17 00:00:00 2001 From: Leonardo Beroes Date: Tue, 20 May 2025 14:42:41 -0400 Subject: [PATCH 3/4] feat: Optimize submission retrieval and processing flow - Renamed variables from 'submission_record' to 'external_grader' throughout the codebase for better consistency with model naming - Added new 'retry' status to integrate with submission processing retry services - Removed unused 'is_processable' method that wasn't providing any value - Enhanced test coverage - Add new status external grader detail migration --- ...-views-for-external-grader-integration.rst | 2 +- .../0006_alter_externalgraderdetail_status.py | 20 +++ submissions/models.py | 32 ++--- submissions/serializers.py | 17 +-- submissions/tests/test_models.py | 48 +------ submissions/tests/test_viewsets.py | 136 +++++++++++------- submissions/views/xqueue.py | 72 ++++------ 7 files changed, 147 insertions(+), 180 deletions(-) create mode 100644 submissions/migrations/0006_alter_externalgraderdetail_status.py diff --git a/docs/source/decisions/0003-implementation-of-views-for-external-grader-integration.rst b/docs/source/decisions/0003-implementation-of-views-for-external-grader-integration.rst index a8017cbe..c0cfed7a 100644 --- a/docs/source/decisions/0003-implementation-of-views-for-external-grader-integration.rst +++ b/docs/source/decisions/0003-implementation-of-views-for-external-grader-integration.rst @@ -12,7 +12,7 @@ Context ******* Following the creation of ExternalGraderDetail (ADR 1) and SubmissionFile (ADR 2) models, we need to implement the API -endpoints that will allow external graders (XWatcher) to interact with the system. The current XQueue implementation +endpoints that will allow external graders (xqueue-watcher) to interact with the system. The current XQueue implementation provides three critical endpoints that need to be replicated: 1. Authentication Service: diff --git a/submissions/migrations/0006_alter_externalgraderdetail_status.py b/submissions/migrations/0006_alter_externalgraderdetail_status.py new file mode 100644 index 00000000..8717b9a8 --- /dev/null +++ b/submissions/migrations/0006_alter_externalgraderdetail_status.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.19 on 2025-05-20 18:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0005_submissionfile'), + ] + + operations = [ + migrations.AlterField( + model_name='externalgraderdetail', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), + ('pulled', 'Pulled'), ('retired', 'Retired'), ('failed', 'Failed'), + ('retry', 'Retry')], default='pending', max_length=20), + ), + ] diff --git a/submissions/models.py b/submissions/models.py index 78d3cdd4..a7b8b968 100644 --- a/submissions/models.py +++ b/submissions/models.py @@ -12,6 +12,7 @@ import functools import logging import os +import uuid from datetime import timedelta from uuid import uuid4 @@ -603,15 +604,18 @@ class ExternalGraderDetail(models.Model): """ class Status(models.TextChoices): + """External grader statuses""" PENDING = 'pending', 'Pending' PULLED = 'pulled', 'Pulled' RETIRED = 'retired', 'Retired' FAILED = 'failed', 'Failed' + RETRY = 'retry', 'Retry' VALID_TRANSITIONS = { - 'pending': ['pulled', 'failed'], - 'pulled': ['retired', 'failed'], - 'failed': ['pending'], + 'pending': ['pulled'], + 'pulled': ['retired', 'failed', 'retry'], + 'retry': ['retired', 'pulled'], + 'failed': [], 'retired': [] } submission = models.OneToOneField( @@ -654,20 +658,6 @@ class Meta: ] ordering = ['-created_at'] - @property - def is_processable(self): - """ - Indicates if this submission can be processed based on its current state - and time since last update. - """ - if self.status not in ['pending', 'failed']: - return False - - processing_window = now() - timedelta( - minutes=getattr(settings, 'SUBMISSION_PROCESSING_DELAY', 60) - ) - return self.status_time <= processing_window - def can_transition_to(self, new_status, current_status=None): """Check if the transition to new_status is valid.""" from_status = current_status if current_status is not None else self.status @@ -687,9 +677,15 @@ def update_status(self, new_status): self.status = new_status self.status_time = now() - if new_status == 'failed': + if new_status in ('retry', 'failed'): self.num_failures += 1 self.save(update_fields=['status', 'status_time', 'num_failures']) + + elif new_status == 'pulled': + pullkey = str(uuid.uuid4()) + self.pullkey = pullkey + self.save(update_fields=['status', 'pullkey', 'status_time']) + else: self.save(update_fields=['status', 'status_time']) diff --git a/submissions/serializers.py b/submissions/serializers.py index aa0cdeda..4d8610af 100644 --- a/submissions/serializers.py +++ b/submissions/serializers.py @@ -8,7 +8,7 @@ from rest_framework import serializers from rest_framework.fields import DateTimeField, Field, IntegerField -from submissions.models import ExternalGraderDetail, Score, ScoreAnnotation, StudentItem, Submission, TeamSubmission +from submissions.models import Score, ScoreAnnotation, StudentItem, Submission, TeamSubmission class RawField(Field): @@ -221,18 +221,3 @@ class Meta: 'submission_uuid', 'annotations', ) - - -class SubmissionListSerializer(serializers.ModelSerializer): - class Meta: - model = Submission - fields = '__all__' - - -class ExternalGraderDetailSerializer(serializers.ModelSerializer): - """ Serializer for ExternalGraderDetail """ - submission = SubmissionListSerializer(read_only=True) - - class Meta: - model = ExternalGraderDetail - fields = '__all__' diff --git a/submissions/tests/test_models.py b/submissions/tests/test_models.py index 30ff51c4..7cd90c24 100644 --- a/submissions/tests/test_models.py +++ b/submissions/tests/test_models.py @@ -357,43 +357,9 @@ def test_failure_count(self): self.assertEqual(self.external_grader_detail.num_failures, 0) # Transition to failed status should increment counter - self.external_grader_detail.update_status('failed') - self.assertEqual(self.external_grader_detail.num_failures, 1) - - # Return to pending - self.external_grader_detail.update_status('pending') - - # Another failure should increment counter again - self.external_grader_detail.update_status('failed') - self.assertEqual(self.external_grader_detail.num_failures, 2) - - def test_is_processable(self): - """Test the is_processable property.""" - # New records should not be processable immediately - self.assertFalse(self.external_grader_detail.is_processable) - - # Set status_time to past the processing window - past_time = now() - timedelta(minutes=61) - self.external_grader_detail.status_time = past_time - self.external_grader_detail.save() - - # Should now be processable - self.assertTrue(self.external_grader_detail.is_processable) - - # Failed records should also be processable after window - self.external_grader_detail.update_status('failed') - - # Need to manually set the time again since update_status resets it - self.external_grader_detail.status_time = now() - timedelta(minutes=61) - self.external_grader_detail.save() - - self.assertTrue(self.external_grader_detail.is_processable) - - # Retired records should never be processable - self.external_grader_detail.update_status('pending') self.external_grader_detail.update_status('pulled') - self.external_grader_detail.update_status('retired') - self.assertFalse(self.external_grader_detail.is_processable) + self.external_grader_detail.update_status('retry') + self.assertEqual(self.external_grader_detail.num_failures, 1) def test_status_time_updates(self): """Test that status_time updates with status changes.""" @@ -428,16 +394,6 @@ def test_invalid_status_transitions(self): with self.assertRaises(ValueError): self.external_grader_detail.update_status('pending') - def test_failure_count_increment(self): - """Test failure count increases properly""" - initial_failures = self.external_grader_detail.num_failures - - # Update to failed status - self.external_grader_detail.update_status('failed') - - # Check failure count increased - self.assertEqual(self.external_grader_detail.num_failures, initial_failures + 1) - def test_clean_new_instance(self): """Test clean method for new instances (no pk assigned yet)""" new_record = ExternalGraderDetail( diff --git a/submissions/tests/test_viewsets.py b/submissions/tests/test_viewsets.py index 914777fb..125c9634 100644 --- a/submissions/tests/test_viewsets.py +++ b/submissions/tests/test_viewsets.py @@ -3,10 +3,12 @@ """ import json import uuid +from datetime import timedelta from unittest.mock import patch from django.contrib.auth import get_user_model from django.core.files.base import ContentFile +from django.db import DatabaseError from django.test import override_settings from django.urls import reverse from django.utils import timezone @@ -42,7 +44,7 @@ def setUp(self): queue_name='test_queue', num_failures=0 ) - self.url = reverse('xqueue-put_result') + self.url_put_result = reverse('xqueue-put_result') self.url_login = reverse('xqueue-login') self.url_logout = reverse('xqueue-logout') self.url_status = reverse('xqueue-status') @@ -71,17 +73,19 @@ def test_get_submission_queue_empty(self): self.viewset.compose_reply(False, f"Queue '{queue_name}' is empty") ) - @patch('submissions.views.xqueue.timezone.now', return_value=timezone.now()) - @patch('submissions.views.xqueue.uuid.uuid4', return_value=str(uuid.uuid4())) - def test_get_submission_success(self, mock_uuid, _): + def test_get_submission_success(self): """Test successfully retrieving a submission from the queue.""" queue_name = 'prueba' self.client.login(username='testuser', password='testpass') + + initial_pullkey = 'initial_pullkey' + new_submission = SubmissionFactory() external_grader = ExternalGraderDetailFactory( queue_name=queue_name, status='pending', - submission=new_submission + submission=new_submission, + pullkey=initial_pullkey ) response = self.client.get(self.get_submission_url, {'queue_name': queue_name}) @@ -92,13 +96,13 @@ def test_get_submission_success(self, mock_uuid, _): xqueue_header = json.loads(content['xqueue_header']) xqueue_body = json.loads(content['xqueue_body']) self.assertEqual(xqueue_header['submission_id'], new_submission.id) - self.assertEqual(xqueue_header['submission_key'], str(mock_uuid.return_value)) + response_pullkey = xqueue_header['submission_key'] self.assertEqual(xqueue_body['student_response'], new_submission.answer) self.assertEqual(content['xqueue_files'], '{}') external_grader.refresh_from_db() self.assertEqual(external_grader.status, 'pulled') - self.assertEqual(external_grader.pullkey, str(mock_uuid.return_value)) + self.assertEqual(external_grader.pullkey, response_pullkey) self.assertIsNotNone(external_grader.status_time) @patch('submissions.views.xqueue.ExternalGraderDetail.update_status', @@ -145,7 +149,7 @@ def test_put_result_invalid_submission_id(self): 'xqueue_body': json.dumps({'score': 0.8}) } - response = self.client.post(self.url, payload, format='json') + response = self.client.post(self.url_put_result, payload, format='json') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) response_data = json.loads(response.content) self.assertEqual( @@ -166,7 +170,7 @@ def test_put_result_invalid_key(self): 'xqueue_body': json.dumps({'score': 0.8}) } - response = self.client.post(self.url, payload, format='json') + response = self.client.post(self.url_put_result, payload, format='json') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) response_data = json.loads(response.content) self.assertEqual( @@ -188,7 +192,7 @@ def test_put_result_invalid_format(self): ] for payload in invalid_payloads: - response = self.client.post(self.url, payload, format='json') + response = self.client.post(self.url_put_result, payload, format='json') self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) response_data = json.loads(response.content) self.assertEqual( @@ -208,23 +212,25 @@ def test_put_result_set_score_failure(self): self.assertEqual(response.status_code, status.HTTP_200_OK) response = self.client.post(self.url_status) self.assertEqual(response.status_code, status.HTTP_200_OK) + self.external_grader.update_status('pulled') + payload = { 'xqueue_header': json.dumps({ 'submission_id': self.submission.id, - 'submission_key': 'test_pull_key' + 'submission_key': self.external_grader.pullkey }), 'xqueue_body': json.dumps({'score': 0.8}) } with patch('submissions.api.set_score') as mock_set_score: mock_set_score.side_effect = Exception('Test error') - response = self.client.post(self.url, payload, format='json') + response = self.client.post(self.url_put_result, payload, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.external_grader.refresh_from_db() self.assertEqual(self.external_grader.num_failures, 1) - self.assertEqual(self.external_grader.status, 'pending') + self.assertEqual(self.external_grader.status, 'retry') def test_put_result_set_score_fail_30_times(self): """ @@ -233,58 +239,36 @@ def test_put_result_set_score_fail_30_times(self): self.client.login(username='testuser', password='testpass') response = self.client.post(self.url_status) self.assertEqual(response.status_code, status.HTTP_200_OK) - payload = { - 'xqueue_header': json.dumps({ - 'submission_id': self.submission.id, - 'submission_key': 'test_pull_key' - }), - 'xqueue_body': json.dumps({'score': 0.8}) - } for each in range(31): - with patch('submissions.api.set_score') as mock_set_score: - mock_set_score.side_effect = Exception('Test error') - response = self.client.post(self.url, payload, format='json') + with patch('submissions.api.set_score') as _: + self.external_grader.update_status('pulled') + payload = { + 'xqueue_header': json.dumps({ + 'submission_id': self.submission.id, + 'submission_key': self.external_grader.pullkey, + }), + 'xqueue_body': json.dumps({'score': 0.8}) + } + response = self.client.post(self.url_put_result, payload, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.external_grader.refresh_from_db() - self.assertEqual(self.external_grader.num_failures, each+1) + self.assertEqual(self.external_grader.num_failures, each + 1) self.assertEqual(self.external_grader.status, 'failed') - def test_put_result_auto_retire(self): - """ - Test submission auto-retirement after too many failures. - """ - self.client.login(username='testuser', password='testpass') - response = self.client.post(self.url_status) - self.assertEqual(response.status_code, status.HTTP_200_OK) - initial_failures = 29 - self.external_grader.num_failures = initial_failures - self.external_grader.save() - - payload = { - 'xqueue_header': json.dumps({ - 'submission_id': self.submission.id, - 'submission_key': 'test_pull_key' - }), - 'xqueue_body': json.dumps({'score': 8}) - } - - for _ in range(2): - with patch('submissions.api.set_score') as mock_set_score: - mock_set_score.side_effect = Exception('Test error') - _ = self.client.post(self.url, payload, format='json') - self.external_grader.refresh_from_db() - - self.external_grader.refresh_from_db() - @patch('submissions.views.xqueue.log') - def test_logging(self, mock_log): + def test_put_result_success(self, mock_log): """ Test that appropriate logging occurs in various scenarios. """ + self.submission.external_grader_detail.status = 'pulled' + self.submission.external_grader_detail.save() + self.submission.external_grader_detail.refresh_from_db() + self.assertEqual(self.submission.external_grader_detail.status, 'pulled') + payload = { 'xqueue_header': json.dumps({ 'submission_id': self.submission.id, @@ -298,13 +282,21 @@ def test_logging(self, mock_log): response = self.client.post(self.url_status) self.assertEqual(response.status_code, status.HTTP_200_OK) mock_set_score.return_value = True - self.client.post(self.url, payload, format='json') + response = self.client.post(self.url_put_result, payload, format='json') - mock_log.info.assert_called_with( + mock_log.info.assert_any_call( "Successfully updated submission score for submission %s", self.submission.id ) + response_data = json.loads(response.content) + self.assertEqual( + response_data, + self.viewset.compose_reply(True, '') + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_get_permissions_login(self): """Test permissions for login endpoint""" viewset = XqueueViewSet() @@ -489,3 +481,39 @@ def test_get_submission_file_urls_format(self): expected_url = f'/test_queue/{test_uuid}' self.assertEqual(xqueue_files['test.txt'], expected_url) + + def test_get_submission_db_error(self): + """Test DatabaseError handling when retrieving a submission.""" + self.client.login(username='testuser', password='testpass') + queue_name = 'test_queue' + + with patch( + 'submissions.views.xqueue.ExternalGraderDetail.objects.select_for_update', + side_effect=DatabaseError("Simulated DB Error") + ): + response = self.client.get(self.get_submission_url, {'queue_name': queue_name}) + + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertEqual( + response.data, + self.viewset.compose_reply(False, "Submission already in process") + ) + + def test_get_submission_already_pulled(self): + """Test retrieving a submission that already has a 'pulled' status.""" + queue_name = 'test_queue' + self.client.login(username='testuser', password='testpass') + self.external_grader.update_status('pulled') + self.external_grader.status_time = timezone.now() - timedelta(minutes=6) + self.external_grader.save() + + response = self.client.get(self.get_submission_url, {'queue_name': queue_name}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = json.loads(response.data['content']) + + xqueue_header = json.loads(content['xqueue_header']) + self.assertEqual(xqueue_header['submission_id'], self.external_grader.submission.id) + self.assertEqual(xqueue_header['submission_key'], self.external_grader.pullkey) + + self.external_grader.refresh_from_db() + self.assertEqual(self.external_grader.status, 'pulled') diff --git a/submissions/views/xqueue.py b/submissions/views/xqueue.py index 0664f61f..f085a1b6 100644 --- a/submissions/views/xqueue.py +++ b/submissions/views/xqueue.py @@ -2,7 +2,6 @@ import json import logging -import uuid from datetime import timedelta from django.contrib.auth import authenticate, login, logout @@ -22,16 +21,9 @@ log = logging.getLogger(__name__) -class XQueueSessionAuthentication(SessionAuthentication): - def enforce_csrf(self, request): - if 'put_result' in request.path: - return None - return super().enforce_csrf(request) - - class XqueueViewSet(viewsets.ViewSet): """ - A collection of services for xwatcher interactions and authentication. + A collection of services for xqueue-watcher interactions and authentication. This ViewSet provides endpoints for managing external grader results and handling user authentication in the system. @@ -48,7 +40,7 @@ class XqueueViewSet(viewsets.ViewSet): - logout: Endpoint for ending user sessions """ - authentication_classes = [XQueueSessionAuthentication] + authentication_classes = [SessionAuthentication] def get_permissions(self): """ @@ -117,7 +109,6 @@ def get_submission(self, request): - Submission data with pull information if successful - Error message if queue is empty or invalid """ - queue_name = request.query_params.get('queue_name') if not queue_name: @@ -128,11 +119,12 @@ def get_submission(self, request): timeout_threshold = timezone.now() - timedelta(minutes=5) try: - submission_record = ( + external_grader = ( ExternalGraderDetail.objects .select_for_update(nowait=True) .filter( Q(queue_name=queue_name, status='pending') | + Q(queue_name=queue_name, status='retry') | Q(queue_name=queue_name, status='pulled', status_time__lt=timeout_threshold) ) .select_related('submission') @@ -145,34 +137,28 @@ def get_submission(self, request): status=status.HTTP_409_CONFLICT ) - if submission_record: + if external_grader: try: - pull_time = timezone.now() - pullkey = str(uuid.uuid4()) - submission_record.update_status('pulled') - submission_record.pullkey = pullkey - submission_record.status_time = pull_time - submission_record.save(update_fields=['pullkey', 'status_time']) + if external_grader.status != "pulled": + external_grader.update_status("pulled") submission_data = { - "grader_payload": json.dumps({"grader": submission_record.grader_file_name}), + "grader_payload": json.dumps({"grader": external_grader.grader_file_name}), "student_info": json.dumps({ - "anonymous_student_id": str(submission_record.submission.student_item.student_id), - "submission_time": str(int(submission_record.created_at.timestamp())), + "anonymous_student_id": str(external_grader.submission.student_item.student_id), + "submission_time": str(int(external_grader.created_at.timestamp())), "random_seed": 1 }), - "student_response": submission_record.submission.answer + "student_response": external_grader.submission.answer } - file_manager = SubmissionFileManager(submission_record) - payload = { 'xqueue_header': json.dumps({ - 'submission_id': submission_record.submission.id, - 'submission_key': pullkey + 'submission_id': external_grader.submission.id, + 'submission_key': external_grader.pullkey }), 'xqueue_body': json.dumps(submission_data), - 'xqueue_files': json.dumps(file_manager.get_files_for_grader()) + 'xqueue_files': json.dumps(get_files_for_grader(external_grader)) } return Response( @@ -209,7 +195,7 @@ def put_result(self, request): ) try: - submission_record = ExternalGraderDetail.objects.select_for_update( + external_grader = ExternalGraderDetail.objects.select_for_update( nowait=True).get(submission__id=submission_id) except ExternalGraderDetail.DoesNotExist: log.error( @@ -224,9 +210,9 @@ def put_result(self, request): status=status.HTTP_404_NOT_FOUND ) - if not submission_record.pullkey or submission_key != submission_record.pullkey: + if not external_grader.pullkey or submission_key != external_grader.pullkey: log.error(f"Invalid pullkey: submission key from xwatcher {submission_key} " - f"and submission key stored {submission_record.pullkey} are different") + f"and submission key stored {external_grader.pullkey} are different") return Response( self.compose_reply(False, 'Incorrect key for submission'), status=status.HTTP_403_FORBIDDEN @@ -235,26 +221,22 @@ def put_result(self, request): # pylint: disable=broad-exception-caught try: log.info("Attempting to set_score...") - set_score(str(submission_record.submission.uuid), + set_score(str(external_grader.submission.uuid), points_earned, - 1 + external_grader.points_possible ) - log.info(f"=====> Saving score {score_msg}") - submission_record.grader_reply = score_msg - submission_record.status_time = timezone.now() - submission_record.update_status('retired') - submission_record.save() + external_grader.grader_reply = score_msg + external_grader.save() + external_grader.update_status('retired') log.info("Successfully updated submission score for submission %s", submission_id) - except Exception as e: - log.error(f"Error when execute set_score: {e}") + except Exception: + log.exception("Error when execute set_score") # Keep track of how many times we've failed to set_score a grade for this submission - submission_record.num_failures += 1 - if submission_record.num_failures > 30: - submission_record.status = "failed" + if external_grader.num_failures >= 30: + external_grader.update_status('failed') else: - submission_record.status = "pending" - submission_record.save() + external_grader.update_status('retry') return Response(self.compose_reply(success=True, content='')) From 4602624f76197aa33b0ed4cbf9ce98aaf988e5ea Mon Sep 17 00:00:00 2001 From: Leonardo Beroes Date: Wed, 22 Jan 2025 13:32:22 -0400 Subject: [PATCH 4/4] feat: Add event emission for external grader scores This commit adds the EXTERNAL_GRADER_SCORE_SUBMITTED event emission to the put_result endpoint in the XQueueViewSet. When a grader submits a result and the score is successfully saved, the system now emits an event with all the necessary information for the LMS to render the graded XBlock. Key changes: - Add queue_key field to ExternalGraderDetail model - Include queue_key in create_external_grader_detail method - Emit EXTERNAL_GRADER_SCORE_SUBMITTED event after successful score update - Implement robust false to propagate error and put submission in pending queue again - Add migration for the new queue_key field - Add openedx-events dependency This enables the event-driven approach for updating XBlocks with scoring data from the edx-submissions service, supporting the gradual migration away from HTTP-based XQueue callbacks. --- ...004-transaction-handling-in-get_submission | 33 ++ requirements/base.txt | 18 +- requirements/ci.txt | 46 +-- requirements/common_constraints.txt | 10 + requirements/dev.txt | 289 +----------------- requirements/docs.txt | 74 +---- requirements/pip-tools.txt | 10 +- requirements/pip.txt | 5 +- requirements/test.in | 1 + requirements/test.txt | 208 +------------ requirements/tox.txt | 19 +- submissions/errors.py | 21 ++ .../0007_externalgraderdetail_queue_key.py | 18 ++ submissions/models.py | 1 + submissions/serializers.py | 17 +- submissions/tests/test_models.py | 4 +- submissions/views/xqueue.py | 20 ++ 17 files changed, 160 insertions(+), 634 deletions(-) create mode 100644 docs/source/decisions/0004-transaction-handling-in-get_submission create mode 100644 submissions/migrations/0007_externalgraderdetail_queue_key.py diff --git a/docs/source/decisions/0004-transaction-handling-in-get_submission b/docs/source/decisions/0004-transaction-handling-in-get_submission new file mode 100644 index 00000000..fa459ab1 --- /dev/null +++ b/docs/source/decisions/0004-transaction-handling-in-get_submission @@ -0,0 +1,33 @@ +Improving Transaction Handling in Get Submission +================================================ + +Status +------ + +**Provisional** *2025-03-19* + +Context +------- + +We identified a concurrency issue in the submission processing flow. Multiple instances of the xqueue watcher were processing the same transaction simultaneously, causing duplicate handling of submissions. Additionally, submissions remained stuck in the "pulled" state indefinitely when errors or unexpected interruptions occurred during processing. + +Decision +-------- + +We introduced a robust locking mechanism at the database level to prevent concurrent processing of the same submission. Specifically, we employed database row-level locking (`select_for_update(nowait=True)`) on submissions to ensure only one xqueue watcher instance can process a transaction at a time. Additionally, we implemented a timeout-based fallback, marking submissions in the "pulled" state as available again if they exceed a defined timeout period (5 minutes). + +Consequences +------------ + +Positive +~~~~~~~~ + +- Ensures data consistency by preventing duplicate transaction processing. +- Implements automatic recovery for submissions stuck in the "pulled" state, improving overall system reliability. +- Minimal impact on system performance due to efficient database-level locking. + +Negative +~~~~~~~~ + +- Slightly increased complexity in transaction handling logic. +- Potential minor latency increase when contention occurs due to database locks. diff --git a/requirements/base.txt b/requirements/base.txt index 21a32f0b..8335c92a 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,32 +1,16 @@ # -# 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 # asgiref==3.8.1 - # via django django==4.2.21 - # via - # -c /home/runner/work/edx-submissions/edx-submissions/requirements/common_constraints.txt - # -r requirements/base.in - # django-model-utils - # djangorestframework - # edx-django-release-util - # jsonfield django-model-utils==5.0.0 - # via -r requirements/base.in djangorestframework==3.16.0 - # via -r requirements/base.in edx-django-release-util==1.5.0 - # via -r requirements/base.in jsonfield==3.1.0 - # via -r requirements/base.in pytz==2025.2 - # via -r requirements/base.in pyyaml==6.0.2 - # via edx-django-release-util six==1.17.0 - # via edx-django-release-util sqlparse==0.5.3 - # via django diff --git a/requirements/ci.txt b/requirements/ci.txt index 3f7fd933..ca1e46fd 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -1,69 +1,25 @@ # -# 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 # cachetools==5.5.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/tox.txt - # tox certifi==2025.4.26 - # via requests chardet==5.2.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/tox.txt - # tox charset-normalizer==3.4.2 - # via requests colorama==0.4.6 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/tox.txt - # tox coverage[toml]==7.8.0 - # via coveralls coveralls==4.0.1 - # via -r requirements/ci.in distlib==0.3.9 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/tox.txt - # virtualenv docopt==0.6.2 - # via coveralls filelock==3.18.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/tox.txt - # tox - # virtualenv idna==3.10 - # via requests packaging==25.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/tox.txt - # pyproject-api - # tox platformdirs==4.3.8 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/tox.txt - # tox - # virtualenv pluggy==1.5.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/tox.txt - # tox pyproject-api==1.9.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/tox.txt - # tox requests==2.32.3 - # via coveralls tox==4.25.0 - # via -r /home/runner/work/edx-submissions/edx-submissions/requirements/tox.txt urllib3==2.2.3 - # via - # -c /home/runner/work/edx-submissions/edx-submissions/requirements/common_constraints.txt - # requests virtualenv==20.31.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/tox.txt - # tox diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index 75193d3f..f18e215c 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -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 + # A central location for most common version constraints # (across edx repos) for pip-installation. # diff --git a/requirements/dev.txt b/requirements/dev.txt index 778bbdc2..d1698ba7 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,353 +1,96 @@ # -# 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 /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pydata-sphinx-theme alabaster==1.0.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.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 /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # django astroid==3.3.10 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pylint - # pylint-celery +attrs==25.3.0 babel==2.17.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pydata-sphinx-theme - # sphinx beautifulsoup4==4.13.4 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pydata-sphinx-theme certifi==2025.4.26 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # requests +cffi==1.17.1 charset-normalizer==3.4.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # requests click==8.2.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # click-log - # code-annotations - # edx-lint click-log==0.4.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # edx-lint code-annotations==2.3.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # edx-lint coverage[toml]==7.8.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pytest-cov ddt==1.7.2 - # via -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt dill==0.4.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pylint django==4.2.21 - # via - # -c /home/runner/work/edx-submissions/edx-submissions/requirements/common_constraints.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # django-model-utils - # djangorestframework - # edx-django-release-util - # jsonfield +django-crum==0.7.9 django-model-utils==5.0.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt +django-waffle==4.2.0 djangorestframework==3.16.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt +dnspython==2.7.0 docutils==0.21.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pydata-sphinx-theme - # sphinx +edx-ccx-keys==2.0.2 edx-django-release-util==1.5.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt +edx-django-utils==7.4.0 edx-lint==5.3.7 - # via - # -c /home/runner/work/edx-submissions/edx-submissions/requirements/constraints.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt +edx-opaque-keys[django]==3.0.0 factory-boy==3.3.3 - # via -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt faker==37.1.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # factory-boy +fastavro==1.11.1 freezegun==1.5.1 - # via -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt idna==3.10 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # requests imagesize==1.4.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # sphinx iniconfig==2.1.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pytest isort==6.0.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pylint jinja2==3.1.6 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # code-annotations - # sphinx jsonfield==3.1.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt markupsafe==3.0.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # jinja2 mccabe==0.7.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pylint mock==5.2.0 - # via -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt +newrelic==10.12.0 +openedx-events==10.2.0 packaging==25.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pydata-sphinx-theme - # pytest - # sphinx pbr==6.1.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # stevedore platformdirs==4.3.8 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pylint pluggy==1.5.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pytest pockets==0.9.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # sphinxcontrib-napoleon +psutil==7.0.0 pycodestyle==2.13.0 - # via -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt +pycparser==2.22 pydata-sphinx-theme==0.15.4 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # sphinx-book-theme pygments==2.19.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # accessible-pygments - # pydata-sphinx-theme - # sphinx pylint==3.3.7 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # edx-lint - # pylint-celery - # pylint-django - # pylint-plugin-utils pylint-celery==0.3 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # edx-lint pylint-django==2.6.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # edx-lint pylint-plugin-utils==0.8.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pylint-celery - # pylint-django +pymongo==4.13.0 +pynacl==1.5.0 pytest==8.3.5 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pytest-cov - # pytest-django pytest-cov==6.1.1 - # via -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt pytest-django==4.11.1 - # via -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt python-dateutil==2.9.0.post0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # freezegun python-slugify==8.0.4 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # code-annotations pytz==2025.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt pyyaml==6.0.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # code-annotations - # edx-django-release-util requests==2.32.3 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # sphinx roman-numerals-py==3.1.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # sphinx six==1.17.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # edx-django-release-util - # edx-lint - # pockets - # python-dateutil - # sphinxcontrib-napoleon snowballstemmer==3.0.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # sphinx soupsieve==2.7 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # beautifulsoup4 sphinx==8.2.3 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pydata-sphinx-theme - # sphinx-book-theme sphinx-book-theme==1.1.4 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt sphinxcontrib-applehelp==2.0.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # sphinx sphinxcontrib-devhelp==2.0.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # sphinx sphinxcontrib-htmlhelp==2.1.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # sphinx sphinxcontrib-jsmath==1.0.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # sphinx sphinxcontrib-napoleon==0.7 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt sphinxcontrib-qthelp==2.0.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # sphinx sphinxcontrib-serializinghtml==2.0.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # sphinx sqlparse==0.5.3 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # django stevedore==5.4.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # code-annotations text-unidecode==1.3 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # python-slugify tomlkit==0.13.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # pylint typing-extensions==4.13.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # beautifulsoup4 - # pydata-sphinx-theme tzdata==2025.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # faker urllib3==2.2.3 - # via - # -c /home/runner/work/edx-submissions/edx-submissions/requirements/common_constraints.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/test.txt - # requests # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/docs.txt b/requirements/docs.txt index 7e571067..f49855f4 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,118 +1,46 @@ # -# 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 pydata-sphinx-theme alabaster==1.0.0 - # via sphinx asgiref==3.8.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # django babel==2.17.0 - # via - # pydata-sphinx-theme - # sphinx beautifulsoup4==4.13.4 - # via pydata-sphinx-theme certifi==2025.4.26 - # via requests charset-normalizer==3.4.2 - # via requests django==4.2.21 - # via - # -c /home/runner/work/edx-submissions/edx-submissions/requirements/common_constraints.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # django-model-utils - # djangorestframework - # edx-django-release-util - # jsonfield django-model-utils==5.0.0 - # via -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt djangorestframework==3.16.0 - # via -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt docutils==0.21.2 - # via - # pydata-sphinx-theme - # sphinx edx-django-release-util==1.5.0 - # via -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt idna==3.10 - # via requests imagesize==1.4.1 - # via sphinx jinja2==3.1.6 - # via sphinx jsonfield==3.1.0 - # via -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt markupsafe==3.0.2 - # via jinja2 packaging==25.0 - # via - # pydata-sphinx-theme - # sphinx pockets==0.9.1 - # via sphinxcontrib-napoleon pydata-sphinx-theme==0.15.4 - # via sphinx-book-theme pygments==2.19.1 - # via - # accessible-pygments - # pydata-sphinx-theme - # sphinx pytz==2025.2 - # via -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt pyyaml==6.0.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # edx-django-release-util requests==2.32.3 - # via sphinx roman-numerals-py==3.1.0 - # via sphinx six==1.17.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # edx-django-release-util - # pockets - # sphinxcontrib-napoleon snowballstemmer==3.0.1 - # via sphinx soupsieve==2.7 - # via beautifulsoup4 sphinx==8.2.3 - # via - # -r requirements/docs.in - # pydata-sphinx-theme - # sphinx-book-theme sphinx-book-theme==1.1.4 - # via -r requirements/docs.in sphinxcontrib-applehelp==2.0.0 - # via sphinx sphinxcontrib-devhelp==2.0.0 - # via sphinx sphinxcontrib-htmlhelp==2.1.0 - # via sphinx sphinxcontrib-jsmath==1.0.1 - # via sphinx sphinxcontrib-napoleon==0.7 - # via -r requirements/docs.in sphinxcontrib-qthelp==2.0.0 - # via sphinx sphinxcontrib-serializinghtml==2.0.0 - # via sphinx sqlparse==0.5.3 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # django typing-extensions==4.13.2 - # via - # beautifulsoup4 - # pydata-sphinx-theme urllib3==2.2.3 - # via - # -c /home/runner/work/edx-submissions/edx-submissions/requirements/common_constraints.txt - # requests diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index 1f3a46a1..abf67ab2 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -1,23 +1,15 @@ # -# 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 # build==1.2.2.post1 - # via pip-tools click==8.2.0 - # via pip-tools packaging==25.0 - # via build pip-tools==7.4.1 - # via -r requirements/pip-tools.in pyproject-hooks==1.2.0 - # via - # build - # pip-tools wheel==0.45.1 - # via pip-tools # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/pip.txt b/requirements/pip.txt index e44c9441..f7e9bc58 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -1,14 +1,11 @@ # -# 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 # wheel==0.45.1 - # via -r requirements/pip.in # The following packages are considered to be unsafe in a requirements file: pip==25.1.1 - # via -r requirements/pip.in setuptools==80.4.0 - # via -r requirements/pip.in diff --git a/requirements/test.in b/requirements/test.in index 20c1eed9..818b3e56 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -14,3 +14,4 @@ pycodestyle>=2.5.0 pytest-cov pytest-django python-dateutil>=2.8.0 +openedx-events==10.2.0 diff --git a/requirements/test.txt b/requirements/test.txt index 1dc36ddc..caa2eceb 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,270 +1,94 @@ # -# 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 - # pydata-sphinx-theme alabaster==1.0.0 - # via - # -r /home/runner/work/edx-submissions/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 - # django astroid==3.3.10 - # via - # pylint - # pylint-celery +attrs==25.3.0 babel==2.17.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # pydata-sphinx-theme - # sphinx beautifulsoup4==4.13.4 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # pydata-sphinx-theme certifi==2025.4.26 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # requests +cffi==1.17.1 charset-normalizer==3.4.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # requests click==8.2.0 - # via - # click-log - # code-annotations - # edx-lint click-log==0.4.0 - # via edx-lint code-annotations==2.3.0 - # via edx-lint coverage[toml]==7.8.0 - # via pytest-cov ddt==1.7.2 - # via -r requirements/test.in dill==0.4.0 - # via pylint - # via - # -c /home/runner/work/edx-submissions/edx-submissions/requirements/common_constraints.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # django-model-utils - # djangorestframework - # edx-django-release-util - # jsonfield +django-crum==0.7.9 django-model-utils==5.0.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt +django-waffle==4.2.0 +dnspython==2.7.0 docutils==0.21.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # pydata-sphinx-theme - # sphinx +edx-ccx-keys==2.0.2 edx-django-release-util==1.5.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt +edx-django-utils==7.4.0 edx-lint==5.3.7 - # via - # -c /home/runner/work/edx-submissions/edx-submissions/requirements/constraints.txt - # -r requirements/test.in +edx-opaque-keys[django]==3.0.0 factory-boy==3.3.3 - # via -r requirements/test.in faker==37.1.0 - # via factory-boy +fastavro==1.11.1 freezegun==1.5.1 - # via -r requirements/test.in idna==3.10 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # requests imagesize==1.4.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # sphinx iniconfig==2.1.0 - # via pytest isort==6.0.1 - # via - # -r requirements/test.in - # pylint jinja2==3.1.6 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # code-annotations - # sphinx jsonfield==3.1.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt markupsafe==3.0.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # jinja2 mccabe==0.7.0 - # via pylint mock==5.2.0 - # via -r requirements/test.in +newrelic==10.12.0 +openedx-events==10.2.0 packaging==25.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # pydata-sphinx-theme - # pytest - # sphinx pbr==6.1.1 - # via stevedore platformdirs==4.3.8 - # via pylint pluggy==1.5.0 - # via pytest pockets==0.9.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # sphinxcontrib-napoleon +psutil==7.0.0 pycodestyle==2.13.0 - # via -r requirements/test.in +pycparser==2.22 pydata-sphinx-theme==0.15.4 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # sphinx-book-theme pygments==2.19.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # accessible-pygments - # pydata-sphinx-theme - # sphinx pylint==3.3.7 - # via - # edx-lint - # pylint-celery - # pylint-django - # pylint-plugin-utils pylint-celery==0.3 - # via edx-lint pylint-django==2.6.1 - # via edx-lint pylint-plugin-utils==0.8.2 - # via - # pylint-celery - # pylint-django +pymongo==4.13.0 +pynacl==1.5.0 pytest==8.3.5 - # via - # pytest-cov - # pytest-django pytest-cov==6.1.1 - # via -r requirements/test.in pytest-django==4.11.1 - # via -r requirements/test.in python-dateutil==2.9.0.post0 - # via - # -r requirements/test.in - # freezegun python-slugify==8.0.4 - # via code-annotations pytz==2025.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt pyyaml==6.0.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # code-annotations - # edx-django-release-util requests==2.32.3 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # sphinx roman-numerals-py==3.1.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # sphinx six==1.17.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # edx-django-release-util - # edx-lint - # pockets - # python-dateutil - # sphinxcontrib-napoleon snowballstemmer==3.0.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # sphinx soupsieve==2.7 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # beautifulsoup4 sphinx==8.2.3 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # pydata-sphinx-theme - # sphinx-book-theme sphinx-book-theme==1.1.4 - # via -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt sphinxcontrib-applehelp==2.0.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # sphinx sphinxcontrib-devhelp==2.0.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # sphinx sphinxcontrib-htmlhelp==2.1.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # sphinx sphinxcontrib-jsmath==1.0.1 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # sphinx sphinxcontrib-napoleon==0.7 - # via -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt sphinxcontrib-qthelp==2.0.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # sphinx sphinxcontrib-serializinghtml==2.0.0 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # sphinx sqlparse==0.5.3 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/base.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # django stevedore==5.4.1 - # via code-annotations text-unidecode==1.3 - # via python-slugify tomlkit==0.13.2 - # via pylint typing-extensions==4.13.2 - # via - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # beautifulsoup4 - # pydata-sphinx-theme tzdata==2025.2 - # via faker urllib3==2.2.3 - # via - # -c /home/runner/work/edx-submissions/edx-submissions/requirements/common_constraints.txt - # -r /home/runner/work/edx-submissions/edx-submissions/requirements/docs.txt - # requests # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/tox.txt b/requirements/tox.txt index f8da5e71..faab0f46 100644 --- a/requirements/tox.txt +++ b/requirements/tox.txt @@ -1,34 +1,17 @@ # -# 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 # cachetools==5.5.2 - # via tox chardet==5.2.0 - # via tox colorama==0.4.6 - # via tox distlib==0.3.9 - # via virtualenv filelock==3.18.0 - # via - # tox - # virtualenv packaging==25.0 - # via - # pyproject-api - # tox platformdirs==4.3.8 - # via - # tox - # virtualenv pluggy==1.5.0 - # via tox pyproject-api==1.9.1 - # via tox tox==4.25.0 - # via -r requirements/tox.in virtualenv==20.31.2 - # via tox diff --git a/submissions/errors.py b/submissions/errors.py index 38a1cf77..fc566e26 100644 --- a/submissions/errors.py +++ b/submissions/errors.py @@ -40,6 +40,27 @@ class ExternalGraderQueueEmptyError(SubmissionError): """ +class InvalidFileTypeError(SubmissionError): + """ + Exception raised when a file object has an unsupported or invalid type. + + This exception is typically raised during file processing when the system + encounters a file object that doesn't match any of the expected types + (bytes, ContentFile, SimpleUploadedFile) and doesn't have a 'read' method. + """ + + +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 + """ + + class SubmissionRequestError(SubmissionError): """ This error is raised when there was a request-specific error diff --git a/submissions/migrations/0007_externalgraderdetail_queue_key.py b/submissions/migrations/0007_externalgraderdetail_queue_key.py new file mode 100644 index 00000000..39f18818 --- /dev/null +++ b/submissions/migrations/0007_externalgraderdetail_queue_key.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.19 on 2025-05-21 18:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0006_alter_externalgraderdetail_status'), + ] + + operations = [ + migrations.AddField( + model_name='externalgraderdetail', + name='queue_key', + field=models.CharField(max_length=128, null=True), + ), + ] diff --git a/submissions/models.py b/submissions/models.py index a7b8b968..ae6f3af2 100644 --- a/submissions/models.py +++ b/submissions/models.py @@ -625,6 +625,7 @@ class Status(models.TextChoices): ) queue_name = models.CharField(max_length=128) + queue_key = models.CharField(max_length=128, null=True) grader_file_name = models.CharField(max_length=128, default='') points_possible = models.PositiveIntegerField(default=1) diff --git a/submissions/serializers.py b/submissions/serializers.py index 4d8610af..aa0cdeda 100644 --- a/submissions/serializers.py +++ b/submissions/serializers.py @@ -8,7 +8,7 @@ from rest_framework import serializers from rest_framework.fields import DateTimeField, Field, IntegerField -from submissions.models import Score, ScoreAnnotation, StudentItem, Submission, TeamSubmission +from submissions.models import ExternalGraderDetail, Score, ScoreAnnotation, StudentItem, Submission, TeamSubmission class RawField(Field): @@ -221,3 +221,18 @@ class Meta: 'submission_uuid', 'annotations', ) + + +class SubmissionListSerializer(serializers.ModelSerializer): + class Meta: + model = Submission + fields = '__all__' + + +class ExternalGraderDetailSerializer(serializers.ModelSerializer): + """ Serializer for ExternalGraderDetail """ + submission = SubmissionListSerializer(read_only=True) + + class Meta: + model = ExternalGraderDetail + fields = '__all__' diff --git a/submissions/tests/test_models.py b/submissions/tests/test_models.py index 7cd90c24..6bf3306d 100644 --- a/submissions/tests/test_models.py +++ b/submissions/tests/test_models.py @@ -328,7 +328,7 @@ def setUp(self): ) def test_default_status(self): - """Test that new queue records are created with 'pending' status.""" + """Test that new external graders are created with 'pending' status.""" self.assertEqual(self.external_grader_detail.status, 'pending') self.assertEqual(self.external_grader_detail.num_failures, 0) @@ -748,7 +748,7 @@ def test_unique_uids(self): ) def test_related_name_access(self): - """Test accessing files through the submission queue record.""" + """Test accessing files through the submission external grader.""" files = self.external_grader_detail.files.all() self.assertEqual(files.count(), 1) self.assertEqual(files.first(), self.submission_file) diff --git a/submissions/views/xqueue.py b/submissions/views/xqueue.py index f085a1b6..62d32937 100644 --- a/submissions/views/xqueue.py +++ b/submissions/views/xqueue.py @@ -9,6 +9,8 @@ from django.db.models import Q from django.http import HttpResponse from django.utils import timezone +from openedx_events.learning.data import ExternalGraderScoreData +from openedx_events.learning.signals import EXTERNAL_GRADER_SCORE_SUBMITTED from rest_framework import status, viewsets from rest_framework.authentication import SessionAuthentication from rest_framework.decorators import action @@ -230,6 +232,24 @@ def put_result(self, request): external_grader.update_status('retired') log.info("Successfully updated submission score for submission %s", submission_id) + # Modify the event emission in put_result + EXTERNAL_GRADER_SCORE_SUBMITTED.send_event( + send_robust=False, + score=ExternalGraderScoreData( + points_possible=external_grader.points_possible, + points_earned=points_earned, + course_id=external_grader.submission.student_item.course_id, + score_msg=score_msg, + submission_id=submission_id, + # Extract these from the submission + user_id=external_grader.submission.student_item.student_id, + module_id=external_grader.submission.student_item.item_id, + queue_key=external_grader.queue_key, + queue_name=external_grader.queue_name + ) + ) + log.info("Score event sent to bus successfully") + except Exception: log.exception("Error when execute set_score") # Keep track of how many times we've failed to set_score a grade for this submission