From 6c3bc5574c84f5fea6a23dac8cc6db5685c54a7c Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Thu, 25 Dec 2025 00:15:29 +0800 Subject: [PATCH 001/113] feat: I add User avatar picture. --- submissions/views.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/submissions/views.py b/submissions/views.py index 42b524d..ec929e1 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -899,6 +899,13 @@ def ranking_view(request): # 總提交數量 total_submission_count = user_submissions.count() + # 獲取用戶頭像 + try: + profile = user.userprofile + avatar_url = profile.avatar.url if profile.avatar else None + except: + avatar_url = None + # 組裝用戶資料(參照 NOJ 格式) user_data = { 'user': { @@ -906,6 +913,7 @@ def ranking_view(request): 'username': user.username, 'real_name': getattr(user, 'real_name', user.username), 'email': user.email, + 'avatar': avatar_url, 'is_active': user.is_active, 'date_joined': user.date_joined.isoformat() if user.date_joined else None }, From 158eb96e3fef15bac76d28d96afaf913d94a4cb6 Mon Sep 17 00:00:00 2001 From: chewinggum-master <41247003s@gapps.ntnu.edu.tw> Date: Fri, 26 Dec 2025 01:35:19 +0800 Subject: [PATCH 002/113] =?UTF-8?q?feat:api=20authentic=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=E6=94=B9=E5=85=A8=E5=9F=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- problems/views/api.py | 52 +++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/problems/views/api.py b/problems/views/api.py index b04fa5f..c7a18f6 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -50,7 +50,13 @@ class ProblemsViewSet(viewsets.ModelViewSet): queryset = Problems.objects.all().order_by("-created_at") serializer_class = ProblemSerializer - permission_classes = [IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly] + + def get_permissions(self): + from rest_framework.permissions import AllowAny + if self.request.method == 'GET': + return [AllowAny()] + # POST/PUT/PATCH/DELETE 使用全域預設:登入 + email verified + return [] def perform_create(self, serializer): serializer.save(creator_id=self.request.user) @@ -62,12 +68,13 @@ def perform_update(self, serializer): class SubtasksViewSet(viewsets.ModelViewSet): queryset = Problem_subtasks.objects.all().order_by("problem_id", "subtask_no") serializer_class = SubtaskSerializer - permission_classes = [IsAuthenticatedOrReadOnly] def get_permissions(self): - if self.request.method in ("POST", "PUT", "PATCH", "DELETE"): - return [IsAuthenticated()] - return super().get_permissions() + from rest_framework.permissions import AllowAny + if self.request.method == 'GET': + return [AllowAny()] + # POST/PUT/PATCH/DELETE 使用全域預設:登入 + email verified + return [] def perform_create(self, serializer): problem = serializer.validated_data["problem_id"] @@ -151,7 +158,6 @@ class ProblemSubtaskDetailView(APIView): DELETE /problem//subtasks/ — 刪除子題 權限:題目擁有者或課程 TA/教師/管理員。 """ - permission_classes = [IsAuthenticated] def _get_subtask_with_permission(self, problem_id: int, subtask_id: int, user): subtask = get_object_or_404(Problem_subtasks, pk=subtask_id) @@ -184,12 +190,13 @@ def delete(self, request, pk: int, subtask_id: int): class TestCasesViewSet(viewsets.ModelViewSet): queryset = Test_cases.objects.all().order_by("subtask_id", "idx") serializer_class = TestCaseSerializer - permission_classes = [IsAuthenticatedOrReadOnly] def get_permissions(self): - if self.request.method in ("POST", "PUT", "PATCH", "DELETE"): - return [IsAuthenticated()] - return super().get_permissions() + from rest_framework.permissions import AllowAny + if self.request.method == 'GET': + return [AllowAny()] + # POST/PUT/PATCH/DELETE 使用全域預設:登入 + email verified + return [] def _ensure_owner(self, subtask): problem = subtask.problem_id @@ -276,7 +283,6 @@ class ProblemTestCaseDetailView(APIView): DELETE /problem//test-cases/ — 刪除測資 權限:owner / 課程 TA / 教師 / 管理員。 """ - permission_classes = [IsAuthenticated] def _get_case_with_permission(self, problem_id: int, case_id: int, user): case = get_object_or_404(Test_cases, pk=case_id) @@ -307,7 +313,13 @@ class TagListCreateView(APIView): """GET /tags/ 取得所有標籤 POST /tags/ 建立新標籤(需要登入,建議僅教師/管理員;此處暫允任何登入使用者) """ - permission_classes = [IsAuthenticatedOrReadOnly] + + def get_permissions(self): + from rest_framework.permissions import AllowAny + if self.request.method == 'GET': + return [AllowAny()] + # POST 使用全域預設:登入 + email verified + return [] def get(self, request): tags = Tags.objects.all().order_by('name') @@ -331,7 +343,6 @@ def _has_problem_manage_permission(problem, user) -> bool: class ProblemTestCaseUploadInitiateView(APIView): - permission_classes = [IsAuthenticated] def post(self, request, pk: int): problem = get_object_or_404(Problems, pk=pk) @@ -364,7 +375,6 @@ def post(self, request, pk: int): class ProblemTestCaseUploadCompleteView(APIView): - permission_classes = [IsAuthenticated] def post(self, request, pk: int): problem = get_object_or_404(Problems, pk=pk) @@ -428,7 +438,6 @@ def stem(n): class ProblemTestCaseDownloadView(APIView): - permission_classes = [IsAuthenticated] def get(self, request, pk: int): problem = get_object_or_404(Problems, pk=pk) @@ -458,7 +467,6 @@ class ProblemTestCaseZipUploadView(APIView): - 將 zip 存到 `MEDIA_ROOT/testcases/p/problem.zip`。 - 回傳保存路徑。 """ - permission_classes = [IsAuthenticated] parser_classes = [MultiPartParser, FormParser] def post(self, request, pk: int): @@ -676,7 +684,6 @@ class ProblemTagAddView(APIView): 權限:題目擁有者 / 課程 TA / 教師 / 管理員 若標籤已存在於題目,回傳 400。 """ - permission_classes = [IsAuthenticated] def _get_problem_for_modify(self, problem_id, user): problem = get_object_or_404(Problems, pk=problem_id) @@ -723,7 +730,6 @@ class ProblemTagRemoveView(APIView): 權限:題目擁有者 / 課程 TA / 教師 / 管理員 若關聯不存在則回傳 404。 """ - permission_classes = [IsAuthenticated] def _get_problem_for_modify(self, problem_id, user): problem = get_object_or_404(Problems, pk=problem_id) @@ -863,7 +869,6 @@ class ProblemDetailView(APIView): 權限:需求書標示需登入;故此處強制 IsAuthenticated。針對非公開題目沿用既有權限檢查。 """ - permission_classes = [IsAuthenticated] LANGUAGE_BIT_MAP = { 'c': 1, @@ -1021,7 +1026,6 @@ class ProblemStatsView(APIView): 權限:需要登入 回傳:AC 用戶、嘗試用戶、平均分數、標準差、分數分布、狀態統計、top10執行時間/記憶體 """ - permission_classes = [IsAuthenticated] def get(self, request, pk): # Defensive: some dependent apps/tables may be missing while local migrations are being fixed. @@ -1094,7 +1098,6 @@ class ProblemHighScoreView(APIView): GET /api/problem//high-score — 取得使用者在該題目的最高分數 需要登入;回傳該使用者在此題的最高分 """ - permission_classes = [IsAuthenticated] def get(self, request, pk): try: @@ -1121,7 +1124,6 @@ class ProblemManageView(APIView): """ # 允許已登入;實際權限在 post() 內判斷: # admin/teacher 直接允許;否則需為該 course 的 TA - permission_classes = [IsAuthenticated] def post(self, request): serializer = ProblemSerializer(data=request.data, context={"request": request}) @@ -1190,8 +1192,6 @@ class ProblemCloneView(APIView): - 403: { message: "Problem can not view." } """ - permission_classes = [IsAuthenticated] - def post(self, request): user = request.user payload = request.data or {} @@ -1326,7 +1326,6 @@ def post(self, request): @api_view(['POST', 'DELETE']) -@permission_classes([IsAuthenticated]) def problem_like_toggle(request, pk): """POST /problem//like - 按讚 DELETE /problem//like - 取消按讚 @@ -1377,7 +1376,6 @@ def problem_likes_count(request, pk): class UserLikedProblemsView(generics.ListAPIView): """GET /user/likes - 列出已按讚的題目(需登入)""" - permission_classes = [IsAuthenticated] serializer_class = ProblemStudentSerializer pagination_class = ProblemPagination @@ -1402,7 +1400,7 @@ def list(self, request, *args, **kwargs): # PUT/DELETE /api/problem/manage/ — 修改/刪除題目 # GET /api/problem/manage/ — 管理員視角題目詳情 class ProblemManageDetailView(APIView): - permission_classes = [IsTeacherOrAdmin, IsAuthenticated] + permission_classes = [IsTeacherOrAdmin] def get_object_for_modify(self, pk, user): """用於 PUT/DELETE,檢查修改/刪除權限。 From a527318b49b16db0866c9c5052074f8ef870f035 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Fri, 26 Dec 2025 12:18:50 +0800 Subject: [PATCH 003/113] feat: add custom checker settings to Problems model and serializers --- problems/models.py | 3 +++ problems/serializers/core.py | 4 ++++ submissions/sandbox_client.py | 4 ++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/problems/models.py b/problems/models.py index 98a68ae..553f6a3 100644 --- a/problems/models.py +++ b/problems/models.py @@ -69,6 +69,9 @@ class Visibility(models.TextChoices): # --- Solution code for test generation (not editorials) --- solution_code = models.TextField(blank=True, null=True, help_text="Optional: reference solution code used for test generation.") solution_code_language = models.CharField(max_length=50, blank=True, null=True, help_text="Optional: language of solution code. Required if solution code is provided.") + # --- Custom checker settings --- + use_checker = models.BooleanField(default=False, help_text="Whether to use a custom checker for judging.") + checker_name = models.CharField(max_length=100, default='diff', blank=True, help_text="Name of the checker to use (e.g., 'diff', 'float', 'custom'). Defaults to 'diff'.") creator_id = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='created_problems') course_id = models.ForeignKey('courses.Courses', on_delete=models.PROTECT, null=False, blank=False, related_name='courses') created_at = models.DateTimeField(default=timezone.now) diff --git a/problems/serializers/core.py b/problems/serializers/core.py index 4f51b48..d60d2ed 100644 --- a/problems/serializers/core.py +++ b/problems/serializers/core.py @@ -86,6 +86,8 @@ class Meta: "subtask_description", "supported_languages", # solution code fields for test generation "solution_code", "solution_code_language", + # custom checker settings + "use_checker", "checker_name", "creator_id", "course_id", "course_name", "created_at", "updated_at", "tags", "tag_ids", @@ -192,6 +194,8 @@ class Meta: "description", "input_description", "output_description", "sample_input", "sample_output", "hint", "subtask_description", "supported_languages", + # custom checker settings + "use_checker", "checker_name", "creator_id", "course_id", "created_at", "updated_at", "tags", "tag_ids", "subtasks", diff --git a/submissions/sandbox_client.py b/submissions/sandbox_client.py index 9496f50..0782426 100644 --- a/submissions/sandbox_client.py +++ b/submissions/sandbox_client.py @@ -90,8 +90,8 @@ def submit_to_sandbox(submission): 'file_hash': submission.code_hash, 'time_limit': time_limit, 'memory_limit': memory_limit, - 'use_checker': False, # TODO: 從 problem 設定取得 - 'checker_name': 'diff', + 'use_checker': problem.use_checker, + 'checker_name': problem.checker_name or 'diff', 'use_static_analysis': False, # TODO: 從 assignment 設定取得 'priority': 0, # 一般優先級 'callback_url': f'{settings.BACKEND_BASE_URL}/submissions/callback/', # Sandbox 判題完成後回傳結果的 URL From 71d53b05d4114be5cea954abc1bbdfc0e81a1b7c Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Fri, 26 Dec 2025 18:26:38 +0800 Subject: [PATCH 004/113] feat: update custom checker settings in Problems model and serializers --- problems/models.py | 12 ++++++++++-- problems/serializers/core.py | 4 ++-- submissions/sandbox_client.py | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/problems/models.py b/problems/models.py index 553f6a3..5a16162 100644 --- a/problems/models.py +++ b/problems/models.py @@ -70,8 +70,16 @@ class Visibility(models.TextChoices): solution_code = models.TextField(blank=True, null=True, help_text="Optional: reference solution code used for test generation.") solution_code_language = models.CharField(max_length=50, blank=True, null=True, help_text="Optional: language of solution code. Required if solution code is provided.") # --- Custom checker settings --- - use_checker = models.BooleanField(default=False, help_text="Whether to use a custom checker for judging.") - checker_name = models.CharField(max_length=100, default='diff', blank=True, help_text="Name of the checker to use (e.g., 'diff', 'float', 'custom'). Defaults to 'diff'.") + use_custom_checker = models.BooleanField( + default=False, + help_text="Whether to use a custom checker instead of the default diff comparison." + ) + checker_name = models.CharField( + max_length=100, + default='diff', + blank=True, + help_text="Name of the checker to use. Only applies when use_custom_checker is True. Common values: 'float' (floating-point comparison), 'token' (token-based), 'custom' (problem-specific)." + ) creator_id = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='created_problems') course_id = models.ForeignKey('courses.Courses', on_delete=models.PROTECT, null=False, blank=False, related_name='courses') created_at = models.DateTimeField(default=timezone.now) diff --git a/problems/serializers/core.py b/problems/serializers/core.py index d60d2ed..a2d23b5 100644 --- a/problems/serializers/core.py +++ b/problems/serializers/core.py @@ -87,7 +87,7 @@ class Meta: # solution code fields for test generation "solution_code", "solution_code_language", # custom checker settings - "use_checker", "checker_name", + "use_custom_checker", "checker_name", "creator_id", "course_id", "course_name", "created_at", "updated_at", "tags", "tag_ids", @@ -195,7 +195,7 @@ class Meta: "sample_input", "sample_output", "hint", "subtask_description", "supported_languages", # custom checker settings - "use_checker", "checker_name", + "use_custom_checker", "checker_name", "creator_id", "course_id", "created_at", "updated_at", "tags", "tag_ids", "subtasks", diff --git a/submissions/sandbox_client.py b/submissions/sandbox_client.py index 0782426..9adccce 100644 --- a/submissions/sandbox_client.py +++ b/submissions/sandbox_client.py @@ -90,7 +90,7 @@ def submit_to_sandbox(submission): 'file_hash': submission.code_hash, 'time_limit': time_limit, 'memory_limit': memory_limit, - 'use_checker': problem.use_checker, + 'use_checker': problem.use_custom_checker, 'checker_name': problem.checker_name or 'diff', 'use_static_analysis': False, # TODO: 從 assignment 設定取得 'priority': 0, # 一般優先級 From 78c0ac507222e4858bec8f4f457cfef942c07601 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Fri, 26 Dec 2025 18:35:30 +0800 Subject: [PATCH 005/113] feat: update checker_name logic to use custom checker conditionally --- submissions/sandbox_client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/submissions/sandbox_client.py b/submissions/sandbox_client.py index 9adccce..2ffb431 100644 --- a/submissions/sandbox_client.py +++ b/submissions/sandbox_client.py @@ -90,8 +90,9 @@ def submit_to_sandbox(submission): 'file_hash': submission.code_hash, 'time_limit': time_limit, 'memory_limit': memory_limit, + # 只有在啟用自訂 checker 時才使用設定的 checker_name,否則強制使用 'diff' 'use_checker': problem.use_custom_checker, - 'checker_name': problem.checker_name or 'diff', + 'checker_name': problem.checker_name if problem.use_custom_checker else 'diff', 'use_static_analysis': False, # TODO: 從 assignment 設定取得 'priority': 0, # 一般優先級 'callback_url': f'{settings.BACKEND_BASE_URL}/submissions/callback/', # Sandbox 判題完成後回傳結果的 URL From f028c265d05a69895a841262b5c732ef7f6fffe4 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Fri, 26 Dec 2025 18:36:57 +0800 Subject: [PATCH 006/113] Update problems/models.py help_text Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- problems/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/problems/models.py b/problems/models.py index 5a16162..67edd56 100644 --- a/problems/models.py +++ b/problems/models.py @@ -78,7 +78,7 @@ class Visibility(models.TextChoices): max_length=100, default='diff', blank=True, - help_text="Name of the checker to use. Only applies when use_custom_checker is True. Common values: 'float' (floating-point comparison), 'token' (token-based), 'custom' (problem-specific)." + help_text="Name of the checker to use. 'diff' is the standard default checker when use_custom_checker is False. Common values: 'diff' (exact text match, default), 'float' (floating-point comparison), 'token' (token-based), 'custom' (problem-specific)." ) creator_id = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='created_problems') course_id = models.ForeignKey('courses.Courses', on_delete=models.PROTECT, null=False, blank=False, related_name='courses') From 643d891e22b38e2363fd1a24ea96f2f9ea105dc4 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Fri, 26 Dec 2025 18:46:30 +0800 Subject: [PATCH 007/113] feat: add custom checker settings to ProblemStudentSerializer and API responses --- problems/serializers/core.py | 3 ++ problems/tests/test_api.py | 81 ++++++++++++++++++++++++++++++++++++ problems/views/api.py | 3 ++ 3 files changed, 87 insertions(+) diff --git a/problems/serializers/core.py b/problems/serializers/core.py index a2d23b5..d8df45b 100644 --- a/problems/serializers/core.py +++ b/problems/serializers/core.py @@ -230,6 +230,8 @@ class Meta: "description", "input_description", "output_description", "sample_input", "sample_output", "hint", "subtask_description", "supported_languages", + # custom checker settings (read-only for students) + "use_custom_checker", "checker_name", "course_id", "created_at", "tags", "tag_ids", "subtasks", @@ -237,6 +239,7 @@ class Meta: ] read_only_fields = [ "acceptance_rate", "total_submissions", "accepted_submissions", + "use_custom_checker", "checker_name", "created_at", ] diff --git a/problems/tests/test_api.py b/problems/tests/test_api.py index 9cb03ab..0a07374 100644 --- a/problems/tests/test_api.py +++ b/problems/tests/test_api.py @@ -261,3 +261,84 @@ def test_sandbox_testdata_download(api_client, teacher, course, settings): ) res_404 = api_client.get(f"/problem/{p2.id}/testdata", {'token': settings.SANDBOX_TOKEN}) assert res_404.status_code == 404 + +@pytest.mark.django_db +def test_problem_custom_checker_settings(api_client, teacher, course): + """Test creating and updating problems with custom checker settings.""" + api_client.force_authenticate(user=teacher) + + # Test 1: 建立題目時設定 custom checker + payload = { + "title": "Float Problem", + "description": "計算浮點數", + "difficulty": "medium", + "course_id": str(course.id), + "use_custom_checker": True, + "checker_name": "float", + } + res = api_client.post("/problem/manage", payload, format="json") + assert res.status_code == 201 + problem_id = res.json()["data"]["problem_id"] + + # Test 2: 驗證 API 回應包含 checker 設定 + res_detail = api_client.get(f"/problem/{problem_id}") + assert res_detail.status_code == 200 + data = res_detail.json()["data"] + assert data["use_custom_checker"] is True + assert data["checker_name"] == "float" + + # Test 3: 更新 checker 設定(使用 PUT) + update_payload = { + "title": "Float Problem", # PUT 需要提供完整資料 + "description": "計算浮點數", + "difficulty": "medium", + "course_id": str(course.id), + "use_custom_checker": False, + "checker_name": "diff", + } + res_update = api_client.put(f"/problem/manage/{problem_id}", update_payload, format="json") + assert res_update.status_code == 200 + + # Test 4: 驗證更新後使用預設 diff + res_after = api_client.get(f"/problem/{problem_id}") + assert res_after.json()["data"]["use_custom_checker"] is False + + +@pytest.mark.django_db +def test_checker_settings_passed_to_sandbox(api_client, teacher, course): + """Test that checker settings are correctly prepared for sandbox submission.""" + from submissions.sandbox_client import submit_to_sandbox + from unittest.mock import patch, MagicMock + + # 建立帶有 custom checker 的題目 + problem = Problems.objects.create( + title="Token Compare", + description="desc", + difficulty="easy", + creator_id=teacher, + course_id=course, + use_custom_checker=True, + checker_name="token", + ) + + # Mock submission + mock_submission = MagicMock() + mock_submission.id = "test-uuid" + mock_submission.problem_id = problem.id + mock_submission.source_code = "print('hello')" + mock_submission.language_type = 2 # Python + + with patch('submissions.sandbox_client.requests.post') as mock_post: + mock_post.return_value.status_code = 202 + mock_post.return_value.json.return_value = {"status": "queued"} + + try: + submit_to_sandbox(mock_submission) + except: + pass # 忽略其他錯誤,只檢查呼叫參數 + + # 驗證傳遞給 Sandbox 的參數 + if mock_post.called: + call_data = mock_post.call_args[1].get('data', {}) + assert call_data.get('use_checker') is True + assert call_data.get('checker_name') == 'token' \ No newline at end of file diff --git a/problems/views/api.py b/problems/views/api.py index b04fa5f..91de6bf 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -1008,6 +1008,9 @@ def _to_list(text: str): 'fillInTemplate': fill_in_template, 'submitCount': submit_count, 'highScore': high_score, + # Custom checker settings + 'use_custom_checker': getattr(problem, 'use_custom_checker', False), + 'checker_name': getattr(problem, 'checker_name', 'diff'), } return api_response(data, "Problem can view.", status_code=200) From 4f676db693c1b86ebc1ed959a6e06998deff34f7 Mon Sep 17 00:00:00 2001 From: Nandy Date: Fri, 26 Dec 2025 19:16:57 +0800 Subject: [PATCH 008/113] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4=20permission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- announcements/views.py | 2 -- courses/views/assign_ta.py | 3 +-- courses/views/course_courseid.py | 3 +-- courses/views/courses.py | 4 +--- courses/views/grade.py | 3 +-- courses/views/homework.py | 3 +-- courses/views/import_csv.py | 3 +-- courses/views/invite.py | 4 +--- courses/views/join.py | 3 +-- courses/views/scoreboard.py | 4 +--- courses/views/summary.py | 2 -- 11 files changed, 9 insertions(+), 25 deletions(-) diff --git a/announcements/views.py b/announcements/views.py index a17d3e8..b3792d9 100644 --- a/announcements/views.py +++ b/announcements/views.py @@ -16,7 +16,6 @@ class CourseAnnouncementBaseView(generics.GenericAPIView): serializer_class = SystemAnnouncementSerializer - permission_classes = [permissions.IsAuthenticated] def get_permissions(self): course_id = self.kwargs.get("course_id") @@ -103,7 +102,6 @@ class AnnouncementCreateView(generics.GenericAPIView): """ serializer_class = AnnouncementCreateSerializer - permission_classes = [permissions.IsAuthenticated] def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) diff --git a/courses/views/assign_ta.py b/courses/views/assign_ta.py index e0ebd40..b6d378d 100644 --- a/courses/views/assign_ta.py +++ b/courses/views/assign_ta.py @@ -1,5 +1,5 @@ from django.contrib.auth import get_user_model -from rest_framework import permissions, status +from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView @@ -17,7 +17,6 @@ class CourseAssignTAView(APIView): - POST /course//assign-ta/ """ - permission_classes = [permissions.IsAuthenticated] serializer_class = CourseAssignTASerializer def post(self, request, course_id): diff --git a/courses/views/course_courseid.py b/courses/views/course_courseid.py index e715a1b..ff078b5 100644 --- a/courses/views/course_courseid.py +++ b/courses/views/course_courseid.py @@ -1,7 +1,7 @@ from collections.abc import Mapping from django.contrib.auth import get_user_model from django.db import transaction -from rest_framework import generics, permissions, status +from rest_framework import generics, status from rest_framework.response import Response from ..common.responses import api_response @@ -22,7 +22,6 @@ class CourseDetailView(generics.GenericAPIView): - 其他課程需為課程成員或管理員 """ - permission_classes = [permissions.IsAuthenticated] serializer_class = CourseDetailSerializer def get(self, request, course_id, *args, **kwargs): diff --git a/courses/views/courses.py b/courses/views/courses.py index eada459..7346703 100644 --- a/courses/views/courses.py +++ b/courses/views/courses.py @@ -1,5 +1,5 @@ from django.db.models import Q -from rest_framework import generics, permissions, status +from rest_framework import generics, status from rest_framework.exceptions import ErrorDetail from ..common.responses import api_response @@ -20,8 +20,6 @@ class CourseListCreateView(generics.GenericAPIView): - DELETE /course/ 刪除課程(擁有者/管理員) """ - permission_classes = [permissions.IsAuthenticated] - def get_serializer_class(self): if self.request.method == "GET": return CourseListSerializer diff --git a/courses/views/grade.py b/courses/views/grade.py index 58cfdac..7261a47 100644 --- a/courses/views/grade.py +++ b/courses/views/grade.py @@ -1,5 +1,5 @@ from django.contrib.auth import get_user_model -from rest_framework import permissions, status +from rest_framework import status from rest_framework.views import APIView from ..common.responses import api_response @@ -22,7 +22,6 @@ class CourseGradeView(APIView): - POST /course//grade// 新增成績(教師/助教/管理員) """ - permission_classes = [permissions.IsAuthenticated] serializer_class = CourseGradeListSerializer create_serializer_class = CourseGradeCreateSerializer delete_serializer_class = CourseGradeDeleteSerializer diff --git a/courses/views/homework.py b/courses/views/homework.py index c589bed..b97862b 100644 --- a/courses/views/homework.py +++ b/courses/views/homework.py @@ -1,5 +1,5 @@ from rest_framework.views import APIView -from rest_framework import status, permissions +from rest_framework import status from ..common.responses import api_response from courses.models import Courses, Course_members @@ -12,7 +12,6 @@ def is_teacher_or_ta(user, course) -> bool: return Course_members.objects.filter(course_id=course, user_id=user, role=Course_members.Role.TA).exists() class CourseHomeworkListByIdView(APIView): - permission_classes = [permissions.IsAuthenticated] def get(self, request, course_id): try: diff --git a/courses/views/import_csv.py b/courses/views/import_csv.py index e5bcf6e..5965ceb 100644 --- a/courses/views/import_csv.py +++ b/courses/views/import_csv.py @@ -10,7 +10,7 @@ from django.db.models import F from django.utils import timezone from django.utils.crypto import get_random_string -from rest_framework import generics, permissions, status, parsers +from rest_framework import generics, parsers, status from rest_framework.exceptions import ErrorDetail from rest_framework.response import Response @@ -29,7 +29,6 @@ class CourseImportCSVView(generics.GenericAPIView): - POST /course//import-csv """ - permission_classes = [permissions.IsAuthenticated] serializer_class = CourseImportCSVSerializer parser_classes = [parsers.MultiPartParser, parsers.FormParser] diff --git a/courses/views/invite.py b/courses/views/invite.py index 30919a9..bcd3be5 100644 --- a/courses/views/invite.py +++ b/courses/views/invite.py @@ -1,5 +1,5 @@ from django.db import IntegrityError -from rest_framework import permissions, status +from rest_framework import status from rest_framework.views import APIView from ..common.responses import api_response @@ -14,8 +14,6 @@ class CourseInviteCodeView(APIView): - DELETE /course//invite-code/ 刪除課程邀請代碼(教師/管理員) """ - permission_classes = [permissions.IsAuthenticated] - def post(self, request, course_id): course = self._get_course(course_id) if course is None: diff --git a/courses/views/join.py b/courses/views/join.py index cc61102..630389d 100644 --- a/courses/views/join.py +++ b/courses/views/join.py @@ -1,6 +1,6 @@ from django.contrib.auth import get_user_model from django.db import transaction -from rest_framework import generics, permissions, status +from rest_framework import generics, status from rest_framework.exceptions import ErrorDetail from ..common.responses import api_response @@ -16,7 +16,6 @@ class CourseJoinView(generics.GenericAPIView): - POST /course//join """ - permission_classes = [permissions.IsAuthenticated] serializer_class = CourseJoinSerializer def post(self, request, join_code, *args, **kwargs): diff --git a/courses/views/scoreboard.py b/courses/views/scoreboard.py index ae9e011..5155527 100644 --- a/courses/views/scoreboard.py +++ b/courses/views/scoreboard.py @@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model from django.db.models import Count, Max from django.utils import timezone -from rest_framework import permissions, status +from rest_framework import status from rest_framework.views import APIView from ..common.responses import api_response @@ -32,8 +32,6 @@ class CourseScoreboardView(APIView): 403: 無評分權限 """ - permission_classes = [permissions.IsAuthenticated] - def get(self, request, course_id): course = self._get_course(course_id) if course is None: diff --git a/courses/views/summary.py b/courses/views/summary.py index 3368424..1312a4d 100644 --- a/courses/views/summary.py +++ b/courses/views/summary.py @@ -1,7 +1,6 @@ from collections import Counter, defaultdict from django.db.models import Count -from rest_framework import permissions from rest_framework.exceptions import PermissionDenied from rest_framework.views import APIView from ..common.responses import api_response @@ -20,7 +19,6 @@ class CourseSummaryView(APIView): - GET /course/summary 取得所有課程的統計資訊(僅管理員) """ - permission_classes = [permissions.IsAuthenticated] def get(self, request): if getattr(request.user, "identity", None) != "admin": From 567bc58d419558af1b65a22f59bddb5d9609429e Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Fri, 26 Dec 2025 23:10:26 +0800 Subject: [PATCH 009/113] feat: add management commands and seed data script for database initialization --- user/management/__init__.py | 1 + user/management/commands/__init__.py | 1 + user/management/commands/seed_data.py | 747 ++++++++++++++++++++++++++ 3 files changed, 749 insertions(+) create mode 100644 user/management/__init__.py create mode 100644 user/management/commands/__init__.py create mode 100644 user/management/commands/seed_data.py diff --git a/user/management/__init__.py b/user/management/__init__.py new file mode 100644 index 0000000..a94f2a3 --- /dev/null +++ b/user/management/__init__.py @@ -0,0 +1 @@ +# Management package diff --git a/user/management/commands/__init__.py b/user/management/commands/__init__.py new file mode 100644 index 0000000..2c1c7c1 --- /dev/null +++ b/user/management/commands/__init__.py @@ -0,0 +1 @@ +# Management commands diff --git a/user/management/commands/seed_data.py b/user/management/commands/seed_data.py new file mode 100644 index 0000000..c122e23 --- /dev/null +++ b/user/management/commands/seed_data.py @@ -0,0 +1,747 @@ +""" +Django management command to seed the database with demo data. + +Usage: + python manage.py seed_data # Seed with default data + python manage.py seed_data --clear # Clear existing data before seeding + python manage.py seed_data --minimal # Seed with minimal data set +""" + +import random +from decimal import Decimal +from django.core.management.base import BaseCommand +from django.utils import timezone +from django.db import transaction +from datetime import timedelta + +from user.models import User, UserProfile +from courses.models import Courses, Course_members, CourseGrade +from problems.models import Problems, Tags, Problem_tags, Problem_subtasks, Test_cases +from assignments.models import Assignments, Assignment_problems, Assignment_tags +from submissions.models import Submission, SubmissionResult +from editor.models import CodeDraft + + +class Command(BaseCommand): + help = 'Seed the database with demo data for development and demos' + + def add_arguments(self, parser): + parser.add_argument( + '--clear', + action='store_true', + help='Clear existing data before seeding', + ) + parser.add_argument( + '--minimal', + action='store_true', + help='Seed with minimal data set', + ) + + def handle(self, *args, **options): + self.stdout.write(self.style.NOTICE('🌱 Starting database seeding...')) + + if options['clear']: + self.clear_data() + + with transaction.atomic(): + if options['minimal']: + self.seed_minimal() + else: + self.seed_full() + + self.stdout.write(self.style.SUCCESS('✅ Database seeding completed!')) + + def clear_data(self): + """Clear existing demo data.""" + self.stdout.write(self.style.WARNING('🗑️ Clearing existing data...')) + + # Delete in reverse order of dependencies + self._safe_delete(CodeDraft) + self._safe_delete(SubmissionResult) + self._safe_delete(Submission) + self._safe_delete(Assignment_tags) + self._safe_delete(Assignment_problems) + self._safe_delete(Assignments) + self._safe_delete(CourseGrade) + self._safe_delete(Course_members) + self._safe_delete(Test_cases) + self._safe_delete(Problem_subtasks) + self._safe_delete(Problem_tags) + self._safe_delete(Problems) + self._safe_delete(Tags) + self._safe_delete(Courses) + self._safe_delete(UserProfile) + + # Try to delete related auth tables that might reference User + self._safe_delete_by_name('auths', 'EmailVerificationToken') + self._safe_delete_by_name('auths', 'PasswordResetToken') + + # Keep superusers - use raw SQL to avoid cascade issues + try: + User.objects.filter(is_superuser=False).delete() + except Exception as e: + self.stdout.write(self.style.WARNING(f' Warning: Could not delete users via ORM: {e}')) + # Fallback: delete users one by one to avoid cascade issues + from django.db import connection + with connection.cursor() as cursor: + cursor.execute("DELETE FROM user_user WHERE is_superuser = 0") + + self.stdout.write(self.style.SUCCESS(' Data cleared!')) + + def _safe_delete(self, model): + """Safely delete all records from a model, handling missing tables.""" + try: + model.objects.all().delete() + except Exception as e: + self.stdout.write(self.style.WARNING(f' Warning: Could not clear {model.__name__}: {e}')) + + def _safe_delete_by_name(self, app_name, model_name): + """Safely delete records from a model by app and model name.""" + try: + from django.apps import apps + model = apps.get_model(app_name, model_name) + model.objects.all().delete() + except Exception: + pass # Table doesn't exist or model not found, skip silently + + def seed_minimal(self): + """Seed with minimal data for quick testing.""" + admin = self.create_users(admin_only=True) + teacher = self.create_teacher() + students = self.create_students(count=3) + + course = self.create_courses(teachers=[teacher], count=1)[0] + self.add_course_members(course, students) + + tags = self.create_tags() + problems = self.create_problems(course, teacher, tags, count=3) + + self.stdout.write(self.style.SUCCESS(' Minimal data seeded!')) + + def seed_full(self): + """Seed with full demo data.""" + # Create users + admin = self.create_admin() + teachers = self.create_teachers(count=3) + tas = self.create_tas(count=5) + students = self.create_students(count=20) + + # Create tags + tags = self.create_tags() + + # Create courses with members + courses = self.create_courses(teachers=teachers, count=5) + for course in courses: + # Assign random TAs and students + course_tas = random.sample(tas, min(2, len(tas))) + course_students = random.sample(students, min(10, len(students))) + self.add_course_members(course, course_students, course_tas) + + # Create problems for each course + all_problems = [] + for course in courses: + teacher = course.teacher_id + problems = self.create_problems(course, teacher, tags, count=random.randint(5, 10)) + all_problems.extend(problems) + + # Create assignments + for course in courses: + course_problems = [p for p in all_problems if p.course_id == course] + if course_problems: + self.create_assignments(course, course_problems, count=2) + + # Create submissions + for student in students: + self.create_submissions(student, all_problems, count=random.randint(5, 15)) + + # Create code drafts + for student in random.sample(students, min(10, len(students))): + self.create_drafts(student, all_problems, count=random.randint(1, 3)) + + # Create course grades + for course in courses: + self.create_course_grades(course) + + self.stdout.write(self.style.SUCCESS(' Full data seeded!')) + + def create_admin(self): + """Create admin user.""" + admin, created = User.objects.get_or_create( + username='admin', + defaults={ + 'email': 'admin@demo.noj.tw', + 'real_name': '系統管理員', + 'identity': User.Identity.ADMIN, + 'is_staff': True, + 'is_superuser': True, + } + ) + if created: + admin.set_password('admin123') + admin.save() + UserProfile.objects.get_or_create( + user=admin, + defaults={'student_id': 'ADMIN001', 'email_verified': True} + ) + self.stdout.write(f' Created admin: {admin.username}') + return admin + + def create_users(self, admin_only=False): + """Create basic users.""" + return self.create_admin() + + def create_teacher(self): + """Create a single teacher.""" + return self.create_teachers(count=1)[0] + + def create_teachers(self, count=3): + """Create teacher users.""" + teachers = [] + teacher_data = [ + {'username': 'prof_chen', 'real_name': '陳教授', 'email': 'chen@demo.noj.tw'}, + {'username': 'prof_wang', 'real_name': '王教授', 'email': 'wang@demo.noj.tw'}, + {'username': 'prof_lin', 'real_name': '林教授', 'email': 'lin@demo.noj.tw'}, + {'username': 'prof_liu', 'real_name': '劉教授', 'email': 'liu@demo.noj.tw'}, + {'username': 'prof_zhang', 'real_name': '張教授', 'email': 'zhang@demo.noj.tw'}, + ] + + for i, data in enumerate(teacher_data[:count]): + teacher, created = User.objects.get_or_create( + username=data['username'], + defaults={ + 'email': data['email'], + 'real_name': data['real_name'], + 'identity': User.Identity.TEACHER, + 'is_staff': True, + } + ) + if created: + teacher.set_password('teacher123') + teacher.save() + UserProfile.objects.get_or_create( + user=teacher, + defaults={ + 'student_id': f'T{str(i+1).zfill(6)}', + 'email_verified': True, + 'bio': f'{data["real_name"]}的個人簡介' + } + ) + self.stdout.write(f' Created teacher: {teacher.username}') + teachers.append(teacher) + + return teachers + + def create_tas(self, count=5): + """Create teaching assistant users.""" + tas = [] + ta_names = ['助教小明', '助教小華', '助教小美', '助教小強', '助教小芳', + '助教大偉', '助教雅婷', '助教志豪'] + + for i in range(min(count, len(ta_names))): + username = f'ta_{i+1:02d}' + ta, created = User.objects.get_or_create( + username=username, + defaults={ + 'email': f'ta{i+1}@demo.noj.tw', + 'real_name': ta_names[i], + 'identity': User.Identity.STUDENT, # TAs are students with TA role in courses + } + ) + if created: + ta.set_password('ta123456') + ta.save() + UserProfile.objects.get_or_create( + user=ta, + defaults={ + 'student_id': f'TA{str(i+1).zfill(6)}', + 'email_verified': True, + 'bio': f'我是{ta_names[i]},負責課程助教工作' + } + ) + self.stdout.write(f' Created TA: {ta.username}') + tas.append(ta) + + return tas + + def create_students(self, count=20): + """Create student users.""" + students = [] + first_names = ['小明', '小華', '小美', '小強', '小芳', '大偉', '雅婷', '志豪', + '怡君', '俊傑', '佳琪', '承翰', '詩涵', '宗翰', '欣怡', '冠廷', + '雅文', '柏翰', '筱婷', '宇軒', '思妤', '子傑', '品萱', '彥廷'] + last_names = ['王', '李', '張', '劉', '陳', '楊', '黃', '趙', '周', '吳', + '徐', '孫', '馬', '朱', '胡', '郭', '何', '高', '林', '羅'] + + for i in range(count): + first = random.choice(first_names) + last = random.choice(last_names) + username = f'student_{i+1:03d}' + + student, created = User.objects.get_or_create( + username=username, + defaults={ + 'email': f'student{i+1}@demo.noj.tw', + 'real_name': f'{last}{first}', + 'identity': User.Identity.STUDENT, + } + ) + if created: + student.set_password('student123') + student.save() + UserProfile.objects.get_or_create( + user=student, + defaults={ + 'student_id': f'B{111000000 + i}', + 'email_verified': random.choice([True, True, True, False]), # 75% verified + 'bio': random.choice(['', '', '熱愛程式設計!', '正在學習中...', '資工系學生']) + } + ) + self.stdout.write(f' Created student: {student.username}') + students.append(student) + + return students + + def create_tags(self): + """Create problem tags.""" + tag_names = [ + 'Array', 'String', 'Sorting', 'Binary Search', 'Dynamic Programming', + 'Greedy', 'Graph', 'BFS', 'DFS', 'Tree', 'Recursion', 'Stack', + 'Queue', 'Hash Table', 'Two Pointers', 'Linked List', 'Math', + 'Bit Manipulation', 'Backtracking', 'Simulation' + ] + + tags = [] + for name in tag_names: + tag, created = Tags.objects.get_or_create(name=name) + if created: + self.stdout.write(f' Created tag: {name}') + tags.append(tag) + + return tags + + def create_courses(self, teachers, count=5): + """Create courses.""" + courses = [] + course_data = [ + { + 'name': '程式設計(一)', + 'description': '本課程介紹程式設計的基本概念,包括變數、運算子、流程控制、函式等。適合初學者入門。', + 'semester': '上學期', + 'academic_year': '113', + }, + { + 'name': '程式設計(二)', + 'description': '延續程式設計(一),深入探討資料結構、演算法基礎、物件導向程式設計等進階概念。', + 'semester': '下學期', + 'academic_year': '113', + }, + { + 'name': '資料結構', + 'description': '學習各種資料結構:陣列、鏈結串列、堆疊、佇列、樹、圖等,及其應用與演算法分析。', + 'semester': '上學期', + 'academic_year': '113', + }, + { + 'name': '演算法概論', + 'description': '介紹演算法設計與分析技巧,包括分治法、動態規劃、貪婪法、圖論演算法等。', + 'semester': '下學期', + 'academic_year': '113', + }, + { + 'name': '競技程式設計', + 'description': '針對程式競賽的進階訓練課程,涵蓋各種經典題型與解題技巧。', + 'semester': '全年', + 'academic_year': '113', + }, + { + 'name': 'Python 程式設計', + 'description': '使用 Python 學習程式設計,涵蓋基礎語法、資料處理、網路爬蟲等實用技能。', + 'semester': '上學期', + 'academic_year': '114', + }, + ] + + for i, data in enumerate(course_data[:count]): + teacher = teachers[i % len(teachers)] + course, created = Courses.objects.get_or_create( + name=data['name'], + academic_year=data['academic_year'], + semester=data['semester'], + defaults={ + 'description': data['description'], + 'teacher_id': teacher, + 'is_active': True, + 'student_limit': random.choice([30, 40, 50, 60, None]), + } + ) + if created: + # Add teacher as course member + Course_members.objects.create( + course_id=course, + user_id=teacher, + role=Course_members.Role.TEACHER + ) + self.stdout.write(f' Created course: {course.name}') + courses.append(course) + + return courses + + def add_course_members(self, course, students, tas=None): + """Add members to a course.""" + if tas: + for ta in tas: + Course_members.objects.get_or_create( + course_id=course, + user_id=ta, + defaults={'role': Course_members.Role.TA} + ) + + for student in students: + Course_members.objects.get_or_create( + course_id=course, + user_id=student, + defaults={'role': Course_members.Role.STUDENT} + ) + + # Update student count + course.student_count = course.members.filter(role=Course_members.Role.STUDENT).count() + course.save(update_fields=['student_count']) + + def create_problems(self, course, teacher, tags, count=5): + """Create problems for a course.""" + problems = [] + problem_templates = [ + { + 'title': 'Hello World', + 'difficulty': Problems.Difficulty.EASY, + 'description': '# 題目說明\n\n請寫一個程式,輸出 "Hello, World!"。\n\n## 輸入格式\n\n無輸入。\n\n## 輸出格式\n\n輸出一行 `Hello, World!`', + 'sample_input': '', + 'sample_output': 'Hello, World!', + 'hint': '這是最基礎的程式題目,只需要使用輸出函式即可。', + 'tags': ['String'], + }, + { + 'title': '兩數之和', + 'difficulty': Problems.Difficulty.EASY, + 'description': '# 題目說明\n\n給定兩個整數 a 和 b,請計算並輸出它們的和。\n\n## 輸入格式\n\n一行,包含兩個整數 a 和 b,以空格分隔。\n\n## 輸出格式\n\n輸出一行,為 a + b 的結果。', + 'sample_input': '3 5', + 'sample_output': '8', + 'hint': '注意資料型態和輸入格式。', + 'tags': ['Math'], + }, + { + 'title': '最大值', + 'difficulty': Problems.Difficulty.EASY, + 'description': '# 題目說明\n\n給定 N 個整數,請找出其中的最大值。\n\n## 輸入格式\n\n第一行包含一個整數 N。\n第二行包含 N 個整數,以空格分隔。\n\n## 輸出格式\n\n輸出最大的整數。', + 'sample_input': '5\n3 1 4 1 5', + 'sample_output': '5', + 'hint': '可以使用迴圈逐一比較,或使用內建函式。', + 'tags': ['Array'], + }, + { + 'title': '費氏數列', + 'difficulty': Problems.Difficulty.MEDIUM, + 'description': '# 題目說明\n\n費氏數列定義為:F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2)。\n給定 N,請輸出 F(N)。\n\n## 輸入格式\n\n一個整數 N (0 ≤ N ≤ 40)。\n\n## 輸出格式\n\n輸出 F(N) 的值。', + 'sample_input': '10', + 'sample_output': '55', + 'hint': '可以使用遞迴或迴圈,注意效能問題。', + 'tags': ['Recursion', 'Dynamic Programming'], + }, + { + 'title': '二分搜尋', + 'difficulty': Problems.Difficulty.MEDIUM, + 'description': '# 題目說明\n\n給定一個已排序的整數陣列和目標值,請找出目標值在陣列中的索引。如果目標值不存在,回傳 -1。\n\n## 輸入格式\n\n第一行包含兩個整數 N 和 T。\n第二行包含 N 個已排序的整數。\n\n## 輸出格式\n\n輸出目標值的索引(0-based),或 -1。', + 'sample_input': '5 4\n1 2 3 4 5', + 'sample_output': '3', + 'hint': '二分搜尋的時間複雜度為 O(log N)。', + 'tags': ['Binary Search', 'Array'], + }, + { + 'title': '泡沫排序', + 'difficulty': Problems.Difficulty.MEDIUM, + 'description': '# 題目說明\n\n請實作泡沫排序法,將給定的整數陣列由小到大排序。\n\n## 輸入格式\n\n第一行包含一個整數 N。\n第二行包含 N 個整數。\n\n## 輸出格式\n\n輸出排序後的結果,以空格分隔。', + 'sample_input': '5\n5 2 8 1 9', + 'sample_output': '1 2 5 8 9', + 'hint': '泡沫排序的時間複雜度為 O(N²)。', + 'tags': ['Sorting', 'Array'], + }, + { + 'title': '字串反轉', + 'difficulty': Problems.Difficulty.EASY, + 'description': '# 題目說明\n\n給定一個字串,請輸出反轉後的結果。\n\n## 輸入格式\n\n一行字串,長度不超過 1000。\n\n## 輸出格式\n\n輸出反轉後的字串。', + 'sample_input': 'hello', + 'sample_output': 'olleh', + 'hint': '可以使用迴圈或內建函式。', + 'tags': ['String'], + }, + { + 'title': '迷宮路徑', + 'difficulty': Problems.Difficulty.HARD, + 'description': '# 題目說明\n\n給定一個 N×M 的迷宮,0 表示可通行,1 表示障礙。請找出從左上角到右下角的最短路徑長度。\n\n## 輸入格式\n\n第一行包含兩個整數 N 和 M。\n接下來 N 行,每行包含 M 個整數。\n\n## 輸出格式\n\n輸出最短路徑長度,若無法到達則輸出 -1。', + 'sample_input': '3 3\n0 0 0\n1 1 0\n0 0 0', + 'sample_output': '4', + 'hint': '使用 BFS 來找最短路徑。', + 'tags': ['BFS', 'Graph'], + }, + { + 'title': '最長共同子序列', + 'difficulty': Problems.Difficulty.HARD, + 'description': '# 題目說明\n\n給定兩個字串,請找出它們的最長共同子序列長度。\n\n## 輸入格式\n\n兩行,分別為兩個字串。\n\n## 輸出格式\n\n輸出最長共同子序列的長度。', + 'sample_input': 'ABCDGH\nAEDFHR', + 'sample_output': '3', + 'hint': '經典的動態規劃問題,使用二維 DP 表格。', + 'tags': ['Dynamic Programming', 'String'], + }, + { + 'title': '括號匹配', + 'difficulty': Problems.Difficulty.MEDIUM, + 'description': '# 題目說明\n\n給定一個只包含 ()[]{}的字串,判斷括號是否正確匹配。\n\n## 輸入格式\n\n一行字串。\n\n## 輸出格式\n\n如果匹配正確,輸出 Yes;否則輸出 No。', + 'sample_input': '({[]})', + 'sample_output': 'Yes', + 'hint': '使用堆疊來解決這個問題。', + 'tags': ['Stack', 'String'], + }, + ] + + selected = random.sample(problem_templates, min(count, len(problem_templates))) + + for template in selected: + problem = Problems.objects.create( + title=template['title'], + difficulty=template['difficulty'], + description=template['description'], + sample_input=template['sample_input'], + sample_output=template['sample_output'], + hint=template.get('hint', ''), + creator_id=teacher, + course_id=course, + is_public=random.choice([ + Problems.Visibility.PUBLIC, + Problems.Visibility.COURSE, + Problems.Visibility.HIDDEN, + ]), + max_score=100, + total_submissions=random.randint(0, 100), + accepted_submissions=0, # Will be calculated + ) + + # Set accepted submissions (should be <= total) + problem.accepted_submissions = random.randint(0, problem.total_submissions) + problem.recompute_acceptance_rate(save=True) + + # Add tags + for tag_name in template.get('tags', []): + tag = next((t for t in tags if t.name == tag_name), None) + if tag: + Problem_tags.objects.create(problem_id=problem, tag_id=tag, added_by=teacher) + tag.usage_count += 1 + tag.save() + + # Create subtasks and test cases + self.create_subtasks_and_testcases(problem) + + self.stdout.write(f' Created problem: {problem.title}') + problems.append(problem) + + return problems + + def create_subtasks_and_testcases(self, problem): + """Create subtasks and test cases for a problem.""" + num_subtasks = random.randint(1, 3) + + for subtask_no in range(1, num_subtasks + 1): + subtask = Problem_subtasks.objects.create( + problem_id=problem, + subtask_no=subtask_no, + weight=100 // num_subtasks, + time_limit_ms=random.choice([1000, 2000, 3000]), + memory_limit_mb=random.choice([256, 512]), + ) + + # Create test cases for this subtask + num_testcases = random.randint(2, 5) + for idx in range(1, num_testcases + 1): + Test_cases.objects.create( + subtask_id=subtask, + idx=idx, + input_path=f'testcases/{problem.id}/{subtask_no}/{idx}.in', + output_path=f'testcases/{problem.id}/{subtask_no}/{idx}.out', + input_size=random.randint(10, 1000), + output_size=random.randint(1, 100), + status='draft', + ) + + def create_assignments(self, course, problems, count=2): + """Create assignments for a course.""" + now = timezone.now() + + assignment_titles = [ + '第一週作業:基礎練習', + '第二週作業:流程控制', + '第三週作業:函式練習', + '期中考練習題', + '進階挑戰題', + '期末專題作業', + ] + + assignments = [] + for i in range(count): + start = now - timedelta(days=random.randint(0, 30)) + due = start + timedelta(days=random.randint(7, 14)) + + assignment = Assignments.objects.create( + title=assignment_titles[i % len(assignment_titles)], + description=f'這是課程「{course.name}」的作業,請在期限內完成。', + course=course, + creator=course.teacher_id, + start_time=start, + due_time=due, + late_penalty=random.choice([Decimal('0'), Decimal('10'), Decimal('20')]), + max_attempts=-1, + visibility=Assignments.Visibility.COURSE_ONLY, + status=random.choice([Assignments.Status.ACTIVE, Assignments.Status.DRAFT]), + ) + + # Add problems to assignment + selected_problems = random.sample(problems, min(3, len(problems))) + for order, prob in enumerate(selected_problems, 1): + Assignment_problems.objects.create( + assignment=assignment, + problem=prob, + order_index=order, + weight=Decimal('1.00'), + partial_score=True, + ) + + self.stdout.write(f' Created assignment: {assignment.title}') + assignments.append(assignment) + + return assignments + + def create_submissions(self, student, problems, count=10): + """Create submissions for a student.""" + code_samples = { + 0: '''#include + +int main() { + printf("Hello, World!\\n"); + return 0; +}''', + 1: '''#include +using namespace std; + +int main() { + int a, b; + cin >> a >> b; + cout << a + b << endl; + return 0; +}''', + 2: '''def main(): + n = int(input()) + nums = list(map(int, input().split())) + print(max(nums)) + +if __name__ == "__main__": + main()''', + 3: '''import java.util.Scanner; + +public class Main { + public static void main(String[] args) { + Scanner sc = new Scanner(System.in); + int n = sc.nextInt(); + System.out.println(n * 2); + } +}''', + } + + status_weights = [ + ('0', 40), # Accepted - 40% + ('1', 25), # Wrong Answer - 25% + ('-1', 10), # Pending - 10% + ('3', 10), # TLE - 10% + ('5', 10), # Runtime Error - 10% + ('2', 5), # Compilation Error - 5% + ] + + submissions = [] + selected_problems = random.sample(problems, min(count, len(problems))) + + for problem in selected_problems: + lang = random.randint(0, 3) + code = code_samples.get(lang, code_samples[2]) + + status = random.choices( + [s[0] for s in status_weights], + weights=[s[1] for s in status_weights], + k=1 + )[0] + + score = 0 + if status == '0': # Accepted + score = problem.max_score + elif status == '1': # Wrong Answer + score = random.randint(0, problem.max_score - 1) + + submission = Submission.objects.create( + problem_id=problem.id, + user=student, + language_type=lang, + source_code=code, + status=status, + score=score, + max_score=problem.max_score, + execution_time=random.randint(10, 2000) if status != '-1' else -1, + memory_usage=random.randint(1000, 50000) if status != '-1' else -1, + is_late=random.random() < 0.1, # 10% chance of being late + attempt_number=random.randint(1, 5), + ) + + # Update problem statistics + problem.total_submissions += 1 + if status == '0': + problem.accepted_submissions += 1 + problem.recompute_acceptance_rate(save=True) + + submissions.append(submission) + + return submissions + + def create_drafts(self, student, problems, count=2): + """Create code drafts for a student.""" + drafts = [] + selected = random.sample(problems, min(count, len(problems))) + + for problem in selected: + lang = random.randint(0, 3) + draft = CodeDraft.objects.create( + user=student, + problem_id=problem.id, + language_type=lang, + source_code=f'# Draft for problem {problem.id}\n# Work in progress...\n', + auto_saved=random.choice([True, False]), + ) + drafts.append(draft) + + return drafts + + def create_course_grades(self, course): + """Create course grades for students.""" + students = Course_members.objects.filter( + course_id=course, + role=Course_members.Role.STUDENT + ).values_list('user_id', flat=True) + + for student_id in students[:5]: # Only create for some students + student = User.objects.get(pk=student_id) + CourseGrade.objects.create( + course=course, + student=student, + title='期中成績', + content='包含作業和小考成績', + score={ + 'homework': random.randint(60, 100), + 'quiz': random.randint(50, 100), + 'midterm': random.randint(40, 100), + 'total': random.randint(50, 100), + } + ) From bef3afef8b8f74a444781b12691883fb8f2e84a5 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Fri, 26 Dec 2025 23:20:23 +0800 Subject: [PATCH 010/113] feat: simplify user creation in seed_minimal and seed_full methods --- user/management/commands/seed_data.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/user/management/commands/seed_data.py b/user/management/commands/seed_data.py index c122e23..a02ed5c 100644 --- a/user/management/commands/seed_data.py +++ b/user/management/commands/seed_data.py @@ -106,7 +106,7 @@ def _safe_delete_by_name(self, app_name, model_name): def seed_minimal(self): """Seed with minimal data for quick testing.""" - admin = self.create_users(admin_only=True) + self.create_users(admin_only=True) teacher = self.create_teacher() students = self.create_students(count=3) @@ -114,14 +114,14 @@ def seed_minimal(self): self.add_course_members(course, students) tags = self.create_tags() - problems = self.create_problems(course, teacher, tags, count=3) + self.create_problems(course, teacher, tags, count=3) self.stdout.write(self.style.SUCCESS(' Minimal data seeded!')) def seed_full(self): """Seed with full demo data.""" # Create users - admin = self.create_admin() + self.create_admin() teachers = self.create_teachers(count=3) tas = self.create_tas(count=5) students = self.create_students(count=20) From e11416228d3fb6a9d375786828332dd392ed5c9f Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Fri, 26 Dec 2025 23:30:03 +0800 Subject: [PATCH 011/113] feat: add submission results creation and output/error message generation for test cases --- user/management/commands/seed_data.py | 84 +++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/user/management/commands/seed_data.py b/user/management/commands/seed_data.py index a02ed5c..85df1d4 100644 --- a/user/management/commands/seed_data.py +++ b/user/management/commands/seed_data.py @@ -696,6 +696,10 @@ def create_submissions(self, student, problems, count=10): attempt_number=random.randint(1, 5), ) + # Create submission results for each test case + if status != '-1': # Only create results if not pending + self.create_submission_results(submission, problem, status) + # Update problem statistics problem.total_submissions += 1 if status == '0': @@ -706,6 +710,86 @@ def create_submissions(self, student, problems, count=10): return submissions + def create_submission_results(self, submission, problem, overall_status): + """Create submission results for each test case of a problem.""" + # Get test cases for this problem + subtasks = Problem_subtasks.objects.filter(problem_id=problem) + test_cases = Test_cases.objects.filter(subtask_id__in=subtasks).order_by('subtask_id', 'idx') + + if not test_cases.exists(): + return + + # Map overall status to result statuses + result_status_map = { + '0': 'accepted', # AC + '1': 'wrong_answer', # WA + '3': 'time_limit_exceeded',# TLE + '4': 'memory_limit_exceeded', # MLE + '5': 'runtime_error', # RE + } + + total_test_cases = test_cases.count() + score_per_case = submission.max_score // total_test_cases if total_test_cases > 0 else 0 + + for idx, test_case in enumerate(test_cases, 1): + # Determine status for this test case + if overall_status == '0': + # All test cases passed + tc_status = 'accepted' + tc_score = score_per_case + solve_status = 'solved' + elif overall_status == '2': + # Compilation error - no results + continue + else: + # Randomly decide if this test case passed or failed + if random.random() < 0.5: + tc_status = 'accepted' + tc_score = score_per_case + solve_status = 'solved' + else: + tc_status = result_status_map.get(overall_status, 'wrong_answer') + tc_score = 0 + solve_status = 'unsolved' + + SubmissionResult.objects.create( + problem_id=problem.id, + submission=submission, + test_case_id=test_case.id, + test_case_index=idx, + status=tc_status, + execution_time=random.randint(10, 500) if tc_status != 'time_limit_exceeded' else random.randint(1000, 3000), + memory_usage=random.randint(1000, 30000) if tc_status != 'memory_limit_exceeded' else random.randint(256000, 512000), + score=tc_score, + max_score=score_per_case, + output_preview=self._generate_output_preview(tc_status), + error_message=self._generate_error_message(tc_status), + solve_status=solve_status, + ) + + def _generate_output_preview(self, status): + """Generate sample output preview based on status.""" + if status == 'accepted': + return random.choice(['5', 'Hello, World!', '8', 'Yes', '55']) + elif status == 'wrong_answer': + return random.choice(['4', 'hello world', '7', 'No', '54']) + return None + + def _generate_error_message(self, status): + """Generate error message based on status.""" + if status == 'runtime_error': + return random.choice([ + 'Segmentation fault (core dumped)', + 'IndexError: list index out of range', + 'java.lang.ArrayIndexOutOfBoundsException', + 'RuntimeError: maximum recursion depth exceeded', + ]) + elif status == 'time_limit_exceeded': + return 'Time limit exceeded' + elif status == 'memory_limit_exceeded': + return 'Memory limit exceeded' + return None + def create_drafts(self, student, problems, count=2): """Create code drafts for a student.""" drafts = [] From 4d49a69b7dc507a005483e4678833cbaaf8ae640 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Fri, 26 Dec 2025 23:34:21 +0800 Subject: [PATCH 012/113] Update user/management/commands/seed_data.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- user/management/commands/seed_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user/management/commands/seed_data.py b/user/management/commands/seed_data.py index 85df1d4..afd708e 100644 --- a/user/management/commands/seed_data.py +++ b/user/management/commands/seed_data.py @@ -759,7 +759,7 @@ def create_submission_results(self, submission, problem, overall_status): test_case_index=idx, status=tc_status, execution_time=random.randint(10, 500) if tc_status != 'time_limit_exceeded' else random.randint(1000, 3000), - memory_usage=random.randint(1000, 30000) if tc_status != 'memory_limit_exceeded' else random.randint(256000, 512000), + memory_usage=random.randint(256000, 512000) if tc_status == 'memory_limit_exceeded' else random.randint(1000, 30000), score=tc_score, max_score=score_per_case, output_preview=self._generate_output_preview(tc_status), From 3ce0e4214c85cd1301e00582454255c508afdb1f Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Fri, 26 Dec 2025 23:45:21 +0800 Subject: [PATCH 013/113] feat: update submission result probabilities and error handling in test cases --- user/management/commands/seed_data.py | 38 ++++++++++++++++++--------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/user/management/commands/seed_data.py b/user/management/commands/seed_data.py index 85df1d4..5d0bafd 100644 --- a/user/management/commands/seed_data.py +++ b/user/management/commands/seed_data.py @@ -655,12 +655,12 @@ def create_submissions(self, student, problems, count=10): } status_weights = [ - ('0', 40), # Accepted - 40% + ('0', 45), # Accepted - 45% ('1', 25), # Wrong Answer - 25% - ('-1', 10), # Pending - 10% ('3', 10), # TLE - 10% ('5', 10), # Runtime Error - 10% - ('2', 5), # Compilation Error - 5% + ('2', 7), # Compilation Error - 7% + ('-1', 3), # Pending - 3% (少數情況) ] submissions = [] @@ -721,11 +721,12 @@ def create_submission_results(self, submission, problem, overall_status): # Map overall status to result statuses result_status_map = { - '0': 'accepted', # AC - '1': 'wrong_answer', # WA - '3': 'time_limit_exceeded',# TLE + '0': 'accepted', # AC + '1': 'wrong_answer', # WA + '2': 'wrong_answer', # CE - 標記為 wrong_answer (編譯失敗) + '3': 'time_limit_exceeded', # TLE '4': 'memory_limit_exceeded', # MLE - '5': 'runtime_error', # RE + '5': 'runtime_error', # RE } total_test_cases = test_cases.count() @@ -738,19 +739,30 @@ def create_submission_results(self, submission, problem, overall_status): tc_status = 'accepted' tc_score = score_per_case solve_status = 'solved' + error_msg = None elif overall_status == '2': - # Compilation error - no results - continue + # Compilation error - all test cases fail with no execution + tc_status = 'wrong_answer' + tc_score = 0 + solve_status = 'unsolved' + error_msg = random.choice([ + "error: expected ';' before '}' token", + "SyntaxError: invalid syntax", + "error: 'cout' was not declared in this scope", + "IndentationError: unexpected indent", + ]) else: # Randomly decide if this test case passed or failed - if random.random() < 0.5: + if random.random() < 0.4: # 40% chance of passing tc_status = 'accepted' tc_score = score_per_case solve_status = 'solved' + error_msg = None else: tc_status = result_status_map.get(overall_status, 'wrong_answer') tc_score = 0 solve_status = 'unsolved' + error_msg = self._generate_error_message(tc_status) SubmissionResult.objects.create( problem_id=problem.id, @@ -758,12 +770,12 @@ def create_submission_results(self, submission, problem, overall_status): test_case_id=test_case.id, test_case_index=idx, status=tc_status, - execution_time=random.randint(10, 500) if tc_status != 'time_limit_exceeded' else random.randint(1000, 3000), + execution_time=random.randint(10, 500) if tc_status not in ['time_limit_exceeded'] else random.randint(1000, 3000), memory_usage=random.randint(1000, 30000) if tc_status != 'memory_limit_exceeded' else random.randint(256000, 512000), score=tc_score, max_score=score_per_case, - output_preview=self._generate_output_preview(tc_status), - error_message=self._generate_error_message(tc_status), + output_preview=self._generate_output_preview(tc_status) if overall_status != '2' else None, + error_message=error_msg, solve_status=solve_status, ) From b6672bce7f6e81b566465ca1254c00c2c0e28112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=A5=95=E5=A9=B7?= Date: Sat, 27 Dec 2025 00:06:47 +0800 Subject: [PATCH 014/113] fix: remove per-view IsAuthenticated to enforce email verification --- assignments/views.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/assignments/views.py b/assignments/views.py index 8288519..428e2bd 100644 --- a/assignments/views.py +++ b/assignments/views.py @@ -4,7 +4,6 @@ from django.http import Http404 from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated from rest_framework import status, permissions from django.db.models import Max,Sum from django.db.models import Sum, Count, Max, Q @@ -83,8 +82,6 @@ def api_response(data=None, message="OK", status_code=200): # --------- POST /homework/ --------- class HomeworkCreateView(APIView): - permission_classes = [IsAuthenticated] - @transaction.atomic def post(self, request): ser = HomeworkCreateSerializer(data=request.data) @@ -125,8 +122,6 @@ def post(self, request): # --------- GET/PUT/DELETE /homework/ --------- class HomeworkDetailView(APIView): - permission_classes = [IsAuthenticated] - def _get_hw(self, pk: int) -> Assignments: try: return Assignments.objects.select_related("course").get(pk=pk) @@ -267,8 +262,6 @@ def status_text(code: str): # --------- NEW: GET /course//homework --------- class CourseHomeworkListView(APIView): - permission_classes = [IsAuthenticated] - def get(self, request, course_id): try: course = Courses.objects.get(pk=course_id) # UUID or int 都能吃 @@ -315,7 +308,6 @@ class AddProblemsToHomeworkView(APIView): 404: "homework not exists" 400: 參數錯誤(如題目皆不存在) """ - permission_classes = [permissions.IsAuthenticated] def post(self, request, homework_id: int): # 1) 找作業 @@ -424,7 +416,6 @@ class HomeworkDeadlineUpdateAPIView(APIView): "end": "2025-12-31T23:59:59Z" # ISO 8601, 或 null } """ - permission_classes = [IsAuthenticated] def put(self, request, homework_id: int): # 1) 先把作業抓出來,連同 course 一次 select_related @@ -497,8 +488,6 @@ class HomeworkDeadlineView(APIView): - 新增欄位:is_overdue, server_time """ - permission_classes = [permissions.IsAuthenticated] - def get(self, request, homework_id: int): # 1) 先把作業抓出來,連同 course 一次 select_related homework = get_object_or_404( @@ -582,8 +571,6 @@ class HomeworkStatsView(APIView): } """ - permission_classes = [IsAuthenticated] - def get(self, request, homework_id: int): # 1) 找作業 try: @@ -786,7 +773,6 @@ class HomeworkScoreboardView(APIView): GET /homework//scoreboard/ 取得某份作業的排行榜 """ - permission_classes = [IsAuthenticated] def get_assignment(self, homework_id: int) -> Assignments: return get_object_or_404( @@ -952,8 +938,6 @@ class HomeworkSubmissionsListView(APIView): - ?user_id=(只有老師/TA可用) - ?status= (Submission.status 篩選,例如 '0','1','-1'...) """ - permission_classes = [IsAuthenticated] - def get(self, request, homework_id: int): # 1) 找 homework + course hw = get_object_or_404( From 4a719a3b1e6395dad262e1013dac3910cfa9de96 Mon Sep 17 00:00:00 2001 From: RokuSennyou Date: Sat, 27 Dec 2025 01:18:15 +0800 Subject: [PATCH 015/113] feat: add email field to ForgotPasswordSerializer and update ForgotPasswordView logic --- auths/serializers/password.py | 1 + auths/views/password.py | 30 ++++++++++-------------------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/auths/serializers/password.py b/auths/serializers/password.py index 1db88cd..4af0aa7 100644 --- a/auths/serializers/password.py +++ b/auths/serializers/password.py @@ -34,6 +34,7 @@ def save(self, **kwargs): class ForgotPasswordSerializer(serializers.Serializer): username = serializers.CharField(max_length=150, required=True) + email = serializers.EmailField() class ResetPasswordSerializer(serializers.Serializer): diff --git a/auths/views/password.py b/auths/views/password.py index 79ba254..eec3130 100644 --- a/auths/views/password.py +++ b/auths/views/password.py @@ -78,7 +78,7 @@ def post(self, request): class ForgotPasswordView(APIView): """ POST /auth/forgot-password/ - 使用者輸入 username,如果有「已驗證的 email」就寄出重設密碼信。 + 使用者輸入 username + email,兩者都正確且 email 已驗證才寄出重設密碼信。 """ permission_classes = [AllowAny] skip_email_verification = True @@ -90,15 +90,14 @@ def post(self, request): serializer.is_valid(raise_exception=True) username = serializer.validated_data["username"] + email_input = serializer.validated_data["email"] - # 1) 找 user - try: - user = User.objects.get(username=username) - except User.DoesNotExist: + user = User.objects.filter(username=username, email__iexact=email_input).first() + if user is None: return api_response( data=None, - message="找不到此使用者。", - status_code=status.HTTP_404_NOT_FOUND, + message="帳號或信箱不正確。", + status_code=status.HTTP_400_BAD_REQUEST, ) # 2) 檢查是否有已驗證 email @@ -111,9 +110,7 @@ def post(self, request): status_code=status.HTTP_400_BAD_REQUEST, ) - email = getattr(user, "email", None) - - if not email or not profile.email_verified: + if not user.email or not profile.email_verified: return api_response( data=None, message="此帳號尚未綁定或驗證信箱,無法使用密碼恢復,請聯絡管理員。", @@ -126,22 +123,15 @@ def post(self, request): expires_at = now + timezone.timedelta(minutes=RESET_TOKEN_LIFETIME_MINUTES) with transaction.atomic(): - PasswordResetToken.objects.filter(user=user, used=False).update( - used=True, - ) - - PasswordResetToken.objects.create( - user=user, - token=token, - expires_at=expires_at, - ) + PasswordResetToken.objects.filter(user=user, used=False).update(used=True) + PasswordResetToken.objects.create(user=user, token=token, expires_at=expires_at) # 4) 組出前端重設密碼連結 frontend_base = getattr(settings, "FRONTEND_BASE_URL", "http://localhost:3000") reset_url = f"{frontend_base}/reset-password?token={token}" # 5) 寄信 - send_password_reset_email(to_email=email, reset_url=reset_url) + send_password_reset_email(to_email=user.email, reset_url=reset_url) return api_response( data=None, From db459497c42cd946005d905a9a980a22992e0f11 Mon Sep 17 00:00:00 2001 From: RokuSennyou Date: Sat, 27 Dec 2025 01:37:07 +0800 Subject: [PATCH 016/113] docs: update forgot password API READKE to require both username and email --- docs/user.MD | 53 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/docs/user.MD b/docs/user.MD index 7359e32..534142c 100644 --- a/docs/user.MD +++ b/docs/user.MD @@ -1235,7 +1235,7 @@ curl -X POST "http://127.0.0.1:8000/auth/change-password/" \ ## POST /auth/forgot-password/ **說明**: -使用者輸入自己的 `username`。若該帳號已綁定且**已驗證**信箱,系統會寄出「重設密碼」信件到 `User.email`(或你專案綁定信箱欄位),信內包含可用來重設密碼的連結(含一次性 token)。 +使用者需同時輸入正確的 `username` 與 `email`。若兩者皆正確,且該帳號已綁定並**完成信箱驗證**,系統將寄出一封「重設密碼」信件至該帳號的信箱。信件中包含一組一次性且有時效性的重設密碼連結(含 token)。 > 典型使用時機:登入頁面點選「忘記密碼」後呼叫。 @@ -1253,32 +1253,48 @@ curl -X POST "http://127.0.0.1:8000/auth/change-password/" \ #### Body(JSON) -| 欄位 | 型別 | 必填 | 說明 | -| ---------- | ------ | :-: | ----- | -| `username` | string | ✅ | 使用者帳號 | +| 欄位 | 型別 | 必填 | 說明 | +| ---------- | ------ | :-: | ------------ | +| `username` | string | ✅ | 使用者帳號 | +| `email` | string | ✅ | 與帳號綁定的電子郵件信箱 | #### 範例 ```json -{ "username": "momomo" } +{ + "username": "momomo", + "email": "momomo@example.com" +} ``` +--- + ### Success Response * **Status**:`200 OK` ```json -{ "data": null, "message": "已寄出重設密碼信,請至驗證信箱查收。", "status": "ok" } +{ + "data": null, + "message": "已寄出重設密碼信,請至驗證信箱查收。", + "status": "ok" +} ``` +--- + ### Error Responses -#### 找不到使用者 +#### 帳號或信箱不正確 -* **Status**:`404 Not Found` +* **Status**:`400 Bad Request` ```json -{ "data": null, "message": "找不到此使用者。", "status": "error" } +{ + "data": null, + "message": "帳號或信箱不正確。", + "status": "error" +} ``` #### 尚未綁定或尚未驗證信箱 @@ -1286,24 +1302,35 @@ curl -X POST "http://127.0.0.1:8000/auth/change-password/" \ * **Status**:`400 Bad Request` ```json -{ "data": null, "message": "此帳號尚未綁定或驗證信箱,無法使用密碼恢復。", "status": "error" } +{ + "data": null, + "message": "此帳號尚未綁定或驗證信箱,無法使用密碼恢復。", + "status": "error" +} ``` +--- + ### Curl 範例 ```bash curl -X POST "http://127.0.0.1:8000/auth/forgot-password/" \ -H "Content-Type: application/json" \ - -d '{"username":"momomo"}' + -d '{ + "username": "momomo", + "email": "momomo@example.com" + }' ``` +--- + ### 寄信內容(概念) -信件會包含重設連結,通常類似: +信件中會包含一組用於重設密碼的連結,格式通常如下: * `http://localhost:3000/reset-password?token=` -其中 `` 為一次性 token,需在有效期限內使用。 +其中 `` 為一次性且具有效期限的重設密碼 token,僅能使用一次。 --- From ca2e459ce417c610f383361ddeb763dc72db20a9 Mon Sep 17 00:00:00 2001 From: RokuSennyou <167313316+RokuSennyou@users.noreply.github.com> Date: Sat, 27 Dec 2025 01:44:30 +0800 Subject: [PATCH 017/113] Update auths/serializers/password.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- auths/serializers/password.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auths/serializers/password.py b/auths/serializers/password.py index 4af0aa7..61b9898 100644 --- a/auths/serializers/password.py +++ b/auths/serializers/password.py @@ -34,7 +34,7 @@ def save(self, **kwargs): class ForgotPasswordSerializer(serializers.Serializer): username = serializers.CharField(max_length=150, required=True) - email = serializers.EmailField() + email = serializers.EmailField(required=True) class ResetPasswordSerializer(serializers.Serializer): From a3ef0b7c749354a80330b5bbbf2db0eddb89bb44 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sat, 27 Dec 2025 02:00:02 +0800 Subject: [PATCH 018/113] feat: Add some limit in POST submission API but haven't implement IP balcklist. --- submissions/views.py | 123 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 117 insertions(+), 6 deletions(-) diff --git a/submissions/views.py b/submissions/views.py index ec929e1..207d80e 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -400,6 +400,8 @@ class SubmissionListCreateView(BasePermissionMixin, generics.ListCreateAPIView): """ GET /submission/ - 獲取提交列表 POST /submission/ - 創建新提交 + POST /submission/ - 還沒實作黑名單 + """ permission_classes = [permissions.IsAuthenticated] @@ -413,6 +415,27 @@ def get_queryset(self): queryset = Submission.objects.select_related('user').order_by('-created_at') return self.get_viewable_submissions(self.request.user, queryset) + def get_client_ip(self, request): + """獲取客戶端 IP""" + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0].strip() + else: + ip = request.META.get('REMOTE_ADDR', 'unknown') + return ip + + def is_ip_blacklisted(self, ip): + """檢查 IP 是否在黑名單中""" + # TODO: 實作 IP 黑名單邏輯 + # 可以從資料庫、Redis 或配置文件讀取黑名單 + # 範例: + # from django.core.cache import cache + # blacklist = cache.get('ip_blacklist', set()) + # return ip in blacklist + + # 目前暫時返回 False(不阻擋任何 IP) + return False + def post(self, request, *args, **kwargs): """創建新提交 (NOJ 兼容版本)""" @@ -447,12 +470,100 @@ def post(self, request, *args, **kwargs): # 其他驗證錯誤 return api_response(data=None, message="invalid data!", status_code=status.HTTP_400_BAD_REQUEST) - # TODO: 添加其他 NOJ 檢查 - # - problem permission denied - # - homework hasn't start - # - Invalid IP address - # - quota check: "you have used all your quotas" - # - rate limiting: "Submit too fast!" + waitFor + # 額外的安全檢查 + problem_id = serializer.validated_data['problem_id'] + user = request.user + + # 1. Rate Limiting - 提交速率限制(每分鐘最多 10 次) + from django.core.cache import cache + rate_limit_key = f"submission_rate:{user.id}" + current_count = cache.get(rate_limit_key, 0) + + if current_count >= 10: + # 計算等待時間 + ttl = cache.ttl(rate_limit_key) + wait_for = max(ttl, 1) if ttl else 60 + return api_response( + data={'waitFor': wait_for}, + message="Submit too fast!", + status_code=status.HTTP_429_TOO_MANY_REQUESTS + ) + + # 增加計數 + cache.set(rate_limit_key, current_count + 1, 60) # 60 秒過期 + + # 2. IP 黑名單檢查 + client_ip = self.get_client_ip(request) + if self.is_ip_blacklisted(client_ip): + logger.warning(f'Blocked submission from blacklisted IP: {client_ip}') + return api_response( + data=None, + message="Invalid IP address", + status_code=status.HTTP_403_FORBIDDEN + ) + + # 3. Quota 檢查 - 檢查用戶是否還有提交配額 + from .models import UserProblemQuota + try: + quota = UserProblemQuota.objects.get( + user=user, + problem_id=problem_id, + assignment_id__isnull=True # 全域配額 + ) + if quota.remaining_attempts == 0: + return api_response( + data=None, + message="you have used all your quotas", + status_code=status.HTTP_403_FORBIDDEN + ) + # 減少配額(如果不是無限制) + if quota.remaining_attempts > 0: + quota.remaining_attempts -= 1 + quota.save() + except UserProblemQuota.DoesNotExist: + # 沒有配額限制,允許提交 + pass + + # 4. 題目權限檢查 + from problems.models import Problems + try: + problem = Problems.objects.get(id=problem_id) + + # 檢查題目可見性 + if problem.is_public == 'hidden': + # Hidden 題目只有創建者和管理員可以提交 + if problem.creator_id != user and not user.is_staff: + return api_response( + data=None, + message="problem permission denied", + status_code=status.HTTP_403_FORBIDDEN + ) + elif problem.is_public == 'course': + # Course 題目需要檢查是否在課程中 + from courses.models import Courses + try: + course = problem.course_id + # 檢查用戶是否是課程成員(學生或老師) + if course.teacher_id != user: + # 檢查是否是學生 + from courses.models import Course_students + if not Course_students.objects.filter( + course_id=course, + student_id=user + ).exists(): + return api_response( + data=None, + message="problem permission denied", + status_code=status.HTTP_403_FORBIDDEN + ) + except: + pass + except Problems.DoesNotExist: + return api_response( + data=None, + message="Unexisted problem id.", + status_code=status.HTTP_404_NOT_FOUND + ) submission = serializer.save() From acb17632665b5cafc649019c2adaaf04e60d6f66 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sat, 27 Dec 2025 02:10:01 +0800 Subject: [PATCH 019/113] fix: remove all permission_classes = [IsAuthenticated] and @permission_classes([permissions.IsAuthenticated]). --- submissions/views.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/submissions/views.py b/submissions/views.py index 207d80e..4dc85b1 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -161,7 +161,6 @@ def get_viewable_submissions(self, user, queryset): class EditorialListCreateView(BasePermissionMixin, generics.ListCreateAPIView): """題解列表和創建 API""" - permission_classes = [permissions.IsAuthenticated] def get_queryset(self): problem_id = self.kwargs['problem_id'] @@ -221,7 +220,6 @@ def perform_create(self, serializer): class EditorialDetailView(BasePermissionMixin, generics.RetrieveUpdateDestroyAPIView): """題解詳情、更新和刪除 API""" - permission_classes = [permissions.IsAuthenticated] def get_queryset(self): problem_id = self.kwargs['problem_id'] @@ -274,7 +272,6 @@ def perform_update(self, serializer): @api_view(['POST', 'DELETE']) -@permission_classes([permissions.IsAuthenticated]) def editorial_like_toggle(request, problem_id, solution_id): """題解按讚/取消按讚""" @@ -404,7 +401,6 @@ class SubmissionListCreateView(BasePermissionMixin, generics.ListCreateAPIView): """ - permission_classes = [permissions.IsAuthenticated] def get_serializer_class(self): if self.request.method == 'POST': @@ -621,7 +617,6 @@ class SubmissionRetrieveUpdateView(BasePermissionMixin, generics.RetrieveUpdateA PUT /submission/{id} - 上傳程式碼 """ - permission_classes = [permissions.IsAuthenticated] lookup_field = 'id' def get_serializer_class(self): @@ -725,7 +720,6 @@ def put(self, request, *args, **kwargs): return api_response(data=None, message="can not find the source file", status_code=status.HTTP_400_BAD_REQUEST) @api_view(["GET"]) -@permission_classes([permissions.IsAuthenticated]) def submission_output_view(request, id, task_no, case_no): """ GET /submission/{id}/output/{task_no}/{case_no} @@ -792,7 +786,6 @@ class SubmissionListView(BasePermissionMixin, generics.ListAPIView): """GET /submission/ - 獲取提交列表""" serializer_class = SubmissionListSerializer - permission_classes = [permissions.IsAuthenticated] def get_queryset(self): queryset = Submission.objects.select_related('user').order_by('-created_at') @@ -830,7 +823,6 @@ class SubmissionDetailView(BasePermissionMixin, generics.RetrieveAPIView): """GET /submission/{id} - 獲取提交詳情""" serializer_class = SubmissionDetailSerializer - permission_classes = [permissions.IsAuthenticated] lookup_field = 'id' def get_queryset(self): @@ -861,7 +853,6 @@ class SubmissionCodeView(BasePermissionMixin, generics.RetrieveAPIView): """GET /submission/{id}/code - 獲取提交程式碼""" serializer_class = SubmissionCodeSerializer - permission_classes = [permissions.IsAuthenticated] lookup_field = 'id' def get_queryset(self): @@ -896,7 +887,6 @@ class SubmissionStdoutView(BasePermissionMixin, generics.RetrieveAPIView): """GET /submission/{id}/stdout - 獲取提交標準輸出""" serializer_class = SubmissionStdoutSerializer - permission_classes = [permissions.IsAuthenticated] lookup_field = 'id' def get_queryset(self): @@ -924,7 +914,6 @@ def retrieve(self, request, *args, **kwargs): @api_view(['GET']) -@permission_classes([permissions.IsAuthenticated]) def submission_rejudge(request, id): """GET /submission/{id}/rejudge - 重新判題""" @@ -981,7 +970,6 @@ def submission_rejudge(request, id): @api_view(['GET']) -@permission_classes([permissions.IsAuthenticated]) def ranking_view(request): """GET /ranking - 獲取排行榜""" @@ -1045,7 +1033,6 @@ def ranking_view(request): except Exception as e: return api_response(data=None, message="Some error occurred, please contact the admin", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) @api_view(['GET']) -@permission_classes([permissions.IsAuthenticated]) def user_stats_view(request, user_id): """ GET /stats/user/{userId} - 使用者統計 @@ -1169,7 +1156,6 @@ def get_diff_count(name): @api_view(['POST']) -@permission_classes([permissions.IsAuthenticated]) def submit_custom_test(request, problem_id): """ 提交自定義測試(不存資料庫) @@ -1357,7 +1343,6 @@ def submit_custom_test(request, problem_id): @api_view(['GET']) -@permission_classes([permissions.IsAuthenticated]) def get_custom_test_result(request, custom_test_id): """ 查詢自定義測試結果 From 0b45cb0c7176b0380b3da6df3e4d977e4a178f1a Mon Sep 17 00:00:00 2001 From: chewinggum-master <41247003s@gapps.ntnu.edu.tw> Date: Sat, 27 Dec 2025 02:50:27 +0800 Subject: [PATCH 020/113] =?UTF-8?q?feat:=E5=AD=B8=E7=94=9F=E5=8F=AA?= =?UTF-8?q?=E8=83=BD=E7=9C=8B=E5=88=B0=E6=9C=89=E5=8A=A0=E5=85=A5=E7=9A=84?= =?UTF-8?q?=E8=AA=B2=E7=A8=8B=E7=9A=84problem=E6=88=96publec=E7=9A=84probl?= =?UTF-8?q?em?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- problems/views/api.py | 69 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/problems/views/api.py b/problems/views/api.py index c7a18f6..3e9030d 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -58,6 +58,55 @@ def get_permissions(self): # POST/PUT/PATCH/DELETE 使用全域預設:登入 + email verified return [] + def get_queryset(self): + # 舊前端可能使用 router 路徑:/problem/router/problems + # 這裡強化過濾邏輯,與 ProblemListView 保持一致,避免未加入課程者看到 course-only 題目 + from django.db.models import Q + qs = Problems.objects.all().select_related('creator_id', 'course_id').prefetch_related('tags', 'subtasks') + + # 篩選:difficulty + difficulty = self.request.query_params.get('difficulty') + if difficulty: + qs = qs.filter(difficulty=difficulty) + + # 篩選:is_public(僅接受既定值) + visibility = self.request.query_params.get('is_public') + if visibility in ('public', 'course', 'hidden'): + qs = qs.filter(is_public=visibility) + + # 篩選:course_id(帶入時需課程成員或管理員/教師) + course_id = self.request.query_params.get('course_id') + user = getattr(self.request, 'user', None) + if course_id: + qs = qs.filter(course_id=course_id) + if not getattr(user, 'is_authenticated', False): + # 直接擋下,避免洩漏課程題目清單 + return Problems.objects.none() + is_staff_like = bool(getattr(user, 'is_staff', False) or getattr(user, 'is_superuser', False) or getattr(user, 'identity', None) in ['admin', 'teacher']) + if not is_staff_like: + try: + cid = int(course_id) + except (TypeError, ValueError): + cid = None + from courses.models import Course_members + is_member = cid is not None and Course_members.objects.filter(course_id_id=cid, user_id=user).exists() + if not is_member: + # 非成員時不回任何資料 + return Problems.objects.none() + + # 一般權限過濾 + if not getattr(user, 'is_authenticated', False): + qs = qs.filter(is_public__in=['public', True, 1]) + elif not (getattr(user, 'is_staff', False) or getattr(user, 'is_superuser', False) or getattr(user, 'identity', None) in ['admin', 'teacher']): + from courses.models import Course_members + user_courses = Course_members.objects.filter(user_id=user).values_list('course_id', flat=True) + qs = qs.filter( + Q(is_public__in=['public', True, 1]) | Q(creator_id=user) | Q(is_public='course', course_id__in=user_courses) + ) + + ordering = self.request.query_params.get('ordering', '-created_at') + return qs.order_by(ordering) + def perform_create(self, serializer): serializer.save(creator_id=self.request.user) @@ -809,10 +858,26 @@ def get(self, request): if visibility in ('public','course','hidden'): queryset = queryset.filter(is_public=visibility) - # 篩選:course_id + # 篩選:course_id(若帶入 course_id,需驗證課程成員或管理員/教師) course_id = request.query_params.get('course_id') if course_id: queryset = queryset.filter(course_id=course_id) + user = request.user + # 未登入則不允許瀏覽特定課程的題目清單 + if not user.is_authenticated: + return api_response(None, "Authentication required.", status_code=401) + # 管理員/教師視為允許(沿用既有規則) + is_staff_like = bool(getattr(user, 'is_staff', False) or getattr(user, 'is_superuser', False) or getattr(user, 'identity', None) in ['admin', 'teacher']) + if not is_staff_like: + try: + cid = int(course_id) + except (TypeError, ValueError): + cid = None + from courses.models import Course_members + is_member = cid is not None and Course_members.objects.filter(course_id_id=cid, user_id=user).exists() + if not is_member: + # 針對課程頁的題目列表,非成員一律擋下 + return api_response(None, "You are not in this course.", status_code=403) # 權限過濾:未登入或非 owner/課程成員只能看 is_public=public user = request.user @@ -870,6 +935,8 @@ class ProblemDetailView(APIView): 權限:需求書標示需登入;故此處強制 IsAuthenticated。針對非公開題目沿用既有權限檢查。 """ + permission_classes = [] # 允許匿名,實際權限在內部依題目可見性檢查 + LANGUAGE_BIT_MAP = { 'c': 1, 'cpp': 2, From ff326ebc0461db4cbc2935a984d4c7e1a5b78b43 Mon Sep 17 00:00:00 2001 From: chewinggum-master <41247003s@gapps.ntnu.edu.tw> Date: Sat, 27 Dec 2025 02:52:29 +0800 Subject: [PATCH 021/113] Add test files to gitignore --- .gitignore | Bin 348 -> 396 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.gitignore b/.gitignore index b4bc589a7ec04e6098d3a4eb1677b37e6924a2df..6bb9eed3e5d630f2756ea2a49f57fdaefc330423 100644 GIT binary patch delta 56 zcmcb^)Wf_XhS8veA(f$+p@cz?A(5egArnX!FjO+|GH?OKATsd`T0qfah77PM0AoT5 AY5)KL delta 7 OcmeBSzQeR3h7kY@3Iclo From 00ddbe4b068214d076f4b490b76af18e560b456c Mon Sep 17 00:00:00 2001 From: chewinggum-master <41247003s@gapps.ntnu.edu.tw> Date: Sat, 27 Dec 2025 02:52:47 +0800 Subject: [PATCH 022/113] Ignore test.api.py --- .gitignore | Bin 396 -> 470 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.gitignore b/.gitignore index 6bb9eed3e5d630f2756ea2a49f57fdaefc330423..731059d441fb811134f52c3d1a30b56f0e22099e 100644 GIT binary patch delta 59 zcmeBSzQ(+vhfzO?A(0`OA)6tdA(bJIA%#Jop@5-?A)g_MAqOau%TNpyDFL#Ifh3SU IIhs)#0H; Date: Sat, 27 Dec 2025 04:38:45 +0800 Subject: [PATCH 023/113] feat: add submission testing script for Problem 1 diagnostics --- .../test_file/test_problem1_submission.py | 416 ++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 submissions/test_file/test_problem1_submission.py diff --git a/submissions/test_file/test_problem1_submission.py b/submissions/test_file/test_problem1_submission.py new file mode 100644 index 0000000..f96b35f --- /dev/null +++ b/submissions/test_file/test_problem1_submission.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python +""" +測試 Problem 1 的提交流程 +診斷為何 submission 卡在 pending + +使用方式: + cd back_end + python submissions/test_file/test_problem1_submission.py +""" +import os +import sys +import hashlib + +# 設置 Django 環境 +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, BASE_DIR) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'back_end.settings') + +import django +django.setup() + +from django.conf import settings +from problems.models import Problems, Problem_subtasks +from user.models import User +from submissions.models import Submission +import requests + +# ========== 配置 ========== +PROBLEM_ID = 1 +SANDBOX_API_URL = getattr(settings, 'SANDBOX_API_URL', 'http://34.81.90.111:8000') +SANDBOX_API_KEY = getattr(settings, 'SANDBOX_API_KEY', '') or os.getenv('SANDBOX_API_KEY', '') + +# 如果 API Key 仍未設定,可在此處手動填入(僅供測試) +# SANDBOX_API_KEY = 'your-sandbox-api-key-here' + + +def print_section(title): + print("\n" + "=" * 60) + print(f" {title}") + print("=" * 60) + + +def check_problem_exists(): + """檢查題目是否存在""" + print_section("步驟 1: 檢查題目") + + try: + problem = Problems.objects.get(id=PROBLEM_ID) + print(f"✓ 題目存在: {problem.title}") + print(f" - ID: {problem.id}") + print(f" - 難度: {problem.difficulty}") + print(f" - 建立者: {problem.creator_id}") + print(f" - 課程: {problem.course_id}") + + # 檢查 subtasks + subtasks = problem.subtasks.all() + print(f" - Subtasks 數量: {subtasks.count()}") + for st in subtasks: + print(f" - Subtask {st.subtask_no}: time={st.time_limit_ms}ms, mem={st.memory_limit_mb}MB, weight={st.weight}") + + return problem + except Problems.DoesNotExist: + print(f"✗ 題目 {PROBLEM_ID} 不存在!") + return None + + +def check_testcase_package(): + """檢查測資包是否存在""" + print_section("步驟 2: 檢查測資包") + + testcase_dir = os.path.join(settings.MEDIA_ROOT, "testcases", f"p{PROBLEM_ID}") + zip_path = os.path.join(testcase_dir, "problem.zip") + + print(f"測資目錄: {testcase_dir}") + + if not os.path.exists(testcase_dir): + print(f"✗ 測資目錄不存在!") + print(f" 請先上傳測資包: POST /problem/{PROBLEM_ID}/test-cases/upload-zip") + return None, None + + # 列出目錄內容 + print(f"目錄內容:") + for f in os.listdir(testcase_dir): + fpath = os.path.join(testcase_dir, f) + size = os.path.getsize(fpath) + print(f" - {f} ({size} bytes)") + + # 檢查 zip + problem_hash = None + meta = None + + if os.path.exists(zip_path): + print(f"✓ problem.zip 存在") + + # 計算 hash + sha256_hash = hashlib.sha256() + with open(zip_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256_hash.update(chunk) + problem_hash = sha256_hash.hexdigest() + print(f" - SHA256 Hash: {problem_hash}") + + # 檢查 ZIP 內部的 meta.json + import zipfile + import json + try: + with zipfile.ZipFile(zip_path, 'r') as zf: + # 列出 ZIP 內容 + print(f" - ZIP 內容:") + for name in zf.namelist(): + info = zf.getinfo(name) + print(f" {name} ({info.file_size} bytes)") + + # 檢查 meta.json + if 'meta.json' in zf.namelist(): + print(f"✓ meta.json 存在 (在 ZIP 內)") + with zf.open('meta.json') as mf: + meta = json.load(mf) + print(f" - 內容: {json.dumps(meta, indent=2)}") + else: + print(f"✗ meta.json 不在 ZIP 內") + except zipfile.BadZipFile: + print(f"✗ problem.zip 不是有效的 ZIP 檔案") + else: + print(f"✗ problem.zip 不存在!") + + return problem_hash, meta + + +def check_sandbox_connection(): + """檢查 Sandbox 連線""" + print_section("步驟 3: 檢查 Sandbox 連線") + + print(f"Sandbox URL: {SANDBOX_API_URL}") + print(f"API Key: {'已設定' if SANDBOX_API_KEY else '❌ 未設定'}") + + if not SANDBOX_API_KEY: + print(f"⚠ 請在 .env 設定 SANDBOX_API_KEY,或在腳本中手動填入") + return False + + headers = {'X-API-KEY': SANDBOX_API_KEY} + + try: + # 嘗試連線 /docs 端點(通常不需要認證) + response = requests.get(f"{SANDBOX_API_URL}/docs", timeout=5) + if response.status_code == 200: + print(f"✓ Sandbox API 可連線 (/docs)") + + # 嘗試連線需要認證的端點 + response = requests.get(f"{SANDBOX_API_URL}/api/v1/health", headers=headers, timeout=5) + if response.status_code == 200: + print(f"✓ Sandbox API 認證成功") + return True + elif response.status_code == 401: + print(f"✗ API Key 無效或未授權") + return False + else: + print(f"⚠ Sandbox API 回應: {response.status_code}") + # 即使 health 端點不存在,也可能正常工作 + return True + except Exception as e: + print(f"✗ 無法連線到 Sandbox: {e}") + return False + + +def check_celery_status(): + """檢查 Celery 狀態""" + print_section("步驟 4: 檢查 Celery") + + try: + from back_end.celery import app + + # 檢查已註冊的任務 + registered_tasks = list(app.tasks.keys()) + sandbox_tasks = [t for t in registered_tasks if 'sandbox' in t.lower() or 'submission' in t.lower()] + + print(f"已註冊的相關任務:") + for task in sandbox_tasks: + print(f" - {task}") + + # 檢查 broker 連線 + try: + from celery import current_app + inspector = current_app.control.inspect() + active = inspector.active() + if active: + print(f"✓ Celery Worker 運行中") + for worker, tasks in active.items(): + print(f" - {worker}: {len(tasks)} 個活動任務") + else: + print(f"✗ 沒有 Celery Worker 運行") + print(f" 請執行: celery -A back_end worker -l info") + except Exception as e: + print(f"⚠ 無法檢查 Worker 狀態: {e}") + + return True + except Exception as e: + print(f"✗ Celery 檢查失敗: {e}") + return False + + +def check_redis_status(): + """檢查 Redis 狀態""" + print_section("步驟 5: 檢查 Redis") + + try: + import redis + r = redis.Redis(host='127.0.0.1', port=6379, db=0) + r.ping() + print(f"✓ Redis 連線正常") + return True + except Exception as e: + print(f"✗ Redis 連線失敗: {e}") + print(f" 請執行: docker-compose -f docker-compose.redis.yml up -d") + return False + + +def test_direct_sandbox_submit(problem_hash): + """直接測試向 Sandbox 提交""" + print_section("步驟 6: 測試直接提交到 Sandbox") + + if not problem_hash: + print("✗ 沒有 problem_hash,無法測試") + return False + + # A + B 程式碼 + source_code = """ +a, b = map(int, input().split()) +print(a + b) +""" + + file_content = source_code.encode('utf-8') + file_hash = hashlib.sha256(file_content).hexdigest() + + data = { + 'submission_id': 'test-problem1-direct-001', + 'problem_id': str(PROBLEM_ID), + 'problem_hash': problem_hash, + 'mode': 'normal', + 'language': 'python', + 'file_hash': file_hash, + 'time_limit': 1.0, + 'memory_limit': 262144, + 'use_checker': False, + 'checker_name': 'diff', + 'use_static_analysis': False, + 'priority': 0, + 'callback_url': f'{settings.BACKEND_BASE_URL}/submissions/callback/', + } + + from io import BytesIO + files = { + 'file': ('solution.py', BytesIO(file_content), 'text/plain') + } + + headers = {} + if SANDBOX_API_KEY: + headers['X-API-KEY'] = SANDBOX_API_KEY + + print(f"提交資料:") + print(f" - submission_id: {data['submission_id']}") + print(f" - problem_id: {data['problem_id']}") + print(f" - problem_hash: {data['problem_hash'][:16]}...") + print(f" - language: {data['language']}") + print(f" - file_hash: {data['file_hash'][:16]}...") + + confirm = input("\n是否發送測試提交到 Sandbox? (y/N): ").strip().lower() + if confirm != 'y': + print("已取消") + return False + + try: + response = requests.post( + f"{SANDBOX_API_URL}/api/v1/submissions", + data=data, + files=files, + headers=headers, + timeout=30 + ) + + print(f"\n回應狀態碼: {response.status_code}") + print(f"回應內容: {response.text}") + + if response.status_code in [200, 201, 202]: + print(f"✓ 提交成功!") + return True + else: + print(f"✗ 提交失敗") + return False + + except Exception as e: + print(f"✗ 請求失敗: {e}") + return False + + +def create_test_submission(): + """透過 API 建立測試提交""" + print_section("步驟 7: 透過 Django 建立提交") + + # 找一個測試用戶 + user = User.objects.first() + if not user: + print("✗ 沒有可用的用戶") + return None + + print(f"使用用戶: {user.username}") + + # 建立提交 + source_code = """ +a, b = map(int, input().split()) +print(a + b) +""" + + code_hash = hashlib.sha256(source_code.encode()).hexdigest() + + submission = Submission.objects.create( + user=user, + problem_id=PROBLEM_ID, + language_type=2, # Python + source_code=source_code, + code_hash=code_hash, + status='-1', # Pending + ) + + print(f"✓ 提交已建立:") + print(f" - ID: {submission.id}") + print(f" - Status: {submission.status}") + + return submission + + +def trigger_sandbox_task(submission): + """觸發 Sandbox 任務""" + print_section("步驟 8: 觸發 Celery 任務") + + from submissions.tasks import submit_to_sandbox_task + + print(f"發送任務: submit_to_sandbox_task({submission.id})") + + confirm = input("是否觸發任務? (y/N): ").strip().lower() + if confirm != 'y': + print("已取消") + return + + try: + result = submit_to_sandbox_task.delay(str(submission.id)) + print(f"✓ 任務已發送") + print(f" - Task ID: {result.id}") + print(f" - 請查看 Celery Worker 日誌") + except Exception as e: + print(f"✗ 發送失敗: {e}") + + +def main(): + print(""" +╔══════════════════════════════════════════════════════════════════╗ +║ Problem 1 提交測試 - 診斷 Sandbox 連線 ║ +╚══════════════════════════════════════════════════════════════════╝ + """) + + # 步驟 1: 檢查題目 + problem = check_problem_exists() + if not problem: + print("\n請先建立題目 1") + return + + # 步驟 2: 檢查測資包 + problem_hash, meta = check_testcase_package() + + # 步驟 3: 檢查 Sandbox + sandbox_ok = check_sandbox_connection() + + # 步驟 4: 檢查 Celery + check_celery_status() + + # 步驟 5: 檢查 Redis + check_redis_status() + + # 總結 + print_section("診斷總結") + + issues = [] + if not problem_hash: + issues.append("缺少測資包 (problem.zip)") + if not meta: + issues.append("缺少 meta.json") + if not sandbox_ok: + issues.append("Sandbox 無法連線") + + if issues: + print("發現以下問題:") + for i, issue in enumerate(issues, 1): + print(f" {i}. {issue}") + + if not problem_hash: + print(f"\n建議: 上傳測資包") + print(f" curl -X POST 'http://127.0.0.1:8000/problem/{PROBLEM_ID}/test-cases/upload-zip' \\") + print(f" -H 'Authorization: Bearer $TOKEN' \\") + print(f" -F 'file=@testcases.zip'") + else: + print("✓ 所有檢查通過!") + + # 詢問是否進行測試 + test_direct = input("\n是否進行直接 Sandbox 提交測試? (y/N): ").strip().lower() + if test_direct == 'y': + test_direct_sandbox_submit(problem_hash) + + test_full = input("\n是否進行完整提交流程測試? (y/N): ").strip().lower() + if test_full == 'y': + submission = create_test_submission() + if submission: + trigger_sandbox_task(submission) + + +if __name__ == "__main__": + main() \ No newline at end of file From 4bf0a43f8425b69b644c437aae25973a8df8bf87 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sat, 27 Dec 2025 07:15:51 +0800 Subject: [PATCH 024/113] feat: enhance environment variable display in submission testing script --- .../test_file/test_problem1_submission.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/submissions/test_file/test_problem1_submission.py b/submissions/test_file/test_problem1_submission.py index f96b35f..14e3d69 100644 --- a/submissions/test_file/test_problem1_submission.py +++ b/submissions/test_file/test_problem1_submission.py @@ -29,11 +29,32 @@ PROBLEM_ID = 1 SANDBOX_API_URL = getattr(settings, 'SANDBOX_API_URL', 'http://34.81.90.111:8000') SANDBOX_API_KEY = getattr(settings, 'SANDBOX_API_KEY', '') or os.getenv('SANDBOX_API_KEY', '') +SANDBOX_TOKEN = getattr(settings, 'SANDBOX_TOKEN', None) or os.getenv('SANDBOX_TOKEN', '') +BACKEND_BASE_URL = getattr(settings, 'BACKEND_BASE_URL', 'http://localhost:8000') # 如果 API Key 仍未設定,可在此處手動填入(僅供測試) # SANDBOX_API_KEY = 'your-sandbox-api-key-here' +def print_env_variables(): + """顯示讀取到的環境變數""" + print("\n" + "=" * 60) + print(" 環境變數設定") + print("=" * 60) + print(f"SANDBOX_API_URL : {SANDBOX_API_URL}") + print(f"SANDBOX_API_KEY : {'[已設定]' if SANDBOX_API_KEY else '[未設定]'} (長度: {len(SANDBOX_API_KEY)})") + print(f"SANDBOX_TOKEN : {'[已設定]' if SANDBOX_TOKEN else '[未設定]'} (長度: {len(SANDBOX_TOKEN) if SANDBOX_TOKEN else 0})") + print(f"BACKEND_BASE_URL : {BACKEND_BASE_URL}") + print(f"MEDIA_ROOT : {settings.MEDIA_ROOT}") + + # 顯示原始環境變數(未經 settings 處理) + print("\n--- 原始環境變數 ---") + print(f"os.getenv('SANDBOX_API_URL') : {os.getenv('SANDBOX_API_URL', '[未設定]')}") + print(f"os.getenv('SANDBOX_API_KEY') : {'[已設定]' if os.getenv('SANDBOX_API_KEY') else '[未設定]'}") + print(f"os.getenv('SANDBOX_TOKEN') : {'[已設定]' if os.getenv('SANDBOX_TOKEN') else '[未設定]'}") + print(f"os.getenv('BACKEND_BASE_URL') : {os.getenv('BACKEND_BASE_URL', '[未設定]')}") + + def print_section(title): print("\n" + "=" * 60) print(f" {title}") @@ -358,6 +379,9 @@ def main(): ╚══════════════════════════════════════════════════════════════════╝ """) + # 顯示環境變數設定 + print_env_variables() + # 步驟 1: 檢查題目 problem = check_problem_exists() if not problem: From 8b3f1ff680ce3786877494b3381b772d4b46b193 Mon Sep 17 00:00:00 2001 From: RokuSennyou <167313316+RokuSennyou@users.noreply.github.com> Date: Sat, 27 Dec 2025 13:01:18 +0800 Subject: [PATCH 025/113] Update submissions/views.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- submissions/views.py | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/submissions/views.py b/submissions/views.py index 4dc85b1..105e42e 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -537,23 +537,27 @@ def post(self, request, *args, **kwargs): elif problem.is_public == 'course': # Course 題目需要檢查是否在課程中 from courses.models import Courses - try: - course = problem.course_id - # 檢查用戶是否是課程成員(學生或老師) - if course.teacher_id != user: - # 檢查是否是學生 - from courses.models import Course_students - if not Course_students.objects.filter( - course_id=course, - student_id=user - ).exists(): - return api_response( - data=None, - message="problem permission denied", - status_code=status.HTTP_403_FORBIDDEN - ) - except: - pass + course = problem.course_id + # 若題目未關聯任何課程,則不應允許提交 + if course is None: + return api_response( + data=None, + message="problem permission denied", + status_code=status.HTTP_403_FORBIDDEN + ) + # 檢查用戶是否是課程成員(老師或學生) + if course.teacher_id != user: + # 檢查是否是學生 + from courses.models import Course_students + if not Course_students.objects.filter( + course_id=course, + student_id=user + ).exists(): + return api_response( + data=None, + message="problem permission denied", + status_code=status.HTTP_403_FORBIDDEN + ) except Problems.DoesNotExist: return api_response( data=None, From 731363ce54ca38e710c06fb4dba7f0b76649023f Mon Sep 17 00:00:00 2001 From: RokuSennyou <167313316+RokuSennyou@users.noreply.github.com> Date: Sat, 27 Dec 2025 13:05:01 +0800 Subject: [PATCH 026/113] Update submissions/views.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- submissions/views.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/submissions/views.py b/submissions/views.py index 105e42e..d6f526f 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -501,21 +501,22 @@ def post(self, request, *args, **kwargs): # 3. Quota 檢查 - 檢查用戶是否還有提交配額 from .models import UserProblemQuota try: - quota = UserProblemQuota.objects.get( - user=user, - problem_id=problem_id, - assignment_id__isnull=True # 全域配額 - ) - if quota.remaining_attempts == 0: - return api_response( - data=None, - message="you have used all your quotas", - status_code=status.HTTP_403_FORBIDDEN + with transaction.atomic(): + quota = UserProblemQuota.objects.select_for_update().get( + user=user, + problem_id=problem_id, + assignment_id__isnull=True # 全域配額 ) - # 減少配額(如果不是無限制) - if quota.remaining_attempts > 0: - quota.remaining_attempts -= 1 - quota.save() + if quota.remaining_attempts == 0: + return api_response( + data=None, + message="you have used all your quotas", + status_code=status.HTTP_403_FORBIDDEN + ) + # 減少配額(如果不是無限制) + if quota.remaining_attempts > 0: + quota.remaining_attempts -= 1 + quota.save() except UserProblemQuota.DoesNotExist: # 沒有配額限制,允許提交 pass From 88372c22c41d929459ccd628c76b9d0711b63a54 Mon Sep 17 00:00:00 2001 From: RokuSennyou <167313316+RokuSennyou@users.noreply.github.com> Date: Sat, 27 Dec 2025 13:05:24 +0800 Subject: [PATCH 027/113] Update submissions/views.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- submissions/views.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/submissions/views.py b/submissions/views.py index d6f526f..57d3d9a 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -469,6 +469,13 @@ def post(self, request, *args, **kwargs): # 額外的安全檢查 problem_id = serializer.validated_data['problem_id'] user = request.user + # 確保用戶已通過身份驗證(防止 AnonymousUser 進入此邏輯) + if not getattr(user, "is_authenticated", False): + return api_response( + data=None, + message="Authentication credentials were not provided.", + status_code=status.HTTP_401_UNAUTHORIZED, + ) # 1. Rate Limiting - 提交速率限制(每分鐘最多 10 次) from django.core.cache import cache From c438468c7f5d420ad5d9e38180d07490967562bb Mon Sep 17 00:00:00 2001 From: RokuSennyou <167313316+RokuSennyou@users.noreply.github.com> Date: Sat, 27 Dec 2025 13:13:30 +0800 Subject: [PATCH 028/113] Update problems/views/api.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- problems/views/api.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/problems/views/api.py b/problems/views/api.py index 3e9030d..d675d49 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -78,18 +78,23 @@ def get_queryset(self): course_id = self.request.query_params.get('course_id') user = getattr(self.request, 'user', None) if course_id: - qs = qs.filter(course_id=course_id) + try: + cid = int(course_id) + except (TypeError, ValueError): + # 無效的 course_id 直接回傳空 QuerySet,避免異常或洩漏資訊 + return Problems.objects.none() + qs = qs.filter(course_id=cid) if not getattr(user, 'is_authenticated', False): # 直接擋下,避免洩漏課程題目清單 return Problems.objects.none() - is_staff_like = bool(getattr(user, 'is_staff', False) or getattr(user, 'is_superuser', False) or getattr(user, 'identity', None) in ['admin', 'teacher']) + is_staff_like = bool( + getattr(user, 'is_staff', False) + or getattr(user, 'is_superuser', False) + or getattr(user, 'identity', None) in ['admin', 'teacher'] + ) if not is_staff_like: - try: - cid = int(course_id) - except (TypeError, ValueError): - cid = None from courses.models import Course_members - is_member = cid is not None and Course_members.objects.filter(course_id_id=cid, user_id=user).exists() + is_member = Course_members.objects.filter(course_id_id=cid, user_id=user).exists() if not is_member: # 非成員時不回任何資料 return Problems.objects.none() From 8c52602a3ea843482cb579fc042bea8e874c218b Mon Sep 17 00:00:00 2001 From: RokuSennyou <167313316+RokuSennyou@users.noreply.github.com> Date: Sat, 27 Dec 2025 13:13:45 +0800 Subject: [PATCH 029/113] Update problems/views/api.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- problems/views/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/problems/views/api.py b/problems/views/api.py index d675d49..b063d2c 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -937,7 +937,7 @@ class ProblemDetailView(APIView): submitCount: 個人提交次數(若 submissions table 缺失或未登入則 null) highScore: 個人最高分(同上) - 權限:需求書標示需登入;故此處強制 IsAuthenticated。針對非公開題目沿用既有權限檢查。 + 權限:允許匿名存取公開題目;非公開題目與個人相關欄位(如 submitCount、highScore)於內部依題目可見性與使用者身分檢查。 """ permission_classes = [] # 允許匿名,實際權限在內部依題目可見性檢查 From 7f9fb6c16dfccece15c3140ea7f83f79c5a16d86 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sat, 27 Dec 2025 14:17:10 +0800 Subject: [PATCH 030/113] feat: enhance Copycat permissions and authentication checks for course members --- copycat/tests/test_views.py | 287 ++++++++++++++++++++++++++++++------ copycat/views.py | 78 ++++++++-- 2 files changed, 303 insertions(+), 62 deletions(-) diff --git a/copycat/tests/test_views.py b/copycat/tests/test_views.py index 2dc3942..b25e4f2 100644 --- a/copycat/tests/test_views.py +++ b/copycat/tests/test_views.py @@ -1,6 +1,7 @@ # copycat/tests/test_views.py """ 測試 Copycat Views +權限邏輯:使用者必須是該題所屬課程的老師或助教 """ from django.test import TestCase from django.contrib.auth import get_user_model @@ -9,6 +10,9 @@ from rest_framework import status from unittest.mock import patch from copycat.models import CopycatReport +from courses.models import Courses, Course_members +from problems.models import Problems +from user.models import UserProfile User = get_user_model() @@ -19,37 +23,212 @@ class CopycatViewTest(TestCase): def setUp(self): """設置測試環境""" self.client = APIClient() + + # 創建普通使用者 self.user = User.objects.create_user( username='testuser', email='test@example.com', password='testpass123' ) - self.admin = User.objects.create_user( - username='admin', - email='admin@example.com', - password='admin123', - is_staff=True + UserProfile.objects.update_or_create(user=self.user, defaults={'email_verified': True}) + + # 創建課程老師 + self.teacher = User.objects.create_user( + username='teacher', + email='teacher@example.com', + password='teacher123' + ) + UserProfile.objects.update_or_create(user=self.teacher, defaults={'email_verified': True}) + + # 創建課程助教 + self.ta = User.objects.create_user( + username='ta', + email='ta@example.com', + password='ta123' + ) + UserProfile.objects.update_or_create(user=self.ta, defaults={'email_verified': True}) + + # 創建學生 + self.student = User.objects.create_user( + username='student', + email='student@example.com', + password='student123' + ) + UserProfile.objects.update_or_create(user=self.student, defaults={'email_verified': True}) + + # 創建課程(teacher 是主要老師) + self.course = Courses.objects.create( + name='Test Course', + description='Test Description', + teacher_id=self.teacher ) + + # 將 TA 加入課程成員 + Course_members.objects.create( + course_id=self.course, + user_id=self.ta, + role=Course_members.Role.TA + ) + + # 將學生加入課程成員 + Course_members.objects.create( + course_id=self.course, + user_id=self.student, + role=Course_members.Role.STUDENT + ) + + # 創建題目(關聯到課程) + self.problem = Problems.objects.create( + id=101, + title='Test Problem', + description='Test Description', + course_id=self.course, + creator_id=self.teacher + ) + self.url = reverse('copycat') + # =========================================== + # 認證測試 + # =========================================== + def test_trigger_check_requires_authentication(self): """測試觸發檢測需要認證""" data = {'problem_id': 101, 'language': 'python'} response = self.client.post(self.url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + # DRF 預設在未認證時可能返回 401 或 403,取決於設定 + self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]) - def test_trigger_check_requires_admin(self): - """測試觸發檢測需要管理員權限""" + def test_query_report_requires_authentication(self): + """測試查詢報告需要認證""" + response = self.client.get(f'{self.url}?problem_id=101') + # DRF 預設在未認證時可能返回 401 或 403,取決於設定 + self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]) + + # =========================================== + # 權限測試 - POST + # =========================================== + + def test_trigger_check_denied_for_non_course_member(self): + """測試非課程成員無法觸發檢測""" self.client.force_authenticate(user=self.user) data = {'problem_id': 101, 'language': 'python'} response = self.client.post(self.url, data, format='json') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn('老師或助教', response.data['message']) + + def test_trigger_check_denied_for_student(self): + """測試學生無法觸發檢測""" + self.client.force_authenticate(user=self.student) + + data = {'problem_id': 101, 'language': 'python'} + response = self.client.post(self.url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn('老師或助教', response.data['message']) + + @patch('copycat.views.threading.Thread') + def test_trigger_check_allowed_for_course_teacher(self, mock_thread): + """測試課程主要老師可以觸發檢測""" + self.client.force_authenticate(user=self.teacher) + + data = {'problem_id': 101, 'language': 'python'} + response = self.client.post(self.url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.data['status'], 'ok') + + @patch('copycat.views.threading.Thread') + def test_trigger_check_allowed_for_ta(self, mock_thread): + """測試助教可以觸發檢測""" + self.client.force_authenticate(user=self.ta) + + data = {'problem_id': 101, 'language': 'python'} + response = self.client.post(self.url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.data['status'], 'ok') + + @patch('copycat.views.threading.Thread') + def test_trigger_check_allowed_for_course_member_teacher_role(self, mock_thread): + """測試課程成員中角色為老師的使用者可以觸發檢測""" + # 創建另一位老師並加入課程成員 + another_teacher = User.objects.create_user( + username='another_teacher', + email='another_teacher@example.com', + password='teacher123' + ) + UserProfile.objects.update_or_create(user=another_teacher, defaults={'email_verified': True}) + Course_members.objects.create( + course_id=self.course, + user_id=another_teacher, + role=Course_members.Role.TEACHER + ) + + self.client.force_authenticate(user=another_teacher) + + data = {'problem_id': 101, 'language': 'python'} + response = self.client.post(self.url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + + # =========================================== + # 權限測試 - GET + # =========================================== + + def test_query_report_denied_for_non_course_member(self): + """測試非課程成員無法查詢報告""" + self.client.force_authenticate(user=self.user) + + response = self.client.get(f'{self.url}?problem_id=101') + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_query_report_denied_for_student(self): + """測試學生無法查詢報告""" + self.client.force_authenticate(user=self.student) + + response = self.client.get(f'{self.url}?problem_id=101') + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_query_report_allowed_for_teacher(self): + """測試老師可以查詢報告""" + CopycatReport.objects.create( + problem_id=101, + requester=self.teacher, + status='pending' + ) + + self.client.force_authenticate(user=self.teacher) + + response = self.client.get(f'{self.url}?problem_id=101') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_query_report_allowed_for_ta(self): + """測試助教可以查詢報告""" + CopycatReport.objects.create( + problem_id=101, + requester=self.teacher, + status='pending' + ) + + self.client.force_authenticate(user=self.ta) + + response = self.client.get(f'{self.url}?problem_id=101') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # =========================================== + # 參數驗證測試 - POST + # =========================================== + def test_trigger_check_without_problem_id(self): """測試沒有提供 problem_id""" - self.client.force_authenticate(user=self.admin) + self.client.force_authenticate(user=self.teacher) data = {'language': 'python'} response = self.client.post(self.url, data, format='json') @@ -59,16 +238,25 @@ def test_trigger_check_without_problem_id(self): def test_trigger_check_with_invalid_problem_id(self): """測試無效的 problem_id""" - self.client.force_authenticate(user=self.admin) + self.client.force_authenticate(user=self.teacher) data = {'problem_id': 'invalid', 'language': 'python'} response = self.client.post(self.url, data, format='json') self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_trigger_check_with_nonexistent_problem_id(self): + """測試不存在的 problem_id""" + self.client.force_authenticate(user=self.teacher) + + data = {'problem_id': 99999, 'language': 'python'} + response = self.client.post(self.url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + def test_trigger_check_with_invalid_language(self): """測試無效的語言""" - self.client.force_authenticate(user=self.admin) + self.client.force_authenticate(user=self.teacher) data = {'problem_id': 101, 'language': 'invalid_lang'} response = self.client.post(self.url, data, format='json') @@ -76,10 +264,34 @@ def test_trigger_check_with_invalid_language(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn('不支援的語言', response.data['message']) + # =========================================== + # 參數驗證測試 - GET + # =========================================== + + def test_query_report_without_problem_id(self): + """測試查詢報告沒有提供 problem_id""" + self.client.force_authenticate(user=self.teacher) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_query_report_with_invalid_problem_id(self): + """測試查詢報告使用無效的 problem_id""" + self.client.force_authenticate(user=self.teacher) + + response = self.client.get(f'{self.url}?problem_id=invalid') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # =========================================== + # 功能測試 - POST + # =========================================== + @patch('copycat.views.threading.Thread') def test_trigger_check_success(self, mock_thread): """測試成功觸發檢測""" - self.client.force_authenticate(user=self.admin) + self.client.force_authenticate(user=self.teacher) data = {'problem_id': 101, 'language': 'python'} response = self.client.post(self.url, data, format='json') @@ -100,11 +312,11 @@ def test_trigger_check_with_pending_task(self, mock_thread): # 創建一個 pending 的報告 CopycatReport.objects.create( problem_id=101, - requester=self.admin, + requester=self.teacher, status='pending' ) - self.client.force_authenticate(user=self.admin) + self.client.force_authenticate(user=self.teacher) data = {'problem_id': 101, 'language': 'python'} response = self.client.post(self.url, data, format='json') @@ -115,39 +327,22 @@ def test_trigger_check_with_pending_task(self, mock_thread): @patch('copycat.views.threading.Thread') def test_trigger_check_default_language(self, mock_thread): """測試預設語言為 python""" - self.client.force_authenticate(user=self.admin) + self.client.force_authenticate(user=self.teacher) data = {'problem_id': 101} # 沒有指定語言 response = self.client.post(self.url, data, format='json') self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - def test_query_report_requires_authentication(self): - """測試查詢報告需要認證""" - response = self.client.get(f'{self.url}?problem_id=101') - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_query_report_requires_admin(self): - """測試查詢報告需要管理員權限""" - self.client.force_authenticate(user=self.user) - - response = self.client.get(f'{self.url}?problem_id=101') - - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_query_report_without_problem_id(self): - """測試查詢報告沒有提供 problem_id""" - self.client.force_authenticate(user=self.admin) - - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + # =========================================== + # 功能測試 - GET + # =========================================== def test_query_report_not_found(self): """測試查詢不存在的報告""" - self.client.force_authenticate(user=self.admin) + self.client.force_authenticate(user=self.teacher) - response = self.client.get(f'{self.url}?problem_id=999') + response = self.client.get(f'{self.url}?problem_id=101') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertIn('尚未進行過', response.data['message']) @@ -156,11 +351,11 @@ def test_query_report_pending(self): """測試查詢處理中的報告""" CopycatReport.objects.create( problem_id=101, - requester=self.admin, + requester=self.teacher, status='pending' ) - self.client.force_authenticate(user=self.admin) + self.client.force_authenticate(user=self.teacher) response = self.client.get(f'{self.url}?problem_id=101') @@ -173,12 +368,12 @@ def test_query_report_success(self): moss_url = 'http://moss.stanford.edu/results/123/' CopycatReport.objects.create( problem_id=101, - requester=self.admin, + requester=self.teacher, status='success', moss_url=moss_url ) - self.client.force_authenticate(user=self.admin) + self.client.force_authenticate(user=self.teacher) response = self.client.get(f'{self.url}?problem_id=101') @@ -191,12 +386,12 @@ def test_query_report_failed(self): error_msg = 'Connection timeout' CopycatReport.objects.create( problem_id=101, - requester=self.admin, + requester=self.teacher, status='failed', error_message=error_msg ) - self.client.force_authenticate(user=self.admin) + self.client.force_authenticate(user=self.teacher) response = self.client.get(f'{self.url}?problem_id=101') @@ -211,7 +406,7 @@ def test_query_report_returns_latest(self): # 創建舊報告 CopycatReport.objects.create( problem_id=101, - requester=self.admin, + requester=self.teacher, status='success', moss_url='http://old.url' ) @@ -221,11 +416,11 @@ def test_query_report_returns_latest(self): # 創建新報告 new_report = CopycatReport.objects.create( problem_id=101, - requester=self.admin, + requester=self.teacher, status='pending' ) - self.client.force_authenticate(user=self.admin) + self.client.force_authenticate(user=self.teacher) response = self.client.get(f'{self.url}?problem_id=101') diff --git a/copycat/views.py b/copycat/views.py index 190f4d5..d219228 100644 --- a/copycat/views.py +++ b/copycat/views.py @@ -1,14 +1,16 @@ import threading from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework.permissions import IsAdminUser +from rest_framework.permissions import IsAuthenticated from user.permissions import IsEmailVerified from rest_framework.authentication import SessionAuthentication +from rest_framework_simplejwt.authentication import JWTAuthentication from api_tokens.authentication import ApiTokenAuthentication from .models import CopycatReport from submissions.models import Submission from problems.models import Problems +from courses.models import Course_members from .services import run_moss_check, LANG_DB_MAP @@ -19,8 +21,39 @@ def api_response(data=None, message="OK", status_code=200): return Response({"data": data, "message": message, "status": status_str}, status=status_code) class CopycatView(APIView): - authentication_classes = [SessionAuthentication, ApiTokenAuthentication] - permission_classes = [IsAdminUser, IsEmailVerified] + authentication_classes = [SessionAuthentication, JWTAuthentication, ApiTokenAuthentication] + permission_classes = [IsAuthenticated, IsEmailVerified] + + def _has_problem_edit_permission(self, user, problem_id): + """ + 檢查使用者是否有該題目的編輯權限 + 必須是該題所屬課程的老師或助教 + """ + try: + problem = Problems.objects.select_related('course_id').get(pk=problem_id) + except Problems.DoesNotExist: + return False, "題目不存在" + + if not problem.course_id: + return False, "此題目未關聯到任何課程" + + course = problem.course_id + + # 檢查是否為課程主要老師 + if course.teacher_id == user: + return True, None + + # 檢查是否為課程成員中的老師或 TA 角色 + is_course_staff = Course_members.objects.filter( + course_id=course, + user_id=user, + role__in=[Course_members.Role.TEACHER, Course_members.Role.TA] + ).exists() + + if is_course_staff: + return True, None + + return False, "您必須是該題所屬課程的老師或助教才能執行此操作" def post(self, request): problem_id = request.data.get('problem_id') @@ -30,23 +63,26 @@ def post(self, request): if not problem_id: return api_response(None, "缺少 problem_id 參數", status_code=400) - # 1.5 驗證 problem_id 是否為整數 (Copilot 建議) + # 1.5 驗證 problem_id 是否為整數 try: problem_id = int(problem_id) except (ValueError, TypeError): return api_response(None, "problem_id 必須是整數", status_code=400) - # 2. 驗證題目是否存在 (Copilot 建議) - if Problems: - if not Problems.objects.filter(id=problem_id).exists(): - return api_response(None, f"題目 ID {problem_id} 不存在", status_code=404) + # 2. 驗證題目是否存在 + if not Problems.objects.filter(id=problem_id).exists(): + return api_response(None, f"題目 ID {problem_id} 不存在", status_code=404) + + # 3. 權限檢查:必須是該題所屬課程的老師或助教 + has_permission, error_msg = self._has_problem_edit_permission(request.user, problem_id) + if not has_permission: + return api_response(None, error_msg, status_code=403) - # 3. 驗證語言 + # 4. 驗證語言 if language.lower() not in LANG_DB_MAP: return api_response(None, f"不支援的語言: {language}", status_code=400) - # 4. 防止重複任務 (使用 get_or_create 防止 Race Condition) - # 只有當沒有 pending 任務時,才建立新的 + # 5. 防止重複任務 (使用 get_or_create 防止 Race Condition) report, created = CopycatReport.objects.get_or_create( problem_id=problem_id, status='pending', @@ -54,19 +90,18 @@ def post(self, request): ) if not created: - # 如果已經有 pending 的,直接回傳該任務資訊,不重複執行 return api_response( {"report_id": report.id, "status": "pending"}, "該題目已有正在進行中的抄襲比對任務,請稍後再試", - status_code=429 # Too Many Requests + status_code=429 ) - # 5. 啟動背景執行緒 + # 6. 啟動背景執行緒 thread = threading.Thread( target=run_moss_check, args=(report.id, problem_id, language) ) - thread.daemon = True # (Copilot 建議:設為守護執行緒) + thread.daemon = True thread.start() return api_response( @@ -77,12 +112,23 @@ def post(self, request): def get(self, request): """ - 查詢「最新」一份報告 (Copilot 建議:與文件保持一致) + 查詢「最新」一份報告 """ problem_id = request.query_params.get('problem_id') if not problem_id: return api_response(None, "缺少 problem_id 參數", status_code=400) + # 驗證 problem_id 是否為整數 + try: + problem_id = int(problem_id) + except (ValueError, TypeError): + return api_response(None, "problem_id 必須是整數", status_code=400) + + # 權限檢查:必須是該題所屬課程的老師或助教 + has_permission, error_msg = self._has_problem_edit_permission(request.user, problem_id) + if not has_permission: + return api_response(None, error_msg, status_code=403) + # 只抓最新一筆 report = CopycatReport.objects.filter(problem_id=problem_id).order_by('-created_at').first() From 81d1836b7b0c9df4f600f15a3e340369c440a4f0 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sat, 27 Dec 2025 14:38:07 +0800 Subject: [PATCH 031/113] feat: add MOSS connection tests and setup for local execution --- copycat/tests/test_services.py | 232 +++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) diff --git a/copycat/tests/test_services.py b/copycat/tests/test_services.py index a824707..8dadc95 100644 --- a/copycat/tests/test_services.py +++ b/copycat/tests/test_services.py @@ -1,7 +1,21 @@ # copycat/tests/test_services.py """ 測試 Copycat Services (MOSS Integration) + +執行方式: +1. 單元測試: python manage.py test copycat.tests.test_services +2. MOSS 連線測試: python copycat/tests/test_services.py """ +import os +import sys + +# 設定 Django 環境(僅在直接執行時需要) +if __name__ == "__main__": + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'back_end.settings') + import django + django.setup() + from django.test import TestCase from django.contrib.auth import get_user_model from unittest.mock import patch, MagicMock @@ -144,3 +158,221 @@ def test_all_language_aliases_map_correctly(self): # JavaScript 的別名 self.assertEqual(LANG_DB_MAP['javascript'], 4) self.assertEqual(LANG_DB_MAP['js'], 4) + + +# ============================================================ +# 直接執行時的 MOSS 連線測試 +# ============================================================ + +def test_moss_connection(): + """ + 測試 MOSS 是否能成功連線到 Stanford 伺服器 + 這會實際發送請求到 MOSS 伺服器 + """ + import mosspy + import tempfile + from django.conf import settings + + print("=" * 60) + print(" MOSS 連線測試") + print("=" * 60) + + # 1. 檢查 MOSS_USER_ID + moss_user_id = getattr(settings, 'MOSS_USER_ID', 0) + print(f"\n1. MOSS_USER_ID: {moss_user_id}") + + if moss_user_id == 0: + print("\n❌ 錯誤: MOSS_USER_ID 為 0,這是無效的 ID") + print(" 請至 https://theory.stanford.edu/~aiken/moss/ 申請") + print(" 發送郵件到 moss@moss.stanford.edu,內容只需要寫:") + print(" registeruser") + print(" 然後將收到的 User ID 設定到 .env 的 MOSS_USER_ID") + return False + + # 2. 建立 MOSS 客戶端 + print(f"\n2. 建立 MOSS 客戶端 (語言: python)...") + m = mosspy.Moss(moss_user_id, "python") + + # 3. 建立測試檔案 + print(f"\n3. 建立測試檔案...") + with tempfile.TemporaryDirectory() as temp_dir: + # 建立兩個測試檔案(至少需要 2 個檔案才能比對) + file1_path = os.path.join(temp_dir, "student1_test.py") + file2_path = os.path.join(temp_dir, "student2_test.py") + + # 故意讓兩個檔案相似,方便測試 + with open(file1_path, "w") as f: + f.write(''' +def calculate_sum(numbers): + """計算數字列表的總和""" + total = 0 + for num in numbers: + total += num + return total + +def main(): + data = [1, 2, 3, 4, 5] + result = calculate_sum(data) + print(f"Sum: {result}") + +if __name__ == "__main__": + main() +''') + + with open(file2_path, "w") as f: + f.write(''' +def sum_numbers(number_list): + """計算數字列表的總和""" + total = 0 + for n in number_list: + total += n + return total + +def main(): + numbers = [1, 2, 3, 4, 5] + answer = sum_numbers(numbers) + print(f"Sum: {answer}") + +if __name__ == "__main__": + main() +''') + + m.addFile(file1_path) + m.addFile(file2_path) + + print(f" ✅ student1_test.py") + print(f" ✅ student2_test.py") + + # 4. 發送到 MOSS 伺服器 + print(f"\n4. 發送到 MOSS 伺服器...") + print(" (這可能需要 5-30 秒,請耐心等待)") + + try: + url = m.send() + print(f"\n{'=' * 60}") + print(f" ✅ 成功!MOSS 報告網址:") + print(f"{'=' * 60}") + print(f"\n {url}\n") + print(" 請在瀏覽器中開啟此網址查看抄襲比對結果") + print(f"{'=' * 60}") + return True + except Exception as e: + print(f"\n❌ 發送失敗: {e}") + print("\n可能原因:") + print(" 1. MOSS_USER_ID 無效或已過期") + print(" 2. 網路無法連接到 Stanford 伺服器") + print(" 3. MOSS 伺服器暫時無法使用") + return False + + +def test_moss_with_real_submissions(problem_id=None): + """ + 使用真實資料庫中的提交進行 MOSS 測試 + """ + from submissions.models import Submission + from django.conf import settings + import mosspy + import tempfile + + print("=" * 60) + print(" MOSS 真實資料測試") + print("=" * 60) + + moss_user_id = getattr(settings, 'MOSS_USER_ID', 0) + if moss_user_id == 0: + print("\n❌ MOSS_USER_ID 未設定") + return False + + # 如果沒有指定 problem_id,列出可用的題目 + if problem_id is None: + print("\n可用的題目 (有 Python 提交):") + from django.db.models import Count + problems = Submission.objects.filter( + language_type=2 # Python + ).values('problem_id').annotate( + count=Count('id') + ).filter(count__gte=2).order_by('-count')[:10] + + if not problems: + print(" ❌ 沒有找到有足夠 Python 提交的題目") + return False + + for p in problems: + print(f" - Problem {p['problem_id']}: {p['count']} 份提交") + + print("\n請使用 test_moss_with_real_submissions(problem_id) 指定題目 ID") + return False + + # 取得提交 + print(f"\n1. 查詢 Problem {problem_id} 的 Python 提交...") + submissions = Submission.objects.filter( + problem_id=problem_id, + language_type=2 # Python + ).select_related('user').order_by('-created_at') + + # 只保留每位使用者最新的提交 + latest = {} + for sub in submissions: + if sub.user_id not in latest: + latest[sub.user_id] = sub + + final_list = list(latest.values()) + print(f" 找到 {len(final_list)} 份有效提交") + + if len(final_list) < 2: + print(" ❌ 提交數量不足 (至少需要 2 份)") + return False + + # 建立 MOSS 客戶端 + print(f"\n2. 建立 MOSS 客戶端...") + m = mosspy.Moss(moss_user_id, "python") + + # 準備檔案 + print(f"\n3. 準備提交檔案...") + with tempfile.TemporaryDirectory() as temp_dir: + for sub in final_list: + filename = f"{sub.user.username}_{sub.id}.py" + filepath = os.path.join(temp_dir, filename) + with open(filepath, "w", encoding="utf-8") as f: + f.write(sub.source_code) + m.addFile(filepath) + print(f" ✅ {filename}") + + # 發送 + print(f"\n4. 發送到 MOSS 伺服器...") + try: + url = m.send() + print(f"\n{'=' * 60}") + print(f" ✅ 成功!MOSS 報告網址:") + print(f"{'=' * 60}") + print(f"\n {url}\n") + return True + except Exception as e: + print(f"\n❌ 發送失敗: {e}") + return False + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="MOSS 連線測試工具") + parser.add_argument( + "--real", "-r", + action="store_true", + help="使用真實資料庫中的提交進行測試" + ) + parser.add_argument( + "--problem", "-p", + type=int, + default=None, + help="指定題目 ID (與 --real 一起使用)" + ) + + args = parser.parse_args() + + if args.real: + success = test_moss_with_real_submissions(args.problem) + else: + success = test_moss_connection() + + sys.exit(0 if success else 1) From accfa9d89aa863e24bc78a5bca2f60b71acc0b00 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sat, 27 Dec 2025 15:17:59 +0800 Subject: [PATCH 032/113] fix: improve permission checks and error handling in CopycatView --- copycat/views.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/copycat/views.py b/copycat/views.py index d219228..5e02df5 100644 --- a/copycat/views.py +++ b/copycat/views.py @@ -69,20 +69,20 @@ def post(self, request): except (ValueError, TypeError): return api_response(None, "problem_id 必須是整數", status_code=400) - # 2. 驗證題目是否存在 - if not Problems.objects.filter(id=problem_id).exists(): - return api_response(None, f"題目 ID {problem_id} 不存在", status_code=404) - - # 3. 權限檢查:必須是該題所屬課程的老師或助教 + # 2. 權限檢查:必須是該題所屬課程的老師或助教 + # (此方法內部會檢查題目是否存在,避免重複查詢) has_permission, error_msg = self._has_problem_edit_permission(request.user, problem_id) if not has_permission: + # 根據錯誤訊息決定回傳的 status code + if "不存在" in error_msg: + return api_response(None, error_msg, status_code=404) return api_response(None, error_msg, status_code=403) - # 4. 驗證語言 + # 3. 驗證語言 if language.lower() not in LANG_DB_MAP: return api_response(None, f"不支援的語言: {language}", status_code=400) - # 5. 防止重複任務 (使用 get_or_create 防止 Race Condition) + # 4. 防止重複任務 (使用 get_or_create 防止 Race Condition) report, created = CopycatReport.objects.get_or_create( problem_id=problem_id, status='pending', @@ -96,7 +96,7 @@ def post(self, request): status_code=429 ) - # 6. 啟動背景執行緒 + # 5. 啟動背景執行緒 thread = threading.Thread( target=run_moss_check, args=(report.id, problem_id, language) @@ -127,6 +127,8 @@ def get(self, request): # 權限檢查:必須是該題所屬課程的老師或助教 has_permission, error_msg = self._has_problem_edit_permission(request.user, problem_id) if not has_permission: + if "不存在" in error_msg: + return api_response(None, error_msg, status_code=404) return api_response(None, error_msg, status_code=403) # 只抓最新一筆 From 5c7c5bee122e5e120141b3a8e813d7c9426b84b2 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sat, 27 Dec 2025 15:23:45 +0800 Subject: [PATCH 033/113] Fix typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- copycat/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/copycat/views.py b/copycat/views.py index 5e02df5..cf9c064 100644 --- a/copycat/views.py +++ b/copycat/views.py @@ -80,7 +80,7 @@ def post(self, request): # 3. 驗證語言 if language.lower() not in LANG_DB_MAP: - return api_response(None, f"不支援的語言: {language}", status_code=400) + return api_response(None, f"不支援的語言: {language}", status_code=400) # 4. 防止重複任務 (使用 get_or_create 防止 Race Condition) report, created = CopycatReport.objects.get_or_create( From df0433febe2365eb142e4f39e40ce9cac3bccf0d Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sat, 27 Dec 2025 16:11:20 +0800 Subject: [PATCH 034/113] fix: I remove Editorial's title, difficulty, and official. --- docs/editorials.MD | 134 +++++++++++++++++++++---------------- submissions/models.py | 13 +--- submissions/serializers.py | 18 +---- submissions/views.py | 54 ++++++++++++++- 4 files changed, 131 insertions(+), 88 deletions(-) diff --git a/docs/editorials.MD b/docs/editorials.MD index b89897a..0bb3571 100644 --- a/docs/editorials.MD +++ b/docs/editorials.MD @@ -39,27 +39,31 @@ Authorization: Bearer #### 請求體字段說明 - `title` (必填): 題解標題,最大255字元 - `content` (必填): 題解內容,不能為空 -- `difficulty_rating` (可選): 難度評級 +- `difficulty_rating` (可選): 難度評級,範圍 0.0 ~ 99.9,精確到一位小數(例如:3.5) - `is_official` (可選): 是否為官方題解,默認false #### 成功響應 (201 Created) ```json { - "id": "550e8400-e29b-41d4-a716-446655440000", - "title": "題解標題", - "content": "題解內容", - "difficulty_rating": 3.5, - "is_official": true, - "problem_id": 1, - "author": "550e8400-e29b-41d4-a716-446655440001", - "author_username": "teacher1", - "likes_count": 0, - "views_count": 0, - "status": "published", - "created_at": "2025-11-02T08:00:00Z", - "updated_at": "2025-11-02T08:00:00Z", - "published_at": "2025-11-02T08:00:00Z", - "is_liked_by_user": false + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "author_username": "teacher1", + "is_liked_by_user": false, + "problem_id": 1, + "title": "題解標題", + "content": "題解內容", + "difficulty_rating": "3.5", + "likes_count": 0, + "views_count": 0, + "status": "published", + "is_official": true, + "created_at": "2025-11-02T08:00:00Z", + "updated_at": "2025-11-02T08:00:00Z", + "published_at": "2025-11-02T08:00:00Z", + "author": "550e8400-e29b-41d4-a716-446655440001" + }, + "message": "OK", + "status": "ok" } ``` @@ -81,25 +85,29 @@ Authorization: Bearer #### 成功響應 (200 OK) ```json -[ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "title": "官方題解", - "content": "這是官方題解內容...", - "difficulty_rating": 3.5, - "is_official": true, - "problem_id": 1, - "author": "550e8400-e29b-41d4-a716-446655440001", - "author_username": "teacher1", - "likes_count": 15, - "views_count": 234, - "status": "published", - "created_at": "2025-11-01T08:00:00Z", - "updated_at": "2025-11-01T08:00:00Z", - "published_at": "2025-11-01T08:00:00Z", - "is_liked_by_user": true - } -] +{ + "data": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "author_username": "teacher1", + "is_liked_by_user": true, + "problem_id": 1, + "title": "官方題解", + "content": "這是官方題解內容...", + "difficulty_rating": "3.5", + "likes_count": 15, + "views_count": 234, + "status": "published", + "is_official": true, + "created_at": "2025-11-01T08:00:00Z", + "updated_at": "2025-11-01T08:00:00Z", + "published_at": "2025-11-01T08:00:00Z", + "author": "550e8400-e29b-41d4-a716-446655440001" + } + ], + "message": "OK", + "status": "ok" +} ``` #### 錯誤響應 @@ -136,21 +144,25 @@ Authorization: Bearer #### 成功響應 (200 OK) ```json { - "id": "550e8400-e29b-41d4-a716-446655440000", - "title": "更新後的題解標題", - "content": "更新後的題解內容", - "difficulty_rating": 4.0, - "is_official": false, - "problem_id": 1, - "author": "550e8400-e29b-41d4-a716-446655440001", - "author_username": "teacher1", - "likes_count": 15, - "views_count": 234, - "status": "published", - "created_at": "2025-11-01T08:00:00Z", - "updated_at": "2025-11-02T08:00:00Z", - "published_at": "2025-11-01T08:00:00Z", - "is_liked_by_user": true + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "author_username": "teacher1", + "is_liked_by_user": true, + "problem_id": 1, + "title": "更新後的題解標題", + "content": "更新後的題解內容", + "difficulty_rating": "4.0", + "likes_count": 15, + "views_count": 234, + "status": "published", + "is_official": false, + "created_at": "2025-11-01T08:00:00Z", + "updated_at": "2025-11-02T08:00:00Z", + "published_at": "2025-11-01T08:00:00Z", + "author": "550e8400-e29b-41d4-a716-446655440001" + }, + "message": "OK", + "status": "ok" } ``` @@ -241,16 +253,21 @@ Authorization: Bearer ### 常見錯誤響應格式 ```json { - "error": "錯誤描述信息", - "detail": "詳細錯誤信息" + "data": null, + "message": "錯誤描述信息", + "status": "error" } ``` 或字段驗證錯誤: ```json { - "title": ["標題長度不能超過 255 字元"], - "content": ["內容至少需要 10 個字元"] + "data": { + "title": ["標題長度不能超過 255 字元"], + "content": ["內容至少需要 10 個字元"] + }, + "message": "驗證失敗", + "status": "error" } ``` @@ -278,7 +295,8 @@ Authorization: Bearer 1. **UUID格式**: `solutionId` 必須是有效的UUID格式 2. **內容長度**: 題解內容不能為空 3. **標題長度**: 標題最多255個字元 -4. **問題關聯**: 題解必須關聯到存在的問題 -5. **課程權限**: 問題必須屬於某個課程,且操作者必須是該課程的教學人員 -6. **狀態管理**: 創建的題解自動設為"已發布"狀態 -7. **統計更新**: 查看題解時會自動增加瀏覽次數 \ No newline at end of file +4. **難度評級**: `difficulty_rating` 的範圍是 0.0 ~ 99.9,必須是一位小數(如:1.0, 5.5, 99.9) +5. **問題關聯**: 題解必須關聯到存在的問題 +6. **課程權限**: 問題必須屬於某個課程,且操作者必須是該課程的教學人員 +7. **狀態管理**: 創建的題解自動設為"已發布"狀態 +8. **統計更新**: 查看題解時會自動增加瀏覽次數 \ No newline at end of file diff --git a/submissions/models.py b/submissions/models.py index 5371e84..116ec23 100644 --- a/submissions/models.py +++ b/submissions/models.py @@ -397,14 +397,7 @@ class Editorial(models.Model): author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) # Core fields - title = models.CharField(max_length=255) content = models.TextField() - difficulty_rating = models.DecimalField( - max_digits=3, - decimal_places=1, - null=True, - blank=True - ) # Statistics likes_count = models.IntegerField(default=0) @@ -412,7 +405,6 @@ class Editorial(models.Model): # Status status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') - is_official = models.BooleanField(default=False) # Timestamps created_at = models.DateTimeField(auto_now_add=True) @@ -421,7 +413,6 @@ class Editorial(models.Model): class Meta: indexes = [ - models.Index(fields=['problem_id', 'is_official']), models.Index(fields=['problem_id', 'likes_count']), models.Index(fields=['author', 'created_at']), ] @@ -429,7 +420,7 @@ class Meta: db_table = 'editorials' def __str__(self): - return f"Editorial {self.title} - Problem {self.problem_id} - {self.author.username}" + return f"Editorial {self.id} - Problem {self.problem_id} - {self.author.username}" class EditorialLike(models.Model): @@ -460,4 +451,4 @@ class Meta: db_table = 'editorial_likes' def __str__(self): - return f"Like {self.editorial.title} by {self.user.username}" + return f"Like Editorial {self.editorial.id} by {self.user.username}" diff --git a/submissions/serializers.py b/submissions/serializers.py index 4deee39..77558b8 100644 --- a/submissions/serializers.py +++ b/submissions/serializers.py @@ -265,15 +265,6 @@ class Meta: class EditorialCreateSerializer(serializers.ModelSerializer): - title = serializers.CharField( - max_length=255, - min_length=1, - error_messages={ - 'max_length': '標題長度不能超過 255 字元', - 'min_length': '標題不能為空', - } - ) - content = serializers.CharField( max_length=10000, min_length=1, @@ -286,14 +277,8 @@ class EditorialCreateSerializer(serializers.ModelSerializer): class Meta: model = Editorial - fields = ['id', 'title', 'content', 'difficulty_rating', 'is_official'] + fields = ['id', 'content'] read_only_fields = ['id'] - - def validate_title(self, value): - """標題驗證""" - if not value.strip(): - raise serializers.ValidationError('標題不能只包含空白字元') - return value.strip() # 統一回傳 stripped 版本 def validate_content(self, value): """內容驗證""" @@ -332,7 +317,6 @@ def get_is_liked_by_user(self, obj): class EditorialLikeSerializer(serializers.ModelSerializer): user_username = serializers.CharField(source='user.username', read_only=True) - editorial_title = serializers.CharField(source='editorial.title', read_only=True) class Meta: model = EditorialLike diff --git a/submissions/views.py b/submissions/views.py index 509ea60..6b0f7d8 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -174,7 +174,7 @@ def get_queryset(self): return Editorial.objects.filter( problem_id=problem_id, status='published' - ).order_by('-is_official', '-likes_count', '-created_at') + ).order_by('-likes_count', '-created_at') def get_serializer_class(self): if self.request.method == 'POST': @@ -202,10 +202,24 @@ def create(self, request, *args, **kwargs): if response.status_code == status.HTTP_201_CREATED: editorial = Editorial.objects.get(id=response.data['id']) serializer = EditorialSerializer(editorial, context={'request': request}) - response.data = serializer.data + return api_response( + data=serializer.data, + message='題解創建成功', + status_code=status.HTTP_201_CREATED + ) return response + def list(self, request, *args, **kwargs): + """獲取題解列表""" + queryset = self.filter_queryset(self.get_queryset()) + serializer = self.get_serializer(queryset, many=True) + return api_response( + data=serializer.data, + message='獲取題解列表成功', + status_code=status.HTTP_200_OK + ) + def perform_create(self, serializer): """創建題解時設定相關欄位""" problem_id = self.kwargs['problem_id'] @@ -265,6 +279,42 @@ def get_object(self): return obj + def retrieve(self, request, *args, **kwargs): + """獲取題解詳情""" + instance = self.get_object() + serializer = self.get_serializer(instance) + return api_response( + data=serializer.data, + message='獲取題解詳情成功', + status_code=status.HTTP_200_OK + ) + + def update(self, request, *args, **kwargs): + """更新題解""" + partial = kwargs.pop('partial', False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + # 使用 EditorialSerializer 返回完整資料 + editorial_serializer = EditorialSerializer(instance, context={'request': request}) + return api_response( + data=editorial_serializer.data, + message='題解更新成功', + status_code=status.HTTP_200_OK + ) + + def destroy(self, request, *args, **kwargs): + """刪除題解""" + instance = self.get_object() + self.perform_destroy(instance) + return api_response( + data=None, + message='題解刪除成功', + status_code=status.HTTP_204_NO_CONTENT + ) + def perform_update(self, serializer): """更新題解時保持 problem_id 不變""" problem_id = self.kwargs['problem_id'] From 8a54b41d06cac3be8a368741c0cff6614deb48e3 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sat, 27 Dec 2025 16:12:02 +0800 Subject: [PATCH 035/113] docs: I update editorials.MD. --- docs/editorials.MD | 46 +++++++++++----------------------------------- 1 file changed, 11 insertions(+), 35 deletions(-) diff --git a/docs/editorials.MD b/docs/editorials.MD index 0bb3571..b44cbe4 100644 --- a/docs/editorials.MD +++ b/docs/editorials.MD @@ -29,18 +29,12 @@ Authorization: Bearer #### 請求體 ```json { - "title": "題解標題", - "content": "題解內容", - "difficulty_rating": 3.5, - "is_official": true + "content": "題解內容" } ``` #### 請求體字段說明 -- `title` (必填): 題解標題,最大255字元 -- `content` (必填): 題解內容,不能為空 -- `difficulty_rating` (可選): 難度評級,範圍 0.0 ~ 99.9,精確到一位小數(例如:3.5) -- `is_official` (可選): 是否為官方題解,默認false +- `content` (必填): 題解內容,不能為空,最多 10000 字元 #### 成功響應 (201 Created) ```json @@ -50,13 +44,10 @@ Authorization: Bearer "author_username": "teacher1", "is_liked_by_user": false, "problem_id": 1, - "title": "題解標題", "content": "題解內容", - "difficulty_rating": "3.5", "likes_count": 0, "views_count": 0, "status": "published", - "is_official": true, "created_at": "2025-11-02T08:00:00Z", "updated_at": "2025-11-02T08:00:00Z", "published_at": "2025-11-02T08:00:00Z", @@ -92,13 +83,10 @@ Authorization: Bearer "author_username": "teacher1", "is_liked_by_user": true, "problem_id": 1, - "title": "官方題解", - "content": "這是官方題解內容...", - "difficulty_rating": "3.5", + "content": "這是題解內容...", "likes_count": 15, "views_count": 234, "status": "published", - "is_official": true, "created_at": "2025-11-01T08:00:00Z", "updated_at": "2025-11-01T08:00:00Z", "published_at": "2025-11-01T08:00:00Z", @@ -116,9 +104,8 @@ Authorization: Bearer #### 排序規則 題解按以下順序排列: -1. 官方題解優先 (`is_official=true`) -2. 按讚數量降序 -3. 創建時間降序 +1. 按讚數量降序 +2. 創建時間降序 --- @@ -134,10 +121,7 @@ Authorization: Bearer #### 請求體 ```json { - "title": "更新後的題解標題", - "content": "更新後的題解內容", - "difficulty_rating": 4.0, - "is_official": false + "content": "更新後的題解內容" } ``` @@ -149,13 +133,10 @@ Authorization: Bearer "author_username": "teacher1", "is_liked_by_user": true, "problem_id": 1, - "title": "更新後的題解標題", "content": "更新後的題解內容", - "difficulty_rating": "4.0", "likes_count": 15, "views_count": 234, "status": "published", - "is_official": false, "created_at": "2025-11-01T08:00:00Z", "updated_at": "2025-11-02T08:00:00Z", "published_at": "2025-11-01T08:00:00Z", @@ -218,13 +199,10 @@ Authorization: Bearer "author_username": "teacher1", "is_liked_by_user": false, "problem_id": 1, - "title": "動態規劃解法", "content": "這個問題可以使用動態規劃來解決...", - "difficulty_rating": "3.5", "likes_count": 0, "views_count": 0, "status": "published", - "is_official": true, "created_at": "2025-11-01T16:22:17.124771Z", "updated_at": "2025-11-01T16:22:17.124793Z", "published_at": "2025-11-01T16:22:17.124534Z", @@ -293,10 +271,8 @@ Authorization: Bearer ## 注意事項 1. **UUID格式**: `solutionId` 必須是有效的UUID格式 -2. **內容長度**: 題解內容不能為空 -3. **標題長度**: 標題最多255個字元 -4. **難度評級**: `difficulty_rating` 的範圍是 0.0 ~ 99.9,必須是一位小數(如:1.0, 5.5, 99.9) -5. **問題關聯**: 題解必須關聯到存在的問題 -6. **課程權限**: 問題必須屬於某個課程,且操作者必須是該課程的教學人員 -7. **狀態管理**: 創建的題解自動設為"已發布"狀態 -8. **統計更新**: 查看題解時會自動增加瀏覽次數 \ No newline at end of file +2. **內容長度**: 題解內容不能為空,最多 10000 字元 +3. **問題關聯**: 題解必須關聯到存在的問題 +4. **課程權限**: 問題必須屬於某個課程,且操作者必須是該課程的教學人員 +5. **狀態管理**: 創建的題解自動設為“已發布”狀態 +6. **統計更新**: 查看題解時會自動增加瀏覽次數 \ No newline at end of file From fe9b187331354a5301cb3ed9650d6c5330382367 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sat, 27 Dec 2025 16:19:28 +0800 Subject: [PATCH 036/113] fix: fix the bug of admin and makemigrations again. --- ...hecker_name_problems_use_custom_checker.py | 23 +++++++++++++++ submissions/admin.py | 14 ++++----- ..._editorials_problem_79b740_idx_and_more.py | 29 +++++++++++++++++++ 3 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 problems/migrations/0003_problems_checker_name_problems_use_custom_checker.py create mode 100644 submissions/migrations/0003_remove_editorial_editorials_problem_79b740_idx_and_more.py diff --git a/problems/migrations/0003_problems_checker_name_problems_use_custom_checker.py b/problems/migrations/0003_problems_checker_name_problems_use_custom_checker.py new file mode 100644 index 0000000..008f287 --- /dev/null +++ b/problems/migrations/0003_problems_checker_name_problems_use_custom_checker.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2025-12-27 08:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('problems', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='problems', + name='checker_name', + field=models.CharField(blank=True, default='diff', help_text="Name of the checker to use. 'diff' is the standard default checker when use_custom_checker is False. Common values: 'diff' (exact text match, default), 'float' (floating-point comparison), 'token' (token-based), 'custom' (problem-specific).", max_length=100), + ), + migrations.AddField( + model_name='problems', + name='use_custom_checker', + field=models.BooleanField(default=False, help_text='Whether to use a custom checker instead of the default diff comparison.'), + ), + ] diff --git a/submissions/admin.py b/submissions/admin.py index 836d58e..a5d8847 100644 --- a/submissions/admin.py +++ b/submissions/admin.py @@ -290,23 +290,23 @@ def has_add_permission(self, request, obj=None): @admin.register(Editorial) class EditorialAdmin(admin.ModelAdmin): - list_display = ['short_id', 'title', 'problem_id', 'author', 'status', - 'is_official', 'likes_count', 'views_count', 'created_at'] - list_filter = ['status', 'is_official', 'created_at'] - search_fields = ['id', 'title', 'problem_id', 'author__username', 'content'] + list_display = ['short_id', 'problem_id', 'author', 'status', + 'likes_count', 'views_count', 'created_at'] + list_filter = ['status', 'created_at'] + search_fields = ['id', 'problem_id', 'author__username', 'content'] readonly_fields = ['id', 'likes_count', 'views_count', 'created_at', 'updated_at'] date_hierarchy = 'created_at' ordering = ['-created_at'] fieldsets = [ ('基本資訊', { - 'fields': ['id', 'problem_id', 'author', 'title'] + 'fields': ['id', 'problem_id', 'author'] }), ('內容', { - 'fields': ['content', 'difficulty_rating'], + 'fields': ['content'], }), ('狀態與設定', { - 'fields': ['status', 'is_official', 'published_at'] + 'fields': ['status', 'published_at'] }), ('統計', { 'fields': ['likes_count', 'views_count'], diff --git a/submissions/migrations/0003_remove_editorial_editorials_problem_79b740_idx_and_more.py b/submissions/migrations/0003_remove_editorial_editorials_problem_79b740_idx_and_more.py new file mode 100644 index 0000000..9d4008a --- /dev/null +++ b/submissions/migrations/0003_remove_editorial_editorials_problem_79b740_idx_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.7 on 2025-12-27 08:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0002_initial'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='editorial', + name='editorials_problem_79b740_idx', + ), + migrations.RemoveField( + model_name='editorial', + name='difficulty_rating', + ), + migrations.RemoveField( + model_name='editorial', + name='is_official', + ), + migrations.RemoveField( + model_name='editorial', + name='title', + ), + ] From 06e2940552a8b5032f48673c2ab643d7e1dacf4d Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sat, 27 Dec 2025 20:10:19 +0800 Subject: [PATCH 037/113] docs: Update editorials.MD. --- docs/editorials.MD | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/editorials.MD b/docs/editorials.MD index b44cbe4..62563a7 100644 --- a/docs/editorials.MD +++ b/docs/editorials.MD @@ -53,7 +53,7 @@ Authorization: Bearer "published_at": "2025-11-02T08:00:00Z", "author": "550e8400-e29b-41d4-a716-446655440001" }, - "message": "OK", + "message": "獲取題解列表成功", "status": "ok" } ``` @@ -93,7 +93,7 @@ Authorization: Bearer "author": "550e8400-e29b-41d4-a716-446655440001" } ], - "message": "OK", + "message": "獲取題解列表成功", "status": "ok" } ``` @@ -142,7 +142,7 @@ Authorization: Bearer "published_at": "2025-11-01T08:00:00Z", "author": "550e8400-e29b-41d4-a716-446655440001" }, - "message": "OK", + "message": "獲取題解列表成功", "status": "ok" } ``` From 2eb64a44d0d2ca6c51bff20b72f35b483a865924 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sat, 27 Dec 2025 20:11:39 +0800 Subject: [PATCH 038/113] docs: Update editorials.MD. --- docs/editorials.MD | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/editorials.MD b/docs/editorials.MD index 62563a7..6444d91 100644 --- a/docs/editorials.MD +++ b/docs/editorials.MD @@ -241,7 +241,6 @@ Authorization: Bearer ```json { "data": { - "title": ["標題長度不能超過 255 字元"], "content": ["內容至少需要 10 個字元"] }, "message": "驗證失敗", From 2f981401cf289a67013e8c9b34736a4d179376dc Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sat, 27 Dec 2025 20:40:33 +0800 Subject: [PATCH 039/113] feat: Add many filter in GET /submission/ API. --- submissions/views.py | 52 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/submissions/views.py b/submissions/views.py index 509ea60..4df3dce 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -409,6 +409,58 @@ def get_serializer_class(self): def get_queryset(self): queryset = Submission.objects.select_related('user').order_by('-created_at') + + # 篩選參數 + problem_id = self.request.query_params.get('problem_id') + username = self.request.query_params.get('username') + status_filter = self.request.query_params.get('status') + course_id = self.request.query_params.get('course_id') + language_type = self.request.query_params.get('language_type') + before = self.request.query_params.get('before') # ISO 8601 格式 + after = self.request.query_params.get('after') # ISO 8601 格式 + + # 套用篩選條件 + if problem_id: + queryset = queryset.filter(problem_id=problem_id) + + if username: + queryset = queryset.filter(user__username=username) + + if status_filter: + queryset = queryset.filter(status=status_filter) + + if course_id: + try: + # 透過課程找到所有題目,再篩選提交 + problem_ids = Problems.objects.filter(course_id=course_id).values_list('id', flat=True) + queryset = queryset.filter(problem_id__in=problem_ids) + except Exception: + queryset = queryset.none() # 查詢失敗返回空結果 + + if language_type: + try: + queryset = queryset.filter(language_type=int(language_type)) + except ValueError: + pass # 忽略無效的語言類型 + + if before: + try: + from datetime import datetime + # 將 Unix 時間戳記轉換為 datetime 物件 + before_dt = datetime.fromtimestamp(int(before)) + queryset = queryset.filter(timestamp__lt=before_dt) + except (ValueError, TypeError): + pass # 忽略無效的時間格式 + + if after: + try: + from datetime import datetime + # 將 Unix 時間戳記轉換為 datetime 物件 + after_dt = datetime.fromtimestamp(int(after)) + queryset = queryset.filter(timestamp__gt=after_dt) + except (ValueError, TypeError): + pass # 忽略無效的時間格式 + return self.get_viewable_submissions(self.request.user, queryset) def get_client_ip(self, request): From d2ba90c3fe617ec02ccf8e84f0335d76ccafad87 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sat, 27 Dec 2025 20:41:05 +0800 Subject: [PATCH 040/113] docs: I update document about GET /submission/ API. --- docs/submissions.MD | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/submissions.MD b/docs/submissions.MD index fcc5eff..7343cf9 100644 --- a/docs/submissions.MD +++ b/docs/submissions.MD @@ -238,6 +238,18 @@ curl -X PUT http://localhost:8000/submission/a1b2c3d4-5678-90ab-cdef-1234567890a **查詢參數**: - `page` (可選): 頁碼,預設 1 - `page_size` (可選): 每頁數量,預設 20 +- `problem_id` (可選): 篩選特定題目的提交 +- `username` (可選): 篩選特定使用者的提交 +- `status` (可選): 篩選判題狀態(如 "0"=AC, "1"=WA, "2"=CE, "3"=TLE, "4"=MLE, "5"=RE) +- `course_id` (可選): 篩選特定課程的所有提交 +- `language_type` (可選): 篩選程式語言(0=C, 1=C++, 2=Python, 3=Java, 4=JavaScript) +- `before` (可選): 篩選某時間之前的提交(Unix 時間戳記,單位:秒) +- `after` (可選): 篩選某時間之後的提交(Unix 時間戳記,單位:秒) + +**權限說明**: +- 學生:只能查看自己的提交 +- 教師/助教:可以查看自己所教課程的所有學生提交 +- 管理員:可以查看所有提交 **成功響應**: HTTP 200 ```json @@ -285,8 +297,24 @@ curl -X PUT http://localhost:8000/submission/a1b2c3d4-5678-90ab-cdef-1234567890a **範例**: ```bash -# 請求 -curl -X GET http://localhost:8000/submission/?page=1&page_size=20 \ +# 基本查詢 +curl -X GET "http://localhost:8000/submission/?page=1&page_size=20" \ + -H "Authorization: Bearer YOUR_TOKEN" + +# 查詢課程 3 的所有 AC 提交 +curl -X GET "http://localhost:8000/submission/?course_id=3&status=0" \ + -H "Authorization: Bearer YOUR_TOKEN" + +# 查詢特定使用者用 Python 的提交 +curl -X GET "http://localhost:8000/submission/?username=student01&language_type=2" \ + -H "Authorization: Bearer YOUR_TOKEN" + +# 查詢特定時間範圍的提交(2025-12-27 00:00:00 到 23:59:59) +curl -X GET "http://localhost:8000/submission/?after=1735286400&before=1735372799" \ + -H "Authorization: Bearer YOUR_TOKEN" + +# 組合多個篩選條件 +curl -X GET "http://localhost:8000/submission/?problem_id=123&status=0&language_type=2" \ -H "Authorization: Bearer YOUR_TOKEN" ``` From 7a8e79c9a697be9ef73903696af3cf3dd2679465 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sat, 27 Dec 2025 21:54:36 +0800 Subject: [PATCH 041/113] fix: I fix some problem that copilot mentioned before. --- submissions/views.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/submissions/views.py b/submissions/views.py index 4df3dce..dae5d94 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -11,6 +11,7 @@ from problems.models import Problems, Problem_subtasks, Test_cases from user.models import User import uuid +from datetime import datetime # 統一的 API 響應格式 def api_response(data=None, message="OK", status_code=200): @@ -416,8 +417,8 @@ def get_queryset(self): status_filter = self.request.query_params.get('status') course_id = self.request.query_params.get('course_id') language_type = self.request.query_params.get('language_type') - before = self.request.query_params.get('before') # ISO 8601 格式 - after = self.request.query_params.get('after') # ISO 8601 格式 + before = self.request.query_params.get('before') # Unix 時間戳記 (秒) + after = self.request.query_params.get('after') # Unix 時間戳記 (秒) # 套用篩選條件 if problem_id: @@ -434,7 +435,10 @@ def get_queryset(self): # 透過課程找到所有題目,再篩選提交 problem_ids = Problems.objects.filter(course_id=course_id).values_list('id', flat=True) queryset = queryset.filter(problem_id__in=problem_ids) - except Exception: + except (ValueError, TypeError) as e: + import logging + logger = logging.getLogger(__name__) + logger.warning(f'Invalid course_id parameter: {course_id}, error: {e}') queryset = queryset.none() # 查詢失敗返回空結果 if language_type: @@ -445,20 +449,18 @@ def get_queryset(self): if before: try: - from datetime import datetime - # 將 Unix 時間戳記轉換為 datetime 物件 - before_dt = datetime.fromtimestamp(int(before)) - queryset = queryset.filter(timestamp__lt=before_dt) - except (ValueError, TypeError): + # 將 Unix 時間戳記轉換為 timezone-aware datetime 物件 (UTC) + before_dt = datetime.fromtimestamp(int(before), tz=timezone.utc) + queryset = queryset.filter(created_at__lt=before_dt) + except (ValueError, TypeError, OSError): pass # 忽略無效的時間格式 if after: try: - from datetime import datetime - # 將 Unix 時間戳記轉換為 datetime 物件 - after_dt = datetime.fromtimestamp(int(after)) - queryset = queryset.filter(timestamp__gt=after_dt) - except (ValueError, TypeError): + # 將 Unix 時間戳記轉換為 timezone-aware datetime 物件 (UTC) + after_dt = datetime.fromtimestamp(int(after), tz=timezone.utc) + queryset = queryset.filter(created_at__gt=after_dt) + except (ValueError, TypeError, OSError): pass # 忽略無效的時間格式 return self.get_viewable_submissions(self.request.user, queryset) From eb04d69ab4d2945c8e17b5e4eb11ab5bc4d9a126 Mon Sep 17 00:00:00 2001 From: RokuSennyou Date: Sun, 28 Dec 2025 01:17:31 +0800 Subject: [PATCH 042/113] feat: Add access_course field to MeSerializer for retrieving course memberships --- auths/serializers/signup.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/auths/serializers/signup.py b/auths/serializers/signup.py index 27c566d..c8bfc0d 100644 --- a/auths/serializers/signup.py +++ b/auths/serializers/signup.py @@ -1,6 +1,7 @@ from rest_framework import serializers from django.contrib.auth import get_user_model from user.models import UserProfile +from courses.models import Course_members User = get_user_model() @@ -37,9 +38,22 @@ class MeSerializer(serializers.Serializer): email = serializers.EmailField(read_only=True) role = serializers.CharField(source="identity", read_only=True) email_verified = serializers.SerializerMethodField() + access_course = serializers.SerializerMethodField() def get_email_verified(self, obj): userprofile = getattr(obj, "userprofile", None) if userprofile is not None and hasattr(userprofile, "email_verified"): return userprofile.email_verified - return False \ No newline at end of file + return False + + def get_access_course(self, user): + qs = ( + Course_members.objects + .filter( + user_id=user, + role__in=[Course_members.Role.TA, Course_members.Role.TEACHER], + ) + .values_list("course_id_id", flat=True) + .distinct() + ) + return list(qs) \ No newline at end of file From 56313c823dd50fccf18279834e435af8b590092f Mon Sep 17 00:00:00 2001 From: RokuSennyou Date: Sun, 28 Dec 2025 01:24:14 +0800 Subject: [PATCH 043/113] feat: Enhance course access retrieval in MeSerializer and add course membership creation in CourseListCreateView --- auths/serializers/signup.py | 20 +++++++++----------- courses/views/courses.py | 7 +++++++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/auths/serializers/signup.py b/auths/serializers/signup.py index c8bfc0d..d28e320 100644 --- a/auths/serializers/signup.py +++ b/auths/serializers/signup.py @@ -1,7 +1,7 @@ from rest_framework import serializers from django.contrib.auth import get_user_model from user.models import UserProfile -from courses.models import Course_members +from courses.models import Course_members, Courses User = get_user_model() @@ -47,13 +47,11 @@ def get_email_verified(self, obj): return False def get_access_course(self, user): - qs = ( - Course_members.objects - .filter( - user_id=user, - role__in=[Course_members.Role.TA, Course_members.Role.TEACHER], - ) - .values_list("course_id_id", flat=True) - .distinct() - ) - return list(qs) \ No newline at end of file + member_ids = Course_members.objects.filter( + user_id=user, + role__in=[Course_members.Role.TA, Course_members.Role.TEACHER], + ).values_list("course_id_id", flat=True) + + teacher_ids = Courses.objects.filter(teacher_id=user).values_list("id", flat=True) + + return sorted(set(member_ids) | set(teacher_ids)) \ No newline at end of file diff --git a/courses/views/courses.py b/courses/views/courses.py index 7346703..6b01f40 100644 --- a/courses/views/courses.py +++ b/courses/views/courses.py @@ -73,6 +73,13 @@ def post(self, request, *args, **kwargs): ) course = serializer.save() + + Course_members.objects.get_or_create( + course_id=course, + user_id=teacher, + defaults={"role": Course_members.Role.TEACHER}, + ) + return api_response( data={"course": {"id": course.id}}, message="Success.", From c0d67b5d3f3e10d95c250164b3c41d74e48a2e2d Mon Sep 17 00:00:00 2001 From: RokuSennyou Date: Sun, 28 Dec 2025 01:30:34 +0800 Subject: [PATCH 044/113] docs: Add access_course field to GET /auth/me/ response at README --- docs/user.MD | 67 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/docs/user.MD b/docs/user.MD index 534142c..42efcc6 100644 --- a/docs/user.MD +++ b/docs/user.MD @@ -341,7 +341,7 @@ curl -i -X POST "http://127.0.0.1:8000/auth/session/revoke/" \ **路徑**:`GET /auth/me/` **說明**: -使用目前登入狀態(Authorization Header 帶入 `Bearer `)取得當前使用者的基本資料,包含 `user_id`、`username`、`email`、`role`、`email_verified`。 +使用目前登入狀態(Authorization Header 帶入 `Bearer `)取得當前使用者的基本資料,包含 `user_id`、`username`、`email`、`role`、`email_verified`,以及使用者可管理的課程清單 `access_course`。 ### 請求格式 @@ -374,22 +374,34 @@ Authorization: Bearer "username": "momo", "email": "momo@example.com", "role": "student", - "email_verified": true + "email_verified": true, + "access_course": [1, 3, 8] } } ``` -各欄位說明: +--- + +### 各欄位說明 + +| 路徑 | 型別 | 說明 | +| --------------------- | ------ | ------------------------------------ | +| `status` | string | 狀態字串,成功固定為 `"ok"` | +| `message` | string | 描述訊息 | +| `data.user_id` | string | 使用者在系統中的唯一 ID | +| `data.username` | string | 使用者帳號 | +| `data.email` | string | 使用者註冊 email | +| `data.role` | string | 使用者角色(如:`student`、`teacher`、`admin`) | +| `data.email_verified` | bool | 是否已完成信箱驗證 | +| `data.access_course` | int[] | 使用者具有管理權限(Teacher / TA)的課程 ID 清單 | + +--- + +### 權限與說明補充 -| 路徑 | 型別 | 說明 | -| --------------- | ------ | ------------------ | -| `status` | string | 狀態字串,成功固定為 `"ok"` | -| `message` | string | 描述訊息 | -| `data.user_id` | string | 使用者在系統中的唯一 ID | -| `data.username` | string | 使用者帳號 | -| `data.email` | string | 使用者註冊 email | -| `data.role` | string | 使用者角色(如:`student`) | -| `data.email_verified` | bool | 是否驗證信箱 | +* `access_course` 會回傳目前使用者 **可管理的課程**(角色為 Teacher 或 TA)。 +* 若使用者僅為課程學生,則此欄位會回傳空陣列 `[]`。 +* 建立課程的教師會自動被加入該課程的 `Course_members`,並具有 `teacher` 權限。 --- @@ -397,20 +409,20 @@ Authorization: Bearer * 未帶 token 或 token 無效(401): - ```json - { - "detail": "Authentication credentials were not provided." - } - ``` +```json +{ + "detail": "Authentication credentials were not provided." +} +``` - 或 +或 - ```json - { - "detail": "Given token not valid for any token type", - "code": "token_not_valid" - } - ``` +```json +{ + "detail": "Given token not valid for any token type", + "code": "token_not_valid" +} +``` --- @@ -423,9 +435,10 @@ curl -X GET "http://127.0.0.1:8000/auth/me/" \ > 使用說明: > -> * 建議登入後(呼叫 `POST /auth/session/` 拿到 access)就立刻呼叫一次 `GET /auth/me/`, -> 將回傳的 `user_id`、`username`、`email`、`role` 存到前端的全域狀態(例如 Redux / Zustand / Vuex),作為之後顯示使用者名稱或判斷角色權限之用。 -> * 若收到 401,請先確認 access token 是否過期,可搭配 `POST /auth/refresh/` 重新取得新的 access。 +> * 建議登入後(呼叫 `POST /auth/session/` 取得 access token)立刻呼叫一次 `GET /auth/me/`, +> 將回傳的 `user_id`、`username`、`email`、`role`、`email_verified` 與 `access_course` +> 存入前端全域狀態(如 Redux / Zustand / Vuex),作為後續顯示使用者資訊與判斷課程管理權限的依據。 +> * 若收到 401,請確認 access token 是否過期,可搭配 `POST /auth/refresh/` 重新取得新的 access token。 --- From 48d2dfcc41926da655c22bfad92dc1e22000ac98 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sun, 28 Dec 2025 02:47:27 +0800 Subject: [PATCH 045/113] feat: Add static analysis rules and validation to Problems model --- problems/models.py | 72 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/problems/models.py b/problems/models.py index 67edd56..2957379 100644 --- a/problems/models.py +++ b/problems/models.py @@ -15,6 +15,15 @@ def unlimited_or_nonnegative(value: int): def default_supported_langs(): return ["c", "cpp", "java", "python"] +def default_static_analysis_rules(): + """預設的靜態分析規則(空列表表示不使用)""" + return [] + + +def default_forbidden_functions(): + """預設的禁止函數列表(空列表)""" + return [] + class Tags(models.Model): id = models.AutoField(primary_key=True) @@ -30,6 +39,12 @@ class Difficulty(models.TextChoices): EASY = 'easy', 'Easy' MEDIUM = 'medium', 'Medium' HARD = 'hard', 'Hard' + class StaticAnalysisRule(models.TextChoices): + """靜態分析規則選項""" + FORBID_LOOPS = 'forbid-loops', 'Forbid Loops' + FORBID_ARRAYS = 'forbid-arrays', 'Forbid Arrays' + FORBID_STL = 'forbid-stl', 'Forbid STL' + FORBID_FUNCTIONS = 'forbid-functions', 'Forbid Functions' id = models.AutoField(primary_key=True) title = models.CharField(max_length=200) @@ -80,6 +95,17 @@ class Visibility(models.TextChoices): blank=True, help_text="Name of the checker to use. 'diff' is the standard default checker when use_custom_checker is False. Common values: 'diff' (exact text match, default), 'float' (floating-point comparison), 'token' (token-based), 'custom' (problem-specific)." ) + # --- Static analysis settings --- + static_analysis_rules = models.JSONField( + default=default_static_analysis_rules, + blank=True, + help_text="List of static analysis rules to apply. Valid values: 'forbid-loops', 'forbid-arrays', 'forbid-stl', 'forbid-functions'. Empty list means no static analysis." + ) + forbidden_functions = models.JSONField( + default=default_forbidden_functions, + blank=True, + help_text="List of function names that are forbidden when 'forbid-functions' rule is enabled. Required if 'forbid-functions' is in static_analysis_rules." + ) creator_id = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='created_problems') course_id = models.ForeignKey('courses.Courses', on_delete=models.PROTECT, null=False, blank=False, related_name='courses') created_at = models.DateTimeField(default=timezone.now) @@ -122,7 +148,51 @@ def clean(self): lang = (self.solution_code_language or '').strip() if code and not lang: raise ValidationError({'solution_code_language': '當提供 solution code 時,必須指定其語言。'}) - + # Validate static_analysis_rules + valid_rules = {choice[0] for choice in self.StaticAnalysisRule.choices} + rules = self.static_analysis_rules or [] + + if not isinstance(rules, list): + raise ValidationError({'static_analysis_rules': '靜態分析規則必須是列表格式。'}) + + for rule in rules: + if rule not in valid_rules: + raise ValidationError({ + 'static_analysis_rules': f'無效的靜態分析規則: {rule}。有效值為: {", ".join(valid_rules)}' + }) + + # Validate forbidden_functions when forbid-functions is enabled + if 'forbid-functions' in rules: + functions = self.forbidden_functions or [] + if not isinstance(functions, list): + raise ValidationError({'forbidden_functions': '禁止函數列表必須是列表格式。'}) + if len(functions) == 0: + raise ValidationError({ + 'forbidden_functions': '當啟用 forbid-functions 規則時,必須至少指定一個禁止使用的函數。' + }) + for func in functions: + if not isinstance(func, str) or not func.strip(): + raise ValidationError({'forbidden_functions': '函數名稱必須是非空字串。'}) + + @property + def use_static_analysis(self) -> bool: + """是否啟用靜態分析""" + return bool(self.static_analysis_rules) + + def get_static_analysis_config(self) -> dict: + """取得靜態分析配置(供 Sandbox 使用)""" + if not self.static_analysis_rules: + return {'enabled': False} + + config = { + 'enabled': True, + 'rules': self.static_analysis_rules, + } + + if 'forbid-functions' in self.static_analysis_rules: + config['forbidden_functions'] = self.forbidden_functions or [] + + return config class Problem_subtasks(models.Model): id = models.AutoField(primary_key=True) From b309dec1621dec11eedc7e3e6d68971828760671 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sun, 28 Dec 2025 02:56:02 +0800 Subject: [PATCH 046/113] feat: Add static analysis settings to ProblemManageSerializer and ProblemDetailView --- problems/serializers/core.py | 146 +++++++++++++++++++++++++++++++++++ problems/views/api.py | 5 ++ 2 files changed, 151 insertions(+) diff --git a/problems/serializers/core.py b/problems/serializers/core.py index d8df45b..6146d7f 100644 --- a/problems/serializers/core.py +++ b/problems/serializers/core.py @@ -32,6 +32,78 @@ class Meta: "status", ] +class ProblemManageSerializer(serializers.ModelSerializer): + """題目管理用的 Serializer(建立/更新)""" + + tags = serializers.ListField( + child=serializers.IntegerField(), + required=False, + write_only=True, + help_text="標籤 ID 列表" + ) + + static_analysis_rules = serializers.ListField( + child=serializers.ChoiceField(choices=[ + 'forbid-loops', + 'forbid-arrays', + 'forbid-stl', + 'forbid-functions' + ]), + required=False, + default=list, + help_text="靜態分析規則列表。有效值: 'forbid-loops', 'forbid-arrays', 'forbid-stl', 'forbid-functions'" + ) + + forbidden_functions = serializers.ListField( + child=serializers.CharField(max_length=100), + required=False, + default=list, + help_text="禁止使用的函數名稱列表。當 static_analysis_rules 包含 'forbid-functions' 時必填" + ) + + class Meta: + model = Problems + fields = [ + 'id', 'title', 'description', 'difficulty', 'is_public', + 'max_score', 'total_quota', + 'input_description', 'output_description', + 'sample_input', 'sample_output', + 'hint', 'subtask_description', + 'supported_languages', + 'solution_code', 'solution_code_language', + 'use_custom_checker', 'checker_name', + 'static_analysis_rules', 'forbidden_functions', + 'course_id', 'tags', + 'created_at', 'updated_at', + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def validate(self, attrs): + """驗證靜態分析規則和禁止函數的一致性""" + rules = attrs.get('static_analysis_rules', []) + forbidden_funcs = attrs.get('forbidden_functions', []) + + # 如果是更新操作,需要考慮現有值 + if self.instance: + if 'static_analysis_rules' not in attrs: + rules = self.instance.static_analysis_rules or [] + if 'forbidden_functions' not in attrs: + forbidden_funcs = self.instance.forbidden_functions or [] + + # 驗證:當選擇 forbid-functions 時,必須提供至少一個禁止函數 + if 'forbid-functions' in rules: + if not forbidden_funcs: + raise serializers.ValidationError({ + 'forbidden_functions': '當啟用 forbid-functions 規則時,必須至少指定一個禁止使用的函數。' + }) + # 驗證每個函數名稱 + for func in forbidden_funcs: + if not func or not func.strip(): + raise serializers.ValidationError({ + 'forbidden_functions': '函數名稱不能為空字串。' + }) + + return attrs class SubtaskSerializer(serializers.ModelSerializer): """完整 subtask 資訊(管理員用)""" @@ -74,6 +146,26 @@ class ProblemSerializer(serializers.ModelSerializer): allow_null=False ) course_name = serializers.CharField(source="course_id.name", read_only=True) + + # 靜態分析設定 + static_analysis_rules = serializers.ListField( + child=serializers.ChoiceField(choices=[ + 'forbid-loops', + 'forbid-arrays', + 'forbid-stl', + 'forbid-functions' + ]), + required=False, + default=list, + help_text="靜態分析規則列表。有效值: 'forbid-loops', 'forbid-arrays', 'forbid-stl', 'forbid-functions'" + ) + + forbidden_functions = serializers.ListField( + child=serializers.CharField(max_length=100), + required=False, + default=list, + help_text="禁止使用的函數名稱列表。當 static_analysis_rules 包含 'forbid-functions' 時必填" + ) class Meta: model = Problems @@ -88,6 +180,8 @@ class Meta: "solution_code", "solution_code_language", # custom checker settings "use_custom_checker", "checker_name", + # static analysis settings + "static_analysis_rules", "forbidden_functions", "creator_id", "course_id", "course_name", "created_at", "updated_at", "tags", "tag_ids", @@ -98,6 +192,33 @@ class Meta: "creator_id", "created_at", "updated_at", ] + def validate(self, attrs): + """驗證靜態分析規則和禁止函數的一致性""" + rules = attrs.get('static_analysis_rules', []) + forbidden_funcs = attrs.get('forbidden_functions', []) + + # 如果是更新操作,需要考慮現有值 + if self.instance: + if 'static_analysis_rules' not in attrs: + rules = self.instance.static_analysis_rules or [] + if 'forbidden_functions' not in attrs: + forbidden_funcs = self.instance.forbidden_functions or [] + + # 驗證:當選擇 forbid-functions 時,必須提供至少一個禁止函數 + if 'forbid-functions' in rules: + if not forbidden_funcs: + raise serializers.ValidationError({ + 'forbidden_functions': '當啟用 forbid-functions 規則時,必須至少指定一個禁止使用的函數。' + }) + # 驗證每個函數名稱 + for func in forbidden_funcs: + if not func or not func.strip(): + raise serializers.ValidationError({ + 'forbidden_functions': '函數名稱不能為空字串。' + }) + + return attrs + def create(self, validated_data): # Extract tag_ids (write-only field) tag_ids = validated_data.pop('tag_ids', None) @@ -184,6 +305,10 @@ class ProblemDetailSerializer(serializers.ModelSerializer): child=serializers.IntegerField(min_value=1), write_only=True, required=False ) subtasks = SubtaskSerializer(many=True, read_only=True) + + # 靜態分析設定(唯讀,管理員視角顯示用) + static_analysis_config = serializers.SerializerMethodField() + use_static_analysis = serializers.SerializerMethodField() class Meta: model = Problems @@ -196,6 +321,9 @@ class Meta: "subtask_description", "supported_languages", # custom checker settings "use_custom_checker", "checker_name", + # static analysis settings + "static_analysis_rules", "forbidden_functions", + "static_analysis_config", "use_static_analysis", "creator_id", "course_id", "created_at", "updated_at", "tags", "tag_ids", "subtasks", @@ -206,6 +334,14 @@ class Meta: "creator_id", "created_at", "updated_at", ] + def get_static_analysis_config(self, obj): + """取得靜態分析配置(供前端顯示)""" + return obj.get_static_analysis_config() + + def get_use_static_analysis(self, obj): + """是否啟用靜態分析""" + return obj.use_static_analysis + class ProblemStudentSerializer(serializers.ModelSerializer): """學生版題目詳情(隱藏敏感資訊 + 加上個人化資訊)""" @@ -220,6 +356,9 @@ class ProblemStudentSerializer(serializers.ModelSerializer): submit_count = serializers.IntegerField(read_only=True, default=0) high_score = serializers.IntegerField(read_only=True, default=0) is_liked_by_user = serializers.SerializerMethodField() + + # 靜態分析設定(學生只需知道是否啟用) + use_static_analysis = serializers.SerializerMethodField() class Meta: model = Problems @@ -232,6 +371,8 @@ class Meta: "subtask_description", "supported_languages", # custom checker settings (read-only for students) "use_custom_checker", "checker_name", + # static analysis (read-only for students) + "static_analysis_rules", "use_static_analysis", "course_id", "created_at", "tags", "tag_ids", "subtasks", @@ -240,9 +381,14 @@ class Meta: read_only_fields = [ "acceptance_rate", "total_submissions", "accepted_submissions", "use_custom_checker", "checker_name", + "static_analysis_rules", "created_at", ] + def get_use_static_analysis(self, obj): + """是否啟用靜態分析""" + return obj.use_static_analysis + def get_is_liked_by_user(self, obj): request = self.context.get('request') if request and request.user and request.user.is_authenticated: diff --git a/problems/views/api.py b/problems/views/api.py index 942bef1..43826f6 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -1088,6 +1088,11 @@ def _to_list(text: str): # Custom checker settings 'use_custom_checker': getattr(problem, 'use_custom_checker', False), 'checker_name': getattr(problem, 'checker_name', 'diff'), + # Static analysis settings + 'static_analysis_rules': getattr(problem, 'static_analysis_rules', []), + 'forbidden_functions': getattr(problem, 'forbidden_functions', []), + 'use_static_analysis': getattr(problem, 'use_static_analysis', False), + 'static_analysis_config': problem.get_static_analysis_config() if hasattr(problem, 'get_static_analysis_config') else {'enabled': False}, } return api_response(data, "Problem can view.", status_code=200) From 86699fcc85a0ad86d284bdb73ff4872e24a6bade Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sun, 28 Dec 2025 03:02:32 +0800 Subject: [PATCH 047/113] feat: Add static analysis fields and configuration to problem response and documentation --- docs/problems.MD | 87 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/docs/problems.MD b/docs/problems.MD index 2e379a8..f4d2e9b 100644 --- a/docs/problems.MD +++ b/docs/problems.MD @@ -41,7 +41,14 @@ ], "fillInTemplate": null, "submitCount": 2, - "highScore": 80 + "highScore": 80, + "static_analysis_rules": ["forbid-loops"], + "forbidden_functions": [], + "use_static_analysis": true, + "static_analysis_config": { + "enabled": true, + "rules": ["forbid-loops"] + } }, "message": "Problem can view.", "status": "200" @@ -51,6 +58,10 @@ - `description.sampleInput` / `description.sampleOutput`:後端已將原始換行字串依行切割為陣列;若為空字串則回傳 `[]`。 - `allowedLanguage`:語言遮罩(c=1, cpp=2, java=4, python=8;相加形成整數)。 - `testCase`:僅在題目已有上傳測資 zip 且解析成功時出現。沒有則為空陣列。 +- `static_analysis_rules`:靜態分析規則列表,可選值:`forbid-loops`、`forbid-arrays`、`forbid-stl`、`forbid-functions`。空陣列表示不使用靜態分析。 +- `forbidden_functions`:禁止使用的函數名稱列表。當 `static_analysis_rules` 包含 `forbid-functions` 時會有值。 +- `use_static_analysis`:布林值,表示是否啟用靜態分析(根據 `static_analysis_rules` 自動計算)。 +- `static_analysis_config`:完整的靜態分析配置物件(供 Sandbox 使用)。 未登入或無權限時可能回 403;若是舊邏輯隱藏題目也可能視需求回 404。 **cURL** @@ -430,6 +441,8 @@ curl "http://127.0.0.1:8000/problem/$PROBLEM_ID/stats" \ | hint | string/null | ❌ | null | 提示文字 | | subtask_description | string/null | ❌ | null | 子任務敘述(若有 subtasks) | | supported_languages | array | ❌ | `["c","cpp","java","python"]` | 可提交語言(陣列字串) | +| static_analysis_rules | string[] | ❌ | `[]` | 靜態分析規則列表,可選值見下方說明 | +| forbidden_functions | string[] | ❌ | `[]` | 禁止使用的函數名稱列表(當啟用 `forbid-functions` 規則時必填且至少一個) | | tags | int[] | ❌ | (無) | 標籤 id 陣列;嚴格驗證全部必須存在 | 備註: @@ -437,6 +450,32 @@ curl "http://127.0.0.1:8000/problem/$PROBLEM_ID/stats" \ 2. `tags` 為寫入用;回傳時使用 `tags` 物件陣列(含 id/name/usage_count)。 3. 不支援在建立時直接寫 `like_count`、`view_count` 等統計欄位。 +### 靜態分析規則說明 + +`static_analysis_rules` 可選用以下規則(可多選組合): + +| 規則 | 說明 | 適用情境 | +| ---- | ---- | -------- | +| `forbid-loops` | 禁止使用迴圈(for、while、do-while) | 遞迴練習題 | +| `forbid-arrays` | 禁止使用陣列 | 指標練習題 | +| `forbid-stl` | 禁止使用 STL 容器和演算法 | 資料結構實作題 | +| `forbid-functions` | 禁止使用特定函數 | 演算法實作題(禁用內建排序等) | + +**驗證規則**: +- 當 `static_analysis_rules` 包含 `forbid-functions` 時,`forbidden_functions` 必須提供且至少包含一個函數名稱。 +- 函數名稱不能為空字串。 + +**回應中的額外欄位**: +- `use_static_analysis`(boolean):是否啟用靜態分析,根據 `static_analysis_rules` 是否為空自動計算。 +- `static_analysis_config`(object):完整配置,供 Sandbox 判題使用,格式如下: +```json +{ + "enabled": true, + "rules": ["forbid-functions", "forbid-stl"], + "forbidden_functions": ["sort", "qsort"] +} +``` + ### 最小可建立 Payload(僅必填) ```json { @@ -463,6 +502,8 @@ curl "http://127.0.0.1:8000/problem/$PROBLEM_ID/stats" \ "hint": "使用雜湊表", "subtask_description": "Subtask1: n<=1000; Subtask2: n<=1e5", "supported_languages": ["c","cpp","python"], + "static_analysis_rules": ["forbid-functions"], + "forbidden_functions": ["sort", "qsort"], "tags": [1,2] } ``` @@ -628,6 +669,50 @@ curl -X PUT http://127.0.0.1:8000/problem/manage/$PROBLEM_ID \ }' ``` +### 靜態分析設定範例 + +> 啟用禁止迴圈和陣列(遞迴練習) +```bash +curl -X PUT http://127.0.0.1:8000/problem/manage/$PROBLEM_ID \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "static_analysis_rules": ["forbid-loops", "forbid-arrays"] + }' +``` + +> 啟用禁止特定函數(演算法實作) +```bash +curl -X PUT http://127.0.0.1:8000/problem/manage/$PROBLEM_ID \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "static_analysis_rules": ["forbid-functions", "forbid-stl"], + "forbidden_functions": ["sort", "qsort", "stable_sort", "nth_element"] + }' +``` + +> 停用靜態分析 +```bash +curl -X PUT http://127.0.0.1:8000/problem/manage/$PROBLEM_ID \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "static_analysis_rules": [] + }' +``` + +> 靜態分析驗證錯誤(啟用 forbid-functions 但未提供函數列表,預期 400/422) +```bash +curl -X PUT http://127.0.0.1:8000/problem/manage/$PROBLEM_ID \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "static_analysis_rules": ["forbid-functions"] + }' +# 回應:{"forbidden_functions": ["當啟用 forbid-functions 規則時,必須至少指定一個禁止使用的函數。"]} +``` + --- ## 刪除題目(管理) From 64857b75ea55203339f4acb42aabd1fbd3f73e52 Mon Sep 17 00:00:00 2001 From: chewinggum-master <41247003s@gapps.ntnu.edu.tw> Date: Sun, 28 Dec 2025 03:01:10 +0800 Subject: [PATCH 048/113] =?UTF-8?q?feat;=E6=96=B0=E5=A2=9Euser=20name?= =?UTF-8?q?=E5=9C=A8stat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- problems/views/api.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/problems/views/api.py b/problems/views/api.py index 942bef1..98ee5d2 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -1148,11 +1148,33 @@ def get(self, request, pk): for row in submissions.values('status').annotate(cnt=Count('id')): status_count[row['status']] = row['cnt'] - # 9. top10執行時間 - top10_runtime = list(submissions.filter(execution_time__gt=0).order_by('execution_time')[:10].values('id', 'user', 'execution_time', 'score', 'status')) - - # 10. top10記憶體 - top10_memory = list(submissions.filter(memory_usage__gt=0).order_by('memory_usage')[:10].values('id', 'user', 'memory_usage', 'score', 'status')) + # 9. top10執行時間(含使用者 username,方便前端顯示) + runtime_qs = submissions.filter(execution_time__gt=0).select_related('user').order_by('execution_time')[:10] + top10_runtime = [ + { + 'id': s.id, + 'user': s.user_id, + 'username': getattr(s.user, 'username', ''), + 'execution_time': s.execution_time, + 'score': s.score, + 'status': s.status, + } + for s in runtime_qs + ] + + # 10. top10記憶體(含使用者 username) + memory_qs = submissions.filter(memory_usage__gt=0).select_related('user').order_by('memory_usage')[:10] + top10_memory = [ + { + 'id': s.id, + 'user': s.user_id, + 'username': getattr(s.user, 'username', ''), + 'memory_usage': s.memory_usage, + 'score': s.score, + 'status': s.status, + } + for s in memory_qs + ] return api_response({ "acUserRatio": [ac_user_count, total_students], From acfff8d8e3af361d6af43c43dcd0f41c4ca77058 Mon Sep 17 00:00:00 2001 From: chewinggum-master <41247003s@gapps.ntnu.edu.tw> Date: Sun, 28 Dec 2025 04:48:58 +0800 Subject: [PATCH 049/113] =?UTF-8?q?feat:get/problem=E6=96=B0=E5=A2=9Esubmi?= =?UTF-8?q?t=5Fcount=E3=80=81get/problem/{id}=E6=96=B0=E5=A2=9EsubtaskDesc?= =?UTF-8?q?ription?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- problems/serializers/core.py | 3 ++- problems/views/api.py | 31 +++++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/problems/serializers/core.py b/problems/serializers/core.py index d8df45b..70031b7 100644 --- a/problems/serializers/core.py +++ b/problems/serializers/core.py @@ -74,6 +74,7 @@ class ProblemSerializer(serializers.ModelSerializer): allow_null=False ) course_name = serializers.CharField(source="course_id.name", read_only=True) + submit_count = serializers.IntegerField(read_only=True, default=0) class Meta: model = Problems @@ -90,7 +91,7 @@ class Meta: "use_custom_checker", "checker_name", "creator_id", "course_id", "course_name", "created_at", "updated_at", - "tags", "tag_ids", + "tags", "tag_ids", "submit_count", ] read_only_fields = [ "acceptance_rate", "like_count", "view_count", diff --git a/problems/views/api.py b/problems/views/api.py index 98ee5d2..d6ec524 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -907,10 +907,36 @@ def get(self, request): page = paginator.paginate_queryset(queryset, request) if page is not None: serializer = ProblemSerializer(page, many=True) - return paginator.get_paginated_response(serializer.data) + data = serializer.data + # Inject per-user submit_count + if request.user.is_authenticated: + from submissions.models import Submission + problem_ids = [item['id'] for item in data] + user_submissions = Submission.objects.filter( + user=request.user, problem_id__in=problem_ids + ).values_list('problem_id', flat=True) + submit_counts = {} + for pid in problem_ids: + submit_counts[pid] = list(user_submissions).count(pid) + for item in data: + item['submit_count'] = submit_counts.get(item['id'], 0) + return paginator.get_paginated_response(data) serializer = ProblemSerializer(queryset, many=True) - return api_response(serializer.data, "OK", status_code=200) + data = serializer.data + # Inject per-user submit_count + if request.user.is_authenticated: + from submissions.models import Submission + problem_ids = [item['id'] for item in data] + user_submissions = Submission.objects.filter( + user=request.user, problem_id__in=problem_ids + ).values_list('problem_id', flat=True) + submit_counts = {} + for pid in problem_ids: + submit_counts[pid] = list(user_submissions).count(pid) + for item in data: + item['submit_count'] = submit_counts.get(item['id'], 0) + return api_response(data, "OK", status_code=200) # 重要:不允許在 /problem/ 進行建立,統一走 /problem/manage # 若誤用 POST /problem/,回傳 405,請改用 /problem/manage @@ -1085,6 +1111,7 @@ def _to_list(text: str): 'fillInTemplate': fill_in_template, 'submitCount': submit_count, 'highScore': high_score, + 'subtaskDescription': getattr(problem, 'subtask_description', ''), # Custom checker settings 'use_custom_checker': getattr(problem, 'use_custom_checker', False), 'checker_name': getattr(problem, 'checker_name', 'diff'), From 9c1ae6d248fa190a9a10240cad9b6859de7e9c4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 06:41:00 +0000 Subject: [PATCH 050/113] Initial plan From e1c866b8d675491ce31c3c97fa381225ce68983f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 06:45:20 +0000 Subject: [PATCH 051/113] refactor: Address review comments - fix docs, remove dead code, improve class structure, remove duplicate validation Co-authored-by: happylittle7 <7501374+happylittle7@users.noreply.github.com> --- docs/problems.MD | 2 +- problems/models.py | 10 +-- problems/serializers/core.py | 122 ++++++++--------------------------- problems/views/api.py | 2 +- 4 files changed, 34 insertions(+), 102 deletions(-) diff --git a/docs/problems.MD b/docs/problems.MD index f4d2e9b..8ed7db8 100644 --- a/docs/problems.MD +++ b/docs/problems.MD @@ -702,7 +702,7 @@ curl -X PUT http://127.0.0.1:8000/problem/manage/$PROBLEM_ID \ }' ``` -> 靜態分析驗證錯誤(啟用 forbid-functions 但未提供函數列表,預期 400/422) +> 靜態分析驗證錯誤(啟用 forbid-functions 但未提供函數列表,預期 400) ```bash curl -X PUT http://127.0.0.1:8000/problem/manage/$PROBLEM_ID \ -H "Authorization: Bearer $TOKEN" \ diff --git a/problems/models.py b/problems/models.py index 2957379..873f280 100644 --- a/problems/models.py +++ b/problems/models.py @@ -39,21 +39,23 @@ class Difficulty(models.TextChoices): EASY = 'easy', 'Easy' MEDIUM = 'medium', 'Medium' HARD = 'hard', 'Hard' + class StaticAnalysisRule(models.TextChoices): """靜態分析規則選項""" FORBID_LOOPS = 'forbid-loops', 'Forbid Loops' FORBID_ARRAYS = 'forbid-arrays', 'Forbid Arrays' FORBID_STL = 'forbid-stl', 'Forbid STL' FORBID_FUNCTIONS = 'forbid-functions', 'Forbid Functions' + + class Visibility(models.TextChoices): + HIDDEN = 'hidden', 'Hidden' + COURSE = 'course', 'Course only' + PUBLIC = 'public', 'Public' id = models.AutoField(primary_key=True) title = models.CharField(max_length=200) difficulty = models.CharField(max_length=10, choices=Difficulty.choices, default=Difficulty.MEDIUM) max_score = models.IntegerField(default=100) - class Visibility(models.TextChoices): - HIDDEN = 'hidden', 'Hidden' - COURSE = 'course', 'Course only' - PUBLIC = 'public', 'Public' # Visibility of problem: hidden (only owner/admin), course (course members), public (everyone) # Keep field name `is_public` for backward compatibility, but store tri-state choice value. diff --git a/problems/serializers/core.py b/problems/serializers/core.py index 6146d7f..00b96d1 100644 --- a/problems/serializers/core.py +++ b/problems/serializers/core.py @@ -32,79 +32,6 @@ class Meta: "status", ] -class ProblemManageSerializer(serializers.ModelSerializer): - """題目管理用的 Serializer(建立/更新)""" - - tags = serializers.ListField( - child=serializers.IntegerField(), - required=False, - write_only=True, - help_text="標籤 ID 列表" - ) - - static_analysis_rules = serializers.ListField( - child=serializers.ChoiceField(choices=[ - 'forbid-loops', - 'forbid-arrays', - 'forbid-stl', - 'forbid-functions' - ]), - required=False, - default=list, - help_text="靜態分析規則列表。有效值: 'forbid-loops', 'forbid-arrays', 'forbid-stl', 'forbid-functions'" - ) - - forbidden_functions = serializers.ListField( - child=serializers.CharField(max_length=100), - required=False, - default=list, - help_text="禁止使用的函數名稱列表。當 static_analysis_rules 包含 'forbid-functions' 時必填" - ) - - class Meta: - model = Problems - fields = [ - 'id', 'title', 'description', 'difficulty', 'is_public', - 'max_score', 'total_quota', - 'input_description', 'output_description', - 'sample_input', 'sample_output', - 'hint', 'subtask_description', - 'supported_languages', - 'solution_code', 'solution_code_language', - 'use_custom_checker', 'checker_name', - 'static_analysis_rules', 'forbidden_functions', - 'course_id', 'tags', - 'created_at', 'updated_at', - ] - read_only_fields = ['id', 'created_at', 'updated_at'] - - def validate(self, attrs): - """驗證靜態分析規則和禁止函數的一致性""" - rules = attrs.get('static_analysis_rules', []) - forbidden_funcs = attrs.get('forbidden_functions', []) - - # 如果是更新操作,需要考慮現有值 - if self.instance: - if 'static_analysis_rules' not in attrs: - rules = self.instance.static_analysis_rules or [] - if 'forbidden_functions' not in attrs: - forbidden_funcs = self.instance.forbidden_functions or [] - - # 驗證:當選擇 forbid-functions 時,必須提供至少一個禁止函數 - if 'forbid-functions' in rules: - if not forbidden_funcs: - raise serializers.ValidationError({ - 'forbidden_functions': '當啟用 forbid-functions 規則時,必須至少指定一個禁止使用的函數。' - }) - # 驗證每個函數名稱 - for func in forbidden_funcs: - if not func or not func.strip(): - raise serializers.ValidationError({ - 'forbidden_functions': '函數名稱不能為空字串。' - }) - - return attrs - class SubtaskSerializer(serializers.ModelSerializer): """完整 subtask 資訊(管理員用)""" test_cases = TestCaseSerializer(many=True, read_only=True) @@ -193,29 +120,32 @@ class Meta: ] def validate(self, attrs): - """驗證靜態分析規則和禁止函數的一致性""" - rules = attrs.get('static_analysis_rules', []) - forbidden_funcs = attrs.get('forbidden_functions', []) - - # 如果是更新操作,需要考慮現有值 - if self.instance: - if 'static_analysis_rules' not in attrs: - rules = self.instance.static_analysis_rules or [] - if 'forbidden_functions' not in attrs: - forbidden_funcs = self.instance.forbidden_functions or [] - - # 驗證:當選擇 forbid-functions 時,必須提供至少一個禁止函數 - if 'forbid-functions' in rules: - if not forbidden_funcs: - raise serializers.ValidationError({ - 'forbidden_functions': '當啟用 forbid-functions 規則時,必須至少指定一個禁止使用的函數。' - }) - # 驗證每個函數名稱 - for func in forbidden_funcs: - if not func or not func.strip(): - raise serializers.ValidationError({ - 'forbidden_functions': '函數名稱不能為空字串。' - }) + """ + 透過 Problems.clean() 執行驗證,避免在序列化器中重複商業邏輯。 + """ + # 建立或重用 Problems 實例以反映驗證後的狀態 + if self.instance is not None: + instance = self.instance + for field, value in attrs.items(): + setattr(instance, field, value) + else: + # 建立臨時實例用於驗證(需要必填欄位) + temp_attrs = attrs.copy() + # 確保必填欄位存在 + if 'creator_id' not in temp_attrs: + # 使用假的 creator_id 僅用於驗證 + temp_attrs['creator_id_id'] = 1 + if 'course_id' not in temp_attrs: + # 使用假的 course_id 僅用於驗證 + temp_attrs['course_id_id'] = 1 + instance = Problems(**temp_attrs) + + # 將實際驗證委派給模型的 clean(),避免重複邏輯 + try: + instance.clean() + except ValidationError as e: + # 將 Django ValidationError 轉換為 DRF ValidationError + raise serializers.ValidationError(e.message_dict if hasattr(e, 'message_dict') else {'non_field_errors': e.messages}) return attrs diff --git a/problems/views/api.py b/problems/views/api.py index 43826f6..2d23b7b 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -1091,7 +1091,7 @@ def _to_list(text: str): # Static analysis settings 'static_analysis_rules': getattr(problem, 'static_analysis_rules', []), 'forbidden_functions': getattr(problem, 'forbidden_functions', []), - 'use_static_analysis': getattr(problem, 'use_static_analysis', False), + 'use_static_analysis': problem.use_static_analysis if hasattr(problem, 'use_static_analysis') else False, 'static_analysis_config': problem.get_static_analysis_config() if hasattr(problem, 'get_static_analysis_config') else {'enabled': False}, } From 7d23b0849d41ad90bcd4f36d06ce2d468101a429 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 14:47:04 +0800 Subject: [PATCH 052/113] feat: I add IP whitelist in GET /submission/ API and modify test code to verify email. --- submissions/test_file/get_test_token.py | 20 +- submissions/views.py | 236 +++++++++++++++++++++++- 2 files changed, 250 insertions(+), 6 deletions(-) diff --git a/submissions/test_file/get_test_token.py b/submissions/test_file/get_test_token.py index dcc1a2f..e63017c 100644 --- a/submissions/test_file/get_test_token.py +++ b/submissions/test_file/get_test_token.py @@ -39,9 +39,25 @@ def create_test_user(): if created: user.set_password(password) user.save() - print(f"創建新用戶: {username}") + print(f"✓ 創建新用戶: {username}") else: - print(f"使用現有用戶: {username}") + print(f"✓ 使用現有用戶: {username}") + + # 確保 email 已驗證(測試用) + try: + from user.models import UserProfile + profile, profile_created = UserProfile.objects.get_or_create( + user=user, + defaults={'email_verified': True} + ) + if not profile.email_verified: + profile.email_verified = True + profile.save() + print(f"✓ Email 已設為驗證狀態") + else: + print(f"✓ Email 已是驗證狀態") + except Exception as e: + print(f"⚠ 無法設置 email 驗證狀態: {e}") return user, password diff --git a/submissions/views.py b/submissions/views.py index b6646ba..d3d9234 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -12,6 +12,7 @@ from user.models import User import uuid from datetime import datetime +import logging # 統一的 API 響應格式 def api_response(data=None, message="OK", status_code=200): @@ -42,6 +43,198 @@ def api_response(data=None, message="OK", status_code=200): from courses.models import Courses, Course_members +def update_user_problem_stats(submission): + """ + 更新使用者題目解題統計(全域層級) + + 根據提交結果更新 UserProblemSolveStatus,包括: + - 總提交數 + - AC 提交數 + - 最佳分數 + - 首次解題時間 + - 最後提交時間 + - 解題狀態(never_tried/attempted/partial_solved/fully_solved) + - 最佳執行時間和記憶體使用 + """ + from django.utils import timezone + from django.db.models import F + + try: + # 取得或創建 UserProblemSolveStatus + stats, created = UserProblemSolveStatus.objects.get_or_create( + user=submission.user, + problem_id=submission.problem_id, + defaults={ + 'total_submissions': 0, + 'ac_submissions': 0, + 'best_score': 0, + 'solve_status': 'never_tried', + } + ) + + # 更新總提交數 + stats.total_submissions = F('total_submissions') + 1 + + # 更新最後提交時間 + stats.last_submission_time = submission.created_at + + # 如果是 AC (status='0') + if submission.status == '0': + stats.ac_submissions = F('ac_submissions') + 1 + + # 更新首次解題時間(如果是第一次 AC) + if not stats.first_solve_time: + stats.first_solve_time = submission.judged_at or timezone.now() + + # 先保存以計算 F() 表達式 + stats.save() + stats.refresh_from_db() + + # 更新最佳分數 + if submission.score > stats.best_score: + stats.best_score = submission.score + + # 更新最佳執行時間(只在有效時更新) + if submission.execution_time > 0: + if stats.best_execution_time is None or submission.execution_time < stats.best_execution_time: + stats.best_execution_time = submission.execution_time + stats.total_execution_time += submission.execution_time + + # 更新最佳記憶體使用(只在有效時更新) + if submission.memory_usage > 0: + if stats.best_memory_usage is None or submission.memory_usage < stats.best_memory_usage: + stats.best_memory_usage = submission.memory_usage + + # 更新解題狀態 + if stats.best_score >= 100: + stats.solve_status = 'fully_solved' + elif stats.best_score > 0: + stats.solve_status = 'partial_solved' + elif stats.total_submissions > 0: + stats.solve_status = 'attempted' + else: + stats.solve_status = 'never_tried' + + stats.save() + + logger = logging.getLogger(__name__) + logger.info(f'Updated solve status for user {submission.user.id} problem {submission.problem_id}: ' + f'status={stats.solve_status}, best_score={stats.best_score}, ' + f'total_submissions={stats.total_submissions}') + + except Exception as e: + logger = logging.getLogger(__name__) + logger.error(f'Failed to update user problem solve status: {str(e)}', exc_info=True) + +''' 這邊不再需要了,算成績的部分交給前端處理 +def update_user_assignment_stats(submission, assignment_id): + """ + 更新使用者作業題目統計(作業層級) + + 根據提交結果更新 UserProblemStats,包括: + - 總提交數 + - 最佳分數 + - 首次 AC 時間 + - 最後提交時間 + - 解題狀態(unsolved/partial/solved) + - 最佳執行時間和記憶體使用 + - 遲交處理 + """ + from django.utils import timezone + from django.db.models import F + from .models import UserProblemStats + from assignments.models import Assignments, Assignment_problems + + try: + # 檢查是否屬於作業 + if not assignment_id: + return + + # 檢查 assignment 和 problem 的關聯 + try: + assignment = Assignments.objects.get(id=assignment_id) + assignment_problem = Assignment_problems.objects.get( + assignment=assignment, + problem_id=submission.problem_id + ) + except (Assignments.DoesNotExist, Assignment_problems.DoesNotExist): + # 如果作業或題目不存在,跳過 + return + + # 取得或創建 UserProblemStats + stats, created = UserProblemStats.objects.get_or_create( + user=submission.user, + assignment_id=assignment_id, + problem_id=submission.problem_id, + defaults={ + 'total_submissions': 0, + 'best_score': 0, + 'max_possible_score': assignment_problem.weight * 100 if assignment_problem.weight else 100, + 'solve_status': 'unsolved', + } + ) + + # 更新總提交數 + stats.total_submissions = F('total_submissions') + 1 + + # 更新最後提交時間 + stats.last_submission_time = submission.created_at + + # 檢查是否遲交 + if assignment.due_time and submission.created_at > assignment.due_time: + stats.is_late = True + # 計算遲交罰分 + if assignment.late_penalty > 0: + stats.penalty_score = assignment.late_penalty + + # 先保存以計算 F() 表達式 + stats.save() + stats.refresh_from_db() + + # 更新最佳分數(考慮遲交罰分) + final_score = submission.score + if stats.is_late and stats.penalty_score > 0: + final_score = int(submission.score * (1 - float(stats.penalty_score) / 100)) + + if final_score > stats.best_score: + stats.best_score = final_score + stats.best_submission = submission + + # 如果是 AC (status='0') 且還沒有 first_ac_time + if submission.status == '0' and not stats.first_ac_time: + stats.first_ac_time = submission.judged_at or timezone.now() + + # 更新最佳執行時間 + if submission.execution_time > 0: + if stats.best_execution_time is None or submission.execution_time < stats.best_execution_time: + stats.best_execution_time = submission.execution_time + + # 更新最佳記憶體使用 + if submission.memory_usage > 0: + if stats.best_memory_usage is None or submission.memory_usage < stats.best_memory_usage: + stats.best_memory_usage = submission.memory_usage + + # 更新解題狀態 + if stats.best_score >= stats.max_possible_score: + stats.solve_status = 'solved' + elif stats.best_score > 0: + stats.solve_status = 'partial' + else: + stats.solve_status = 'unsolved' + + stats.save() + + logger = logging.getLogger(__name__) + logger.info(f'Updated assignment stats for user {submission.user.id} ' + f'assignment {assignment_id} problem {submission.problem_id}: ' + f'status={stats.solve_status}, best_score={stats.best_score}, ' + f'is_late={stats.is_late}') + + except Exception as e: + logger = logging.getLogger(__name__) + logger.error(f'Failed to update user assignment stats: {str(e)}', exc_info=True) +''' + class BasePermissionMixin: """基礎權限檢查 Mixin - 提供通用權限檢查方法""" @@ -469,6 +662,7 @@ def get_queryset(self): language_type = self.request.query_params.get('language_type') before = self.request.query_params.get('before') # Unix 時間戳記 (秒) after = self.request.query_params.get('after') # Unix 時間戳記 (秒) + ip_prefix = self.request.query_params.get('ip_prefix') # IP 網段前綴 # 套用篩選條件 if problem_id: @@ -513,6 +707,31 @@ def get_queryset(self): except (ValueError, TypeError, OSError): pass # 忽略無效的時間格式 + # IP 網段前綴篩選 + if ip_prefix: + try: + # 支援 CIDR 格式 (例如 192.168.1.0/24) 或簡單前綴 (例如 192.168.) + if '/' in ip_prefix: + # CIDR 格式:使用 ipaddress 模組 + import ipaddress + network = ipaddress.ip_network(ip_prefix, strict=False) + # 獲取網段範圍 + start_ip = str(network.network_address) + end_ip = str(network.broadcast_address) + # 篩選 IP 在範圍內的提交 + queryset = queryset.filter( + ip_address__gte=start_ip, + ip_address__lte=end_ip + ) + else: + # 簡單前綴匹配:例如 "192.168." 會匹配所有 192.168.x.x + queryset = queryset.filter(ip_address__startswith=ip_prefix) + except (ValueError, TypeError) as e: + import logging + logger = logging.getLogger(__name__) + logger.warning(f'Invalid ip_prefix parameter: {ip_prefix}, error: {e}') + pass # 忽略無效的 IP 前綴 + return self.get_viewable_submissions(self.request.user, queryset) def get_client_ip(self, request): @@ -1603,7 +1822,7 @@ class SubmissionCallbackAPIView(APIView): 接收 Sandbox 判題結果的 callback endpoint Sandbox 判題完成後會 POST 到這個 endpoint - URL: POST /api/submissions/callback/ + URL: POST /{callback_url}/submissions/callback/ """ permission_classes = [permissions.AllowAny] # Sandbox 不需要 JWT 認證 @@ -1717,9 +1936,18 @@ def post(self, request): logger.info(f'Created {len(test_results)} test results for submission {submission_id}') - # 5. TODO: 更新 User_problem_stats (之後實現) - # update_user_problem_stats(submission) - + # 5. 更新 UserProblemSolveStatus(全域層級) + update_user_problem_stats(submission) + '''這邊也不需要了,算成績的部分交給前端處理 + + # 6. 更新 UserProblemStats(作業層級) + # 嘗試從 submission 找到對應的 assignment_id + # submission model 需要加上 assignment_id 欄位,或者從 context 傳入 + # 目前先跳過,等 submission model 更新後再啟用 + # if hasattr(submission, 'assignment_id') and submission.assignment_id: + # update_user_assignment_stats(submission, submission.assignment_id) + + ''' return api_response( data={'submission_id': str(submission_id)}, message='Callback processed successfully', From d24f9e12a9e9343251cac8fcb555179af1f6c7eb Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 14:47:24 +0800 Subject: [PATCH 053/113] docs: I update GET /submission/ API document. --- docs/submissions.MD | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/submissions.MD b/docs/submissions.MD index 7343cf9..8e9e9e3 100644 --- a/docs/submissions.MD +++ b/docs/submissions.MD @@ -236,15 +236,18 @@ curl -X PUT http://localhost:8000/submission/a1b2c3d4-5678-90ab-cdef-1234567890a **認證**: 必須(Bearer Token) **查詢參數**: -- `page` (可選): 頁碼,預設 1 -- `page_size` (可選): 每頁數量,預設 20 +- `page` (可選): 頁碼,預設 1 +- `page_size` (可選): 每頁數量,預設 20 - `problem_id` (可選): 篩選特定題目的提交 - `username` (可選): 篩選特定使用者的提交 -- `status` (可選): 篩選判題狀態(如 "0"=AC, "1"=WA, "2"=CE, "3"=TLE, "4"=MLE, "5"=RE) +- `status` (可選): 篩選判題狀態(如 "0"=AC, "1"=WA, "2"=CE, "3"=TLE, "4"=MLE, "5"=RE) - `course_id` (可選): 篩選特定課程的所有提交 -- `language_type` (可選): 篩選程式語言(0=C, 1=C++, 2=Python, 3=Java, 4=JavaScript) -- `before` (可選): 篩選某時間之前的提交(Unix 時間戳記,單位:秒) -- `after` (可選): 篩選某時間之後的提交(Unix 時間戳記,單位:秒) +- `language_type` (可選): 篩選程式語言(0=C, 1=C++, 2=Python, 3=Java, 4=JavaScript) +- `before` (可選): 篩選某時間之前的提交(Unix 時間戳記,單位:秒) +- `after` (可選): 篩選某時間之後的提交(Unix 時間戳記,單位:秒) +- `ip_prefix` (可選): 篩選 IP 網段前綴,支援以下格式: + - 簡單前綴: `192.168.` 匹配所有 192.168.x.x + - CIDR 表示法: `192.168.1.0/24` 匹配 192.168.1.0-255 範圍 **權限說明**: - 學生:只能查看自己的提交 @@ -316,6 +319,18 @@ curl -X GET "http://localhost:8000/submission/?after=1735286400&before=173537279 # 組合多個篩選條件 curl -X GET "http://localhost:8000/submission/?problem_id=123&status=0&language_type=2" \ -H "Authorization: Bearer YOUR_TOKEN" + +# 查詢特定 IP 網段的提交(簡單前綴) +curl -X GET "http://localhost:8000/submission/?ip_prefix=192.168." \ + -H "Authorization: Bearer YOUR_TOKEN" + +# 查詢特定 IP 網段的提交(CIDR 表示法) +curl -X GET "http://localhost:8000/submission/?ip_prefix=192.168.1.0/24" \ + -H "Authorization: Bearer YOUR_TOKEN" + +# 組合 IP 網段與其他條件 +curl -X GET "http://localhost:8000/submission/?ip_prefix=10.0.0.0/8&status=0" \ + -H "Authorization: Bearer YOUR_TOKEN" ``` --- @@ -662,9 +677,9 @@ submissionId: "a1b2c3d4-5678-90ab-cdef-1234567890ab" ### 1. 提交限制檢查 - [ ] `"problem permission denied"` - 題目權限檢查 - [ ] `"homework hasn't start"` - 作業時間檢查 -- [ ] `"Invalid IP address"` - IP 白名單檢查 +- [x] IP 網段前綴篩選 - 支援簡單前綴和 CIDR 表示法 - [ ] `"you have used all your quotas"` - 配額限制 -- [ ] `"Submit too fast!"` - 提交頻率限制(含 `waitFor` 欄位) +- [ ] `"Submit too fast!"` - 提交頻率限制(含 `waitFor` 欄位) ### 2. 判題系統整合 - [ ] 實際連接 SandBox 判題系統 @@ -834,9 +849,15 @@ curl -X GET http://localhost:8000/submission/student-submission-id/ \ ## 版本歷史 +**v1.1 (2025-12-28)** +- 新增 IP 網段前綴篩選功能 +- 支援簡單前綴匹配(如 `192.168.`) +- 支援 CIDR 表示法(如 `192.168.1.0/24`) +- 實作統計資料自動更新(全域與作業層級) + **v1.0 (2025-11-09)** - 初始版本 -- 實作基本提交功能(創建、上傳、查詢) +- 實作基本提交功能(創建、上傳、查詢) - 兼容 NOJ 格式標準 - 實作角色權限系統 - 支援重新判題功能 From 913e635eda3c1f10c41db8dc41d79bdab0901411 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 06:53:07 +0000 Subject: [PATCH 054/113] test: Add comprehensive test coverage for static analysis functionality Co-authored-by: happylittle7 <7501374+happylittle7@users.noreply.github.com> --- ...4_problems_forbidden_functions_and_more.py | 24 ++ problems/serializers/core.py | 1 + problems/tests/test_api.py | 259 +++++++++++++++++- 3 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 problems/migrations/0004_problems_forbidden_functions_and_more.py diff --git a/problems/migrations/0004_problems_forbidden_functions_and_more.py b/problems/migrations/0004_problems_forbidden_functions_and_more.py new file mode 100644 index 0000000..f11f217 --- /dev/null +++ b/problems/migrations/0004_problems_forbidden_functions_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.7 on 2025-12-28 06:49 + +import problems.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('problems', '0003_problems_checker_name_problems_use_custom_checker'), + ] + + operations = [ + migrations.AddField( + model_name='problems', + name='forbidden_functions', + field=models.JSONField(blank=True, default=problems.models.default_forbidden_functions, help_text="List of function names that are forbidden when 'forbid-functions' rule is enabled. Required if 'forbid-functions' is in static_analysis_rules."), + ), + migrations.AddField( + model_name='problems', + name='static_analysis_rules', + field=models.JSONField(blank=True, default=problems.models.default_static_analysis_rules, help_text="List of static analysis rules to apply. Valid values: 'forbid-loops', 'forbid-arrays', 'forbid-stl', 'forbid-functions'. Empty list means no static analysis."), + ), + ] diff --git a/problems/serializers/core.py b/problems/serializers/core.py index 00b96d1..b12f1d8 100644 --- a/problems/serializers/core.py +++ b/problems/serializers/core.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from django.core.exceptions import ValidationError from ..models import Problems, Problem_subtasks, Test_cases, Tags, Problem_tags from typing import Optional diff --git a/problems/tests/test_api.py b/problems/tests/test_api.py index 0a07374..1926798 100644 --- a/problems/tests/test_api.py +++ b/problems/tests/test_api.py @@ -9,6 +9,7 @@ import zipfile from io import BytesIO from problems.services.storage import _storage +from user.models import UserProfile @pytest.fixture @@ -23,19 +24,30 @@ def teacher(db): ) u.is_staff = True u.save(update_fields=["is_staff"]) + # Create UserProfile with email_verified=True + UserProfile.objects.update_or_create( + user=u, + defaults={"email_verified": True} + ) return u @pytest.fixture def student(db): User = get_user_model() - return User.objects.create_user( + u = User.objects.create_user( username="student1", email="student1@example.com", password="pass1234", real_name="Student One", identity="student", ) + # Create UserProfile with email_verified=True for student too + UserProfile.objects.update_or_create( + user=u, + defaults={"email_verified": True} + ) + return u @pytest.fixture @@ -341,4 +353,247 @@ def test_checker_settings_passed_to_sandbox(api_client, teacher, course): if mock_post.called: call_data = mock_post.call_args[1].get('data', {}) assert call_data.get('use_checker') is True - assert call_data.get('checker_name') == 'token' \ No newline at end of file + assert call_data.get('checker_name') == 'token' + + +@pytest.mark.django_db +def test_problem_static_analysis_rules_basic(api_client, teacher, course): + """Test creating and updating problems with static analysis rules.""" + api_client.force_authenticate(user=teacher) + + # Test 1: 建立題目時設定靜態分析規則 + payload = { + "title": "Recursion Only Problem", + "description": "必須使用遞迴解題", + "difficulty": "medium", + "course_id": str(course.id), + "static_analysis_rules": ["forbid-loops"], + } + res = api_client.post("/problem/manage", payload, format="json") + assert res.status_code == 201 + problem_id = res.json()["data"]["problem_id"] + + # Test 2: 驗證 API 回應包含靜態分析設定 + res_detail = api_client.get(f"/problem/{problem_id}") + assert res_detail.status_code == 200 + data = res_detail.json()["data"] + assert data["static_analysis_rules"] == ["forbid-loops"] + assert data["use_static_analysis"] is True + assert data["static_analysis_config"]["enabled"] is True + assert "forbid-loops" in data["static_analysis_config"]["rules"] + + # Test 3: 更新靜態分析規則(使用 PUT) + update_payload = { + "title": "Recursion Only Problem", + "description": "必須使用遞迴解題", + "difficulty": "medium", + "course_id": str(course.id), + "static_analysis_rules": ["forbid-loops", "forbid-arrays"], + } + res_update = api_client.put(f"/problem/manage/{problem_id}", update_payload, format="json") + assert res_update.status_code == 200 + + # Test 4: 驗證更新後包含多個規則 + res_after = api_client.get(f"/problem/{problem_id}") + data_after = res_after.json()["data"] + assert set(data_after["static_analysis_rules"]) == {"forbid-loops", "forbid-arrays"} + + +@pytest.mark.django_db +def test_static_analysis_forbid_functions_validation(api_client, teacher, course): + """Test validation that forbid-functions requires forbidden_functions list.""" + api_client.force_authenticate(user=teacher) + + # Test 1: 啟用 forbid-functions 但未提供函數列表 => 400 + payload = { + "title": "No Sort Problem", + "description": "不能使用 sort", + "difficulty": "medium", + "course_id": str(course.id), + "static_analysis_rules": ["forbid-functions"], + "forbidden_functions": [], # 空列表應該失敗 + } + res = api_client.post("/problem/manage", payload, format="json") + assert res.status_code == 422 # API returns 422 for validation errors + + # Test 2: 提供函數列表後應該成功 + payload["forbidden_functions"] = ["sort", "sorted"] + res = api_client.post("/problem/manage", payload, format="json") + assert res.status_code == 201 + problem_id = res.json()["data"]["problem_id"] + + # Test 3: 驗證 forbidden_functions 被正確儲存 + res_detail = api_client.get(f"/problem/{problem_id}") + data = res_detail.json()["data"] + assert set(data["forbidden_functions"]) == {"sort", "sorted"} + assert data["static_analysis_config"]["forbidden_functions"] == ["sort", "sorted"] + + +@pytest.mark.django_db +def test_static_analysis_empty_function_name_validation(api_client, teacher, course): + """Test that empty function names are rejected.""" + api_client.force_authenticate(user=teacher) + + # 嘗試建立帶有空字串函數名稱的題目 + payload = { + "title": "Invalid Functions Problem", + "description": "Test validation", + "difficulty": "medium", + "course_id": str(course.id), + "static_analysis_rules": ["forbid-functions"], + "forbidden_functions": ["sort", "", "printf"], # 包含空字串 + } + res = api_client.post("/problem/manage", payload, format="json") + assert res.status_code == 422 # API returns 422 for validation errors + body = res.json() + # 應該包含錯誤訊息 + assert "forbidden_functions" in str(body).lower() or "函數" in str(body) + + +@pytest.mark.django_db +def test_static_analysis_disable_rules(api_client, teacher, course): + """Test disabling static analysis rules.""" + api_client.force_authenticate(user=teacher) + + # 建立帶有靜態分析規則的題目 + problem = Problems.objects.create( + title="Test Problem", + description="desc", + difficulty="easy", + creator_id=teacher, + course_id=course, + static_analysis_rules=["forbid-loops"], + ) + + # 清空規則以停用靜態分析 + update_payload = { + "title": "Test Problem", + "description": "desc", + "difficulty": "easy", + "course_id": str(course.id), + "static_analysis_rules": [], + } + res = api_client.put(f"/problem/manage/{problem.id}", update_payload, format="json") + assert res.status_code == 200 + + # 驗證靜態分析已停用 + res_detail = api_client.get(f"/problem/{problem.id}") + data = res_detail.json()["data"] + assert data["static_analysis_rules"] == [] + assert data["use_static_analysis"] is False + assert data["static_analysis_config"]["enabled"] is False + + +@pytest.mark.django_db +def test_static_analysis_remove_forbid_functions_rule(api_client, teacher, course): + """Test removing forbid-functions rule while keeping forbidden_functions list.""" + api_client.force_authenticate(user=teacher) + + # 建立帶有 forbid-functions 規則的題目 + problem = Problems.objects.create( + title="Test Problem", + description="desc", + difficulty="easy", + creator_id=teacher, + course_id=course, + static_analysis_rules=["forbid-functions"], + forbidden_functions=["sort", "qsort"], + ) + + # 移除 forbid-functions 規則但保留 forbidden_functions(應該可以成功) + update_payload = { + "title": "Test Problem", + "description": "desc", + "difficulty": "easy", + "course_id": str(course.id), + "static_analysis_rules": ["forbid-loops"], # 不包含 forbid-functions + # 保留 forbidden_functions 欄位 + } + res = api_client.put(f"/problem/manage/{problem.id}", update_payload, format="json") + assert res.status_code == 200 + + # 驗證規則已更新 + res_detail = api_client.get(f"/problem/{problem.id}") + data = res_detail.json()["data"] + assert "forbid-functions" not in data["static_analysis_rules"] + assert "forbid-loops" in data["static_analysis_rules"] + + +@pytest.mark.django_db +def test_static_analysis_multiple_rules(api_client, teacher, course): + """Test creating problems with multiple static analysis rules.""" + api_client.force_authenticate(user=teacher) + + payload = { + "title": "Strict Problem", + "description": "多重限制", + "difficulty": "hard", + "course_id": str(course.id), + "static_analysis_rules": ["forbid-loops", "forbid-arrays", "forbid-stl"], + } + res = api_client.post("/problem/manage", payload, format="json") + assert res.status_code == 201 + problem_id = res.json()["data"]["problem_id"] + + # 驗證所有規則都被正確儲存 + res_detail = api_client.get(f"/problem/{problem_id}") + data = res_detail.json()["data"] + assert set(data["static_analysis_rules"]) == {"forbid-loops", "forbid-arrays", "forbid-stl"} + assert data["use_static_analysis"] is True + config = data["static_analysis_config"] + assert config["enabled"] is True + assert set(config["rules"]) == {"forbid-loops", "forbid-arrays", "forbid-stl"} + + +@pytest.mark.django_db +def test_static_analysis_invalid_rule(api_client, teacher, course): + """Test that invalid rule names are rejected.""" + api_client.force_authenticate(user=teacher) + + payload = { + "title": "Invalid Rule Problem", + "description": "Test validation", + "difficulty": "medium", + "course_id": str(course.id), + "static_analysis_rules": ["forbid-loops", "invalid-rule"], + } + res = api_client.post("/problem/manage", payload, format="json") + # 應該失敗,因為 invalid-rule 不是有效的規則 + assert res.status_code == 422 # API returns 422 for validation errors + + +@pytest.mark.django_db +def test_static_analysis_api_response_fields(api_client, teacher, course): + """Test that API responses include all static analysis fields.""" + api_client.force_authenticate(user=teacher) + + # 建立帶有靜態分析設定的題目 + problem = Problems.objects.create( + title="API Test Problem", + description="desc", + difficulty="easy", + creator_id=teacher, + course_id=course, + static_analysis_rules=["forbid-functions"], + forbidden_functions=["printf", "scanf"], + ) + + # 驗證 API 回應包含所有必要欄位 + res = api_client.get(f"/problem/{problem.id}") + assert res.status_code == 200 + data = res.json()["data"] + + # 檢查必要欄位 + assert "static_analysis_rules" in data + assert "forbidden_functions" in data + assert "use_static_analysis" in data + assert "static_analysis_config" in data + + # 檢查 config 物件結構 + config = data["static_analysis_config"] + assert "enabled" in config + assert "rules" in config + assert "forbidden_functions" in config + assert config["enabled"] is True + assert config["rules"] == ["forbid-functions"] + assert config["forbidden_functions"] == ["printf", "scanf"] From 12a8d46d38f44b77740a6df5724535cc3a9a1a1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 06:55:21 +0000 Subject: [PATCH 055/113] refactor: Address code review feedback - improve validation and consistency Co-authored-by: happylittle7 <7501374+happylittle7@users.noreply.github.com> --- problems/serializers/core.py | 15 +++++++-------- problems/tests/test_api.py | 6 +++--- problems/views/api.py | 2 +- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/problems/serializers/core.py b/problems/serializers/core.py index b12f1d8..c3c02c1 100644 --- a/problems/serializers/core.py +++ b/problems/serializers/core.py @@ -130,15 +130,14 @@ def validate(self, attrs): for field, value in attrs.items(): setattr(instance, field, value) else: - # 建立臨時實例用於驗證(需要必填欄位) + # 建立臨時實例用於驗證 + # 注意:我們只使用 clean() 驗證靜態分析欄位,不會實際存取外鍵 temp_attrs = attrs.copy() - # 確保必填欄位存在 - if 'creator_id' not in temp_attrs: - # 使用假的 creator_id 僅用於驗證 - temp_attrs['creator_id_id'] = 1 - if 'course_id' not in temp_attrs: - # 使用假的 course_id 僅用於驗證 - temp_attrs['course_id_id'] = 1 + # 提供必填外鍵的臨時值(僅用於建立物件,不會實際查詢資料庫) + if 'creator_id' not in temp_attrs and 'creator_id_id' not in temp_attrs: + temp_attrs['creator_id_id'] = None # clean() 不會驗證此欄位 + if 'course_id' not in temp_attrs and 'course_id_id' not in temp_attrs: + temp_attrs['course_id_id'] = None # clean() 不會驗證此欄位 instance = Problems(**temp_attrs) # 將實際驗證委派給模型的 clean(),避免重複邏輯 diff --git a/problems/tests/test_api.py b/problems/tests/test_api.py index 1926798..4dc5253 100644 --- a/problems/tests/test_api.py +++ b/problems/tests/test_api.py @@ -414,7 +414,7 @@ def test_static_analysis_forbid_functions_validation(api_client, teacher, course "forbidden_functions": [], # 空列表應該失敗 } res = api_client.post("/problem/manage", payload, format="json") - assert res.status_code == 422 # API returns 422 for validation errors + assert res.status_code == 422 # DRF returns 422 for validation errors in this endpoint # Test 2: 提供函數列表後應該成功 payload["forbidden_functions"] = ["sort", "sorted"] @@ -444,7 +444,7 @@ def test_static_analysis_empty_function_name_validation(api_client, teacher, cou "forbidden_functions": ["sort", "", "printf"], # 包含空字串 } res = api_client.post("/problem/manage", payload, format="json") - assert res.status_code == 422 # API returns 422 for validation errors + assert res.status_code == 422 # DRF returns 422 for validation errors in this endpoint body = res.json() # 應該包含錯誤訊息 assert "forbidden_functions" in str(body).lower() or "函數" in str(body) @@ -559,7 +559,7 @@ def test_static_analysis_invalid_rule(api_client, teacher, course): } res = api_client.post("/problem/manage", payload, format="json") # 應該失敗,因為 invalid-rule 不是有效的規則 - assert res.status_code == 422 # API returns 422 for validation errors + assert res.status_code == 422 # DRF returns 422 for validation errors in this endpoint @pytest.mark.django_db diff --git a/problems/views/api.py b/problems/views/api.py index 2d23b7b..43826f6 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -1091,7 +1091,7 @@ def _to_list(text: str): # Static analysis settings 'static_analysis_rules': getattr(problem, 'static_analysis_rules', []), 'forbidden_functions': getattr(problem, 'forbidden_functions', []), - 'use_static_analysis': problem.use_static_analysis if hasattr(problem, 'use_static_analysis') else False, + 'use_static_analysis': getattr(problem, 'use_static_analysis', False), 'static_analysis_config': problem.get_static_analysis_config() if hasattr(problem, 'get_static_analysis_config') else {'enabled': False}, } From 683d152f72883019969c09ef33139e9d87e68085 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sun, 28 Dec 2025 15:28:52 +0800 Subject: [PATCH 056/113] feat: Update meta structure to include detailed testcases information --- problems/tests/test_api.py | 13 +++++++---- problems/views/api.py | 44 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/problems/tests/test_api.py b/problems/tests/test_api.py index 4dc5253..69b498d 100644 --- a/problems/tests/test_api.py +++ b/problems/tests/test_api.py @@ -196,10 +196,15 @@ def test_sandbox_checksum_and_meta(api_client, teacher, course, settings): res_meta = api_client.get(f"/problem/{p.id}/meta", {'token': settings.SANDBOX_TOKEN}) assert res_meta.status_code == 200 meta = res_meta.json()['data'] - assert meta['task_count'] == 2 - assert meta['missing_pairs'] == [] - assert len(meta['tasks']) == 2 - assert meta['tasks'][0]['in'].endswith('.in') and meta['tasks'][0]['out'].endswith('.out') + # 新格式:tasks 陣列包含子題設定,testcases 陣列包含測資檔案資訊 + assert 'tasks' in meta + assert 'testcases' in meta + assert len(meta['tasks']) == 1 # 0001, 0002 都屬於 subtask 00 + assert len(meta['testcases']) == 2 # 共兩個測資 + # 驗證 testcases 結構 + tc0 = meta['testcases'][0] + assert tc0['in'].endswith('.in') and tc0['out'].endswith('.out') + assert 'stem' in tc0 and 'no' in tc0 and 'subtask' in tc0 # meta 錯誤 token res_meta_bad = api_client.get(f"/problem/{p.id}/meta", {'token': 'wrong'}) diff --git a/problems/views/api.py b/problems/views/api.py index 142915e..fc37f65 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -586,8 +586,27 @@ def build_meta_entry(ss_idx: int): max_ss = max(case_counts.keys()) else: max_ss = -1 + + # 建立 testcases 列表,記錄每個測資檔案的詳細資訊 + testcases = [] + testcase_no = 1 + for ss in range(max_ss + 1): + entry = pairs_map.get(ss, {'in': set(), 'out': set()}) + matched_tts = sorted(entry['in'] & entry['out']) + for tt in matched_tts: + stem = f"{ss:02d}{tt:02d}" + testcases.append({ + "stem": stem, + "no": testcase_no, + "in": f"{stem}.in", + "out": f"{stem}.out", + "subtask": ss + 1 # subtask 從 1 開始 + }) + testcase_no += 1 + meta = { - "tasks": [build_meta_entry(ss) for ss in range(max_ss + 1)] + "tasks": [build_meta_entry(ss) for ss in range(max_ss + 1)], + "testcases": testcases } # 重打包 zip:複製原檔案並加入 meta.json @@ -725,7 +744,28 @@ def build_meta_entry(ss_idx: int): "timeLimit": time_limit, } max_ss = max(case_counts.keys()) if case_counts else -1 - meta = {"tasks": [build_meta_entry(ss) for ss in range(max_ss + 1)]} + + # 建立 testcases 列表 + testcases = [] + testcase_no = 1 + for ss in range(max_ss + 1): + entry = pairs_map.get(ss, {'in': set(), 'out': set()}) + matched_tts = sorted(entry['in'] & entry['out']) + for tt in matched_tts: + stem = f"{ss:02d}{tt:02d}" + testcases.append({ + "stem": stem, + "no": testcase_no, + "in": f"{stem}.in", + "out": f"{stem}.out", + "subtask": ss + 1 + }) + testcase_no += 1 + + meta = { + "tasks": [build_meta_entry(ss) for ss in range(max_ss + 1)], + "testcases": testcases + } except zipfile.BadZipFile: return api_response(None, "Corrupted test case archive", status_code=500) # 回傳 fallback 生成的 meta 結構 From 070229a94ef0671ff02c48cda3bca54382ae2fb3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 07:43:16 +0000 Subject: [PATCH 057/113] Initial plan From c47828025d2d66956286f85503c299ee30a7b03c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 07:49:47 +0000 Subject: [PATCH 058/113] Refactor: Extract testcase building logic and enhance test coverage Co-authored-by: happylittle7 <7501374+happylittle7@users.noreply.github.com> --- problems/tests/test_api.py | 86 ++++++++++++++++++++++++++++++++++++-- problems/views/api.py | 67 ++++++++++++++--------------- 2 files changed, 114 insertions(+), 39 deletions(-) diff --git a/problems/tests/test_api.py b/problems/tests/test_api.py index 69b498d..a8e6fea 100644 --- a/problems/tests/test_api.py +++ b/problems/tests/test_api.py @@ -201,16 +201,96 @@ def test_sandbox_checksum_and_meta(api_client, teacher, course, settings): assert 'testcases' in meta assert len(meta['tasks']) == 1 # 0001, 0002 都屬於 subtask 00 assert len(meta['testcases']) == 2 # 共兩個測資 - # 驗證 testcases 結構 + # 驗證 testcases 結構和具體值 tc0 = meta['testcases'][0] - assert tc0['in'].endswith('.in') and tc0['out'].endswith('.out') - assert 'stem' in tc0 and 'no' in tc0 and 'subtask' in tc0 + assert tc0['stem'] == '0001' + assert tc0['no'] == 1 + assert tc0['in'] == '0001.in' + assert tc0['out'] == '0001.out' + assert tc0['subtask'] == 1 + tc1 = meta['testcases'][1] + assert tc1['stem'] == '0002' + assert tc1['no'] == 2 + assert tc1['in'] == '0002.in' + assert tc1['out'] == '0002.out' + assert tc1['subtask'] == 1 # meta 錯誤 token res_meta_bad = api_client.get(f"/problem/{p.id}/meta", {'token': 'wrong'}) assert res_meta_bad.status_code == 401 +@pytest.mark.django_db +def test_upload_zip_with_meta(api_client, teacher, course): + """Test the upload-zip endpoint creates meta.json with correct testcases array.""" + p = Problems.objects.create( + title="UploadZipMeta", + description="test", + difficulty="easy", + is_public=True, + creator_id=teacher, + course_id=course, + ) + + # 建立 zip 測資 (0001.in/out, 0002.in/out, 0101.in/out) + mem = BytesIO() + with zipfile.ZipFile(mem, 'w') as zf: + zf.writestr('0001.in', 'input1') + zf.writestr('0001.out', 'output1') + zf.writestr('0002.in', 'input2') + zf.writestr('0002.out', 'output2') + zf.writestr('0101.in', 'input3') + zf.writestr('0101.out', 'output3') + mem.seek(0) + + # 上傳 zip + api_client.force_authenticate(user=teacher) + res = api_client.post( + f"/problem/{p.id}/test-cases/upload-zip", + {'file': mem}, + format='multipart' + ) + assert res.status_code == 201 + + # 驗證重打包的 zip 包含正確的 meta.json + rel = os.path.join('testcases', f'p{p.id}', 'problem.zip') + assert _storage.exists(rel) + + with _storage.open(rel, 'rb') as f: + with zipfile.ZipFile(f, 'r') as zf: + assert 'meta.json' in zf.namelist() + import json + meta = json.loads(zf.read('meta.json')) + + # 驗證 meta 結構 + assert 'tasks' in meta + assert 'testcases' in meta + assert len(meta['tasks']) == 2 # subtask 00 和 01 + assert len(meta['testcases']) == 3 # 共三個測資 + + # 驗證 testcases 具體值 + tc0 = meta['testcases'][0] + assert tc0['stem'] == '0001' + assert tc0['no'] == 1 + assert tc0['in'] == '0001.in' + assert tc0['out'] == '0001.out' + assert tc0['subtask'] == 1 + + tc1 = meta['testcases'][1] + assert tc1['stem'] == '0002' + assert tc1['no'] == 2 + assert tc1['in'] == '0002.in' + assert tc1['out'] == '0002.out' + assert tc1['subtask'] == 1 + + tc2 = meta['testcases'][2] + assert tc2['stem'] == '0101' + assert tc2['no'] == 3 + assert tc2['in'] == '0101.in' + assert tc2['out'] == '0101.out' + assert tc2['subtask'] == 2 + + @pytest.mark.django_db def test_sandbox_testdata_download(api_client, teacher, course, settings): """Test the new /problem//testdata endpoint for sandbox test data downloads.""" diff --git a/problems/views/api.py b/problems/views/api.py index fc37f65..99eb1a1 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -508,6 +508,35 @@ def get(self, request, pk: int): return resp +def _build_testcases_list(pairs_map: dict, max_ss: int) -> list: + """ + 建立 testcases 列表,記錄每個測資檔案的詳細資訊 + + Args: + pairs_map: 映射 subtask_index -> {'in': set(tt), 'out': set(tt)} + max_ss: 最大的 subtask index + + Returns: + 包含每個測資詳細資訊的列表 + """ + testcases = [] + testcase_no = 1 + for ss in range(max_ss + 1): + entry = pairs_map.get(ss, {'in': set(), 'out': set()}) + matched_tts = sorted(entry['in'] & entry['out']) + for tt in matched_tts: + stem = f"{ss:02d}{tt:02d}" + testcases.append({ + "stem": stem, + "no": testcase_no, + "in": f"{stem}.in", + "out": f"{stem}.out", + "subtask": ss + 1 # subtask 從 1 開始 + }) + testcase_no += 1 + return testcases + + class ProblemTestCaseZipUploadView(APIView): """ POST /problem//test-cases/upload-zip @@ -587,26 +616,9 @@ def build_meta_entry(ss_idx: int): else: max_ss = -1 - # 建立 testcases 列表,記錄每個測資檔案的詳細資訊 - testcases = [] - testcase_no = 1 - for ss in range(max_ss + 1): - entry = pairs_map.get(ss, {'in': set(), 'out': set()}) - matched_tts = sorted(entry['in'] & entry['out']) - for tt in matched_tts: - stem = f"{ss:02d}{tt:02d}" - testcases.append({ - "stem": stem, - "no": testcase_no, - "in": f"{stem}.in", - "out": f"{stem}.out", - "subtask": ss + 1 # subtask 從 1 開始 - }) - testcase_no += 1 - meta = { "tasks": [build_meta_entry(ss) for ss in range(max_ss + 1)], - "testcases": testcases + "testcases": _build_testcases_list(pairs_map, max_ss) } # 重打包 zip:複製原檔案並加入 meta.json @@ -745,26 +757,9 @@ def build_meta_entry(ss_idx: int): } max_ss = max(case_counts.keys()) if case_counts else -1 - # 建立 testcases 列表 - testcases = [] - testcase_no = 1 - for ss in range(max_ss + 1): - entry = pairs_map.get(ss, {'in': set(), 'out': set()}) - matched_tts = sorted(entry['in'] & entry['out']) - for tt in matched_tts: - stem = f"{ss:02d}{tt:02d}" - testcases.append({ - "stem": stem, - "no": testcase_no, - "in": f"{stem}.in", - "out": f"{stem}.out", - "subtask": ss + 1 - }) - testcase_no += 1 - meta = { "tasks": [build_meta_entry(ss) for ss in range(max_ss + 1)], - "testcases": testcases + "testcases": _build_testcases_list(pairs_map, max_ss) } except zipfile.BadZipFile: return api_response(None, "Corrupted test case archive", status_code=500) From 6904a3e121847562182e07f014e227f6cb014499 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 07:55:33 +0000 Subject: [PATCH 059/113] Fix: Address code review comments and test isolation issue Co-authored-by: happylittle7 <7501374+happylittle7@users.noreply.github.com> --- problems/tests/test_api.py | 8 +++++++- problems/views/api.py | 6 +++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/problems/tests/test_api.py b/problems/tests/test_api.py index a8e6fea..462fcea 100644 --- a/problems/tests/test_api.py +++ b/problems/tests/test_api.py @@ -7,6 +7,7 @@ from django.conf import settings import os import zipfile +import json from io import BytesIO from problems.services.storage import _storage from user.models import UserProfile @@ -180,6 +181,12 @@ def test_sandbox_checksum_and_meta(api_client, teacher, course, settings): zf.writestr('0002.out', 'output2') mem.seek(0) rel = os.path.join('testcases', f'p{p.id}', 'problem.zip') + # Clean up any existing file to ensure test isolation + try: + if _storage.exists(rel): + _storage.delete(rel) + except Exception: + pass _storage.save(rel, mem) # checksum 正確 token @@ -259,7 +266,6 @@ def test_upload_zip_with_meta(api_client, teacher, course): with _storage.open(rel, 'rb') as f: with zipfile.ZipFile(f, 'r') as zf: assert 'meta.json' in zf.namelist() - import json meta = json.loads(zf.read('meta.json')) # 驗證 meta 結構 diff --git a/problems/views/api.py b/problems/views/api.py index 99eb1a1..f944b85 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -513,11 +513,11 @@ def _build_testcases_list(pairs_map: dict, max_ss: int) -> list: 建立 testcases 列表,記錄每個測資檔案的詳細資訊 Args: - pairs_map: 映射 subtask_index -> {'in': set(tt), 'out': set(tt)} - max_ss: 最大的 subtask index + pairs_map (dict): 映射 subtask_index -> {'in': set(tt), 'out': set(tt)} + max_ss (int): 最大的 subtask index Returns: - 包含每個測資詳細資訊的列表 + list: 包含每個測資詳細資訊的列表,每個元素包含 stem, no, in, out, subtask 欄位 """ testcases = [] testcase_no = 1 From 36d73c5bd07a8709b0e1fcc2f2bd6764ada018e1 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 16:01:01 +0800 Subject: [PATCH 060/113] fix: I try to fix the problem about student cannot submit. --- submissions/serializers.py | 2 +- .../test_file/test_submission_views_api.py | 32 ++++++++++++++++ submissions/views.py | 37 ++++++++++--------- 3 files changed, 52 insertions(+), 19 deletions(-) diff --git a/submissions/serializers.py b/submissions/serializers.py index 77558b8..8dff035 100644 --- a/submissions/serializers.py +++ b/submissions/serializers.py @@ -352,7 +352,7 @@ class SubmissionBaseCreateSerializer(serializers.ModelSerializer): min_value=0, max_value=4, error_messages={ - 'invalid': 'invalid data!', + 'invalid': 'languageType 格式錯誤:必須是 0-4 的整數 (收到的類型不正確)', 'required': 'post data missing!', 'min_value': 'not allowed language', 'max_value': 'not allowed language' diff --git a/submissions/test_file/test_submission_views_api.py b/submissions/test_file/test_submission_views_api.py index a2e0a8c..97bfb53 100644 --- a/submissions/test_file/test_submission_views_api.py +++ b/submissions/test_file/test_submission_views_api.py @@ -39,17 +39,28 @@ class SubmissionAPITestSetup: @classmethod def create_test_users(cls): """創建測試用戶""" + from user.models import UserProfile + # 普通學生 cls.student1 = User.objects.create_user( username='api_student1', email='api_student1@test.com', password='testpass123' ) + # 設置郵箱驗證 + profile1, _ = UserProfile.objects.get_or_create(user=cls.student1) + profile1.email_verified = True + profile1.save() + cls.student2 = User.objects.create_user( username='api_student2', email='api_student2@test.com', password='testpass123' ) + # 設置郵箱驗證 + profile2, _ = UserProfile.objects.get_or_create(user=cls.student2) + profile2.email_verified = True + profile2.save() # 老師 cls.teacher = User.objects.create_user( @@ -57,6 +68,10 @@ def create_test_users(cls): email='api_teacher@test.com', password='testpass123' ) + # 設置郵箱驗證 + profile_teacher, _ = UserProfile.objects.get_or_create(user=cls.teacher) + profile_teacher.email_verified = True + profile_teacher.save() # TA cls.ta = User.objects.create_user( @@ -64,6 +79,10 @@ def create_test_users(cls): email='api_ta@test.com', password='testpass123' ) + # 設置郵箱驗證 + profile_ta, _ = UserProfile.objects.get_or_create(user=cls.ta) + profile_ta.email_verified = True + profile_ta.save() # 管理員 cls.admin = User.objects.create_user( @@ -73,6 +92,11 @@ def create_test_users(cls): is_staff=True, is_superuser=True ) + # 設置郵箱驗證 + profile_admin, _ = UserProfile.objects.get_or_create(user=cls.admin) + profile_admin.email_verified = True + profile_admin.save() + @classmethod def create_test_courses(cls): @@ -117,6 +141,7 @@ def create_test_problems(cls): description='API測試:給定一個整數數組,返回兩個數字的索引', course_id=cls.course1, creator_id=cls.teacher, # 添加必需的 creator_id + is_public='course', # 設置為課程可見 difficulty=Problems.Difficulty.EASY ) @@ -126,6 +151,7 @@ def create_test_problems(cls): description='API測試:反轉一個單鏈表', course_id=cls.course2, creator_id=cls.teacher, # 添加必需的 creator_id + is_public='course', # 設置為課程可見 difficulty=Problems.Difficulty.MEDIUM ) @@ -136,6 +162,7 @@ def create_test_problems(cls): description='API測試:用於測試權限的題目(關聯到 course2)', course_id=cls.course2, # 修改:不能為 None,使用 course2 creator_id=cls.teacher, # 添加必需的 creator_id + is_public='course', # 設置為課程可見 difficulty=Problems.Difficulty.HARD ) @@ -290,6 +317,11 @@ def test_create_submission_success(self): response = self.client.post(self.get_submission_create_url(), data) + # 調試:打印響應內容 + if response.status_code != status.HTTP_201_CREATED: + print(f"\n錯誤狀態碼: {response.status_code}") + print(f"響應內容: {response.data}") + # 驗證 NOJ 格式響應 (通過 api_response 包裝) self.assertEqual(response.status_code, status.HTTP_201_CREATED) message = self.get_api_message(response) diff --git a/submissions/views.py b/submissions/views.py index d3d9234..2ad1efa 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -784,10 +784,11 @@ def post(self, request, *args, **kwargs): elif 'not allowed language' in str(errors['language_type']): return api_response(data=None, message="not allowed language", status_code=status.HTTP_403_FORBIDDEN) else: - return api_response(data=None, message="invalid data!", status_code=status.HTTP_400_BAD_REQUEST) + return api_response(data=None, message=f"languageType 驗證失敗: {errors['language_type']}", status_code=status.HTTP_400_BAD_REQUEST) # 其他驗證錯誤 - return api_response(data=None, message="invalid data!", status_code=status.HTTP_400_BAD_REQUEST) + error_details = '; '.join([f"{field}: {', '.join(msgs)}" for field, msgs in errors.items()]) + return api_response(data=None, message=f"資料驗證失敗: {error_details}", status_code=status.HTTP_400_BAD_REQUEST) # 額外的安全檢查 problem_id = serializer.validated_data['problem_id'] @@ -867,7 +868,7 @@ def post(self, request, *args, **kwargs): ) elif problem.is_public == 'course': # Course 題目需要檢查是否在課程中 - from courses.models import Courses + from courses.models import Courses, Course_members course = problem.course_id # 若題目未關聯任何課程,則不應允許提交 if course is None: @@ -876,19 +877,16 @@ def post(self, request, *args, **kwargs): message="problem permission denied", status_code=status.HTTP_403_FORBIDDEN ) - # 檢查用戶是否是課程成員(老師或學生) - if course.teacher_id != user: - # 檢查是否是學生 - from courses.models import Course_students - if not Course_students.objects.filter( - course_id=course, - student_id=user - ).exists(): - return api_response( - data=None, - message="problem permission denied", - status_code=status.HTTP_403_FORBIDDEN - ) + # 檢查用戶是否是課程成員(老師、助教或學生) + if not Course_members.objects.filter( + course_id=course, + user_id=user + ).exists(): + return api_response( + data=None, + message="problem permission denied", + status_code=status.HTTP_403_FORBIDDEN + ) except Problems.DoesNotExist: return api_response( data=None, @@ -910,11 +908,14 @@ def post(self, request, *args, **kwargs): error_message = str(e.detail[0]) if hasattr(e, 'detail') and e.detail else str(e) if 'problem' in error_message.lower(): return api_response(data=None, message="Unexisted problem id.", status_code=status.HTTP_404_NOT_FOUND) - return api_response(data=None, message="invalid data!", status_code=status.HTTP_400_BAD_REQUEST) + return api_response(data=None, message=f"資料驗證錯誤: {error_message}", status_code=status.HTTP_400_BAD_REQUEST) except Exception as e: # 其他系統錯誤 - return api_response(data=None, message="invalid data!", status_code=status.HTTP_400_BAD_REQUEST) + import logging + logger = logging.getLogger(__name__) + logger.error(f"提交創建失敗: {str(e)}", exc_info=True) + return api_response(data=None, message=f"系統錯誤: {str(e)[:200]}", status_code=status.HTTP_400_BAD_REQUEST) def list(self, request, *args, **kwargs): """獲取提交列表 (NOJ 兼容版本)""" From e31733cc0c2667ef084902fbec38d1d4815a39aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=A5=95=E5=A9=B7?= Date: Sun, 28 Dec 2025 16:05:02 +0800 Subject: [PATCH 061/113] docs: add PUT /homework/ update API doc --- assignments/views.py | 150 ++++++++++++++++++++++++++++++++++ docs/assignments.MD | 186 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 302 insertions(+), 34 deletions(-) diff --git a/assignments/views.py b/assignments/views.py index 428e2bd..99af89c 100644 --- a/assignments/views.py +++ b/assignments/views.py @@ -259,6 +259,156 @@ def status_text(code: str): message="get homework", status_code=status.HTTP_200_OK, ) + @transaction.atomic + def put(self, request, homework_id: int): + hw = self._get_hw(homework_id) + course = hw.course + + if not is_teacher_or_ta(request.user, course): + return api_response( + data=None, + message="user must be the teacher or ta of this course", + status_code=status.HTTP_403_FORBIDDEN, + ) + + ser = HomeworkUpdateSerializer(data=request.data) + ser.is_valid(raise_exception=True) + vd = ser.validated_data + + # 基本欄位 + if "name" in vd: + new_title = (vd["name"] or "").strip() + # 可選:避免空白名稱 + if not new_title: + return api_response( + data=None, + message="name cannot be blank", + status_code=status.HTTP_400_BAD_REQUEST, + ) + + # ✅ 同課程內名稱不得與「其他作業」重複(排除自己 hw.id) + exists = Assignments.objects.filter( + course=hw.course, + title=new_title, + ).exclude(id=hw.id).exists() + + if exists: + return api_response( + data=None, + message="homework exists in this course", + status_code=status.HTTP_400_BAD_REQUEST, + ) + + hw.title = new_title + if "markdown" in vd: + hw.description = vd.get("markdown", "") or "" + if "start" in vd: + hw.start_time = vd.get("_start_dt") + if "end" in vd: + hw.due_time = vd.get("_end_dt") + + # penalty -> late_penalty(Decimal 0~100) + if "penalty" in vd: + # 你的 serializer penalty 是 CharField,這裡做轉換 + raw = (vd.get("penalty") or "").strip() + if raw == "": + hw.late_penalty = Decimal("0.00") + else: + try: + val = Decimal(raw) + except Exception: + return api_response( + data=None, + message="penalty must be a number string (0~100)", + status_code=status.HTTP_400_BAD_REQUEST, + ) + if val < 0 or val > 100: + return api_response( + data=None, + message="penalty must be between 0 and 100", + status_code=status.HTTP_400_BAD_REQUEST, + ) + hw.late_penalty = val + + # max_attempts(如果你想支援就從 request.data 讀;你 serializer 沒這欄) + if "max_attempts" in request.data: + try: + ma = int(request.data.get("max_attempts")) + except Exception: + return api_response( + data=None, + message="max_attempts must be int (-1 or >=1)", + status_code=status.HTTP_400_BAD_REQUEST, + ) + if ma != -1 and ma < 1: + return api_response( + data=None, + message="max_attempts must be -1 or >=1", + status_code=status.HTTP_400_BAD_REQUEST, + ) + hw.max_attempts = ma + + hw.save() + + # 題目整包覆蓋 + if "problem_ids" in vd: + new_pids = vd.get("problem_ids") or [] + Assignment_problems.objects.filter(assignment=hw).delete() + for idx, pid in enumerate(new_pids, start=1): + Assignment_problems.objects.create( + assignment=hw, + problem_id=pid, + order_index=idx, + weight=Decimal("1.00"), + special_judge=False, + ) + + payload = { + "id": hw.id, + "name": hw.title, + "start": to_epoch_from_dt(hw.start_time), + "end": to_epoch_from_dt(hw.due_time), + "problemIds": collect_problem_ids(hw), + "markdown": hw.description or "", + "latePenalty": str(hw.late_penalty), # 文件要寫清楚:字串/數字 + "maxAttempts": hw.max_attempts, + } + + return api_response( + data=payload, + message="update homework", + status_code=status.HTTP_200_OK, + ) + @transaction.atomic + def delete(self, request, homework_id: int): + """ + DELETE /homework/ + 刪除作業(老師/TA) + 行為: + - 會連帶刪除 assignment_problems(因為 FK on_delete=CASCADE) + - 不會刪除 Problems / Submissions(submissions 仍存在於題目層級) + """ + hw = self._get_hw(homework_id) + course = hw.course + + # 權限:只有老師/TA + if not is_teacher_or_ta(request.user, course): + return api_response( + data=None, + message="user must be the teacher or ta of this course", + status_code=status.HTTP_403_FORBIDDEN, + ) + + # 刪除 + hw_id = hw.id + hw_title = hw.title + hw.delete() + + return api_response( + data={"id": hw_id, "name": hw_title}, + message="delete homework", + status_code=status.HTTP_200_OK, + ) # --------- NEW: GET /course//homework --------- class CourseHomeworkListView(APIView): diff --git a/docs/assignments.MD b/docs/assignments.MD index 3ed115f..e0dc94c 100644 --- a/docs/assignments.MD +++ b/docs/assignments.MD @@ -47,52 +47,170 @@ cURL 測試範例: 權限:需登入,且必須為該課程之老師或助教。 -請求格式(JSON): +說明: +- 此 API 用於更新作業基本資訊、題目清單、逾期扣分、最大嘗試次數。 +- **只需傳要更新的欄位**(皆為選填)。 +- 若有提供 `problem_ids`,會採用 **整包覆蓋(replace)**:先刪除舊的題目綁定,再依序重建 `order_index=1..n`。 + +--- + +### 請求格式(JSON;欄位皆選填) -| 欄位 | 型別 | 必填 | 說明 | -| ------------ | -------------- | :--: | ------------------------------------------- | -| name | string | ✅ | 作業名稱(同課程內不可重複) | -| markdown | string | ❌ | 作業說明 | -| course_id | uuid | ✅ | 所屬課程 ID | -| start | string or null | ❌ | 開始時間(ISO 8601;後端轉為 `_start_dt`) | -| end | string or null | ❌ | 結束時間(ISO 8601;後端轉為 `_end_dt`) | -| problem_ids | array | ❌ | 題目 ID 列表(會依序建立 `order_index`) | +| 欄位 | 型別 | 說明 | +|---|---|---| +| name | string | 新作業名稱(會 `strip()`;同課程內若與其他作業重名 → 400) | +| markdown | string | 新作業說明(可為空字串) | +| start | int or null | 新開始時間(epoch seconds) | +| end | int or null | 新截止時間(epoch seconds;若同時給 start/end,end 需 >= start) | +| problem_ids | array | 重新指定題目(整包覆蓋;會依序建立 `order_index`) | +| penalty | string | 逾期扣分百分比(字串數字,範圍 0~100;會寫入 `Assignments.late_penalty`) | +| max_attempts | int | 最大嘗試次數(-1 代表不限;或 >=1) | 請求範例: - { - "name": "HW1 - Intro", - "markdown": "第一份作業內容", - "course_id": "4e3e0a4e-6a75-48da-b692-291c65186ce3", - "start": null, - "end": null, - "problem_ids": [101, 102] - } +```json +{ + "name": "HW1 - Updated", + "markdown": "更新後的作業說明", + "start": 1735660800, + "end": 1736265599, + "problem_ids": [201, 202, 203], + "penalty": "10.00", + "max_attempts": -1 +} +``` -成功回應(200): +--- - "Add homework Success" +### 成功回應(200 OK) -錯誤回應: -- 400 名稱重複: +回傳格式採統一 `api_response` 包裝: - "homework exists in this course" +```json +{ + "data": { + "id": 1, + "name": "HW1 - Updated", + "start": 1735660800, + "end": 1736265599, + "problem_ids": [201, 202, 203], + "markdown": "更新後的作業說明", + "late_penalty": "10.00", + "max_attempts": -1 + }, + "message": "update homework", + "status": "ok" +} +``` -- 403 權限不足(非老師或助教): +欄位說明(data): - "user must be the teacher or ta of this course" +| 欄位 | 型別 | 說明 | +|---|---|---| +| id | int | 作業 ID | +| name | string | 作業名稱 | +| start | int or null | 開始時間(epoch seconds) | +| end | int or null | 截止時間(epoch seconds) | +| problem_ids | array | 此作業題目 ID 清單(依 order_index 排序) | +| markdown | string | 作業說明 | +| late_penalty | string | 逾期扣分百分比(Decimal 字串,0~100) | +| max_attempts | int | 最大嘗試次數(-1 或 >=1) | -cURL 測試範例: +--- - curl -X POST "http://127.0.0.1:8000/homework/" ^ - -H "Authorization: Bearer " ^ - -H "Content-Type: application/json" ^ - -d "{ - \"name\": \"HW1 - Intro\", - \"markdown\": \"第一份作業內容\", - \"course_id\": \"\", - \"problem_ids\": [101, 102] - }" +### 錯誤回應 + +#### 400 – 參數錯誤(範例) + +- 名稱空白: + +```json +{ + "data": null, + "message": "name cannot be blank", + "status": "error" +} +``` + +- 同課程作業名稱重複: + +```json +{ + "data": null, + "message": "homework exists in this course", + "status": "error" +} +``` + +- end 早於 start(或 timestamp 格式錯誤等 serializer 驗證失敗): + +```json +{ + "data": null, + "message": "{'end': ['end earlier than start']}", + "status": "error" +} +``` + +- penalty 非數字字串或超出範圍: + +```json +{ + "data": null, + "message": "penalty must be between 0 and 100", + "status": "error" +} +``` + +- max_attempts 非法: + +```json +{ + "data": null, + "message": "max_attempts must be -1 or >=1", + "status": "error" +} +``` + +> 註:serializer 產生的 message 可能是 dict/list 的字串化結果,視你目前的錯誤包裝邏輯而定。 + +#### 403 – 權限不足(非老師或助教) + +```json +{ + "data": null, + "message": "user must be the teacher or ta of this course", + "status": "error" +} +``` + +#### 404 – 作業不存在 + +通常為 DRF 預設: + +```json +{ + "detail": "Not found." +} +``` + +--- + +### cURL 測試範例 + +#### Windows CMD + +```bat +curl -X PUT "http://127.0.0.1:8000/homework//" ^ + -H "Authorization: Bearer " ^ + -H "Content-Type: application/json" ^ + -d "{ + "name": "HW1 - Updated", + "problem_ids": [201, 202, 203], + "penalty": "10.00", + "max_attempts": -1 + }" +``` --- From 341a333fbe3607a95553a498cf777ca7c70c3e9d Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sun, 28 Dec 2025 16:40:13 +0800 Subject: [PATCH 062/113] fix: Correct callback URL in submit_to_sandbox function --- submissions/sandbox_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submissions/sandbox_client.py b/submissions/sandbox_client.py index 2ffb431..e02035d 100644 --- a/submissions/sandbox_client.py +++ b/submissions/sandbox_client.py @@ -95,7 +95,7 @@ def submit_to_sandbox(submission): 'checker_name': problem.checker_name if problem.use_custom_checker else 'diff', 'use_static_analysis': False, # TODO: 從 assignment 設定取得 'priority': 0, # 一般優先級 - 'callback_url': f'{settings.BACKEND_BASE_URL}/submissions/callback/', # Sandbox 判題完成後回傳結果的 URL + 'callback_url': f'{settings.BACKEND_BASE_URL}/submission/callback/', # Sandbox 判題完成後回傳結果的 URL(注意:是 submission 不是 submissions) } # 5. 準備檔案 From 48f3914166726157e0dd25368a7495177ab8e9f7 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sun, 28 Dec 2025 17:00:22 +0800 Subject: [PATCH 063/113] fix: Update callback URL to remove trailing slash --- submissions/sandbox_client.py | 4 ++-- submissions/test_file/test_problem1_submission.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/submissions/sandbox_client.py b/submissions/sandbox_client.py index e02035d..281247a 100644 --- a/submissions/sandbox_client.py +++ b/submissions/sandbox_client.py @@ -95,7 +95,7 @@ def submit_to_sandbox(submission): 'checker_name': problem.checker_name if problem.use_custom_checker else 'diff', 'use_static_analysis': False, # TODO: 從 assignment 設定取得 'priority': 0, # 一般優先級 - 'callback_url': f'{settings.BACKEND_BASE_URL}/submission/callback/', # Sandbox 判題完成後回傳結果的 URL(注意:是 submission 不是 submissions) + 'callback_url': settings.BACKEND_BASE_URL.rstrip('/'), # Sandbox 判題完成後回傳結果的 URL(注意:是 submission 不是 submissions) } # 5. 準備檔案 @@ -185,7 +185,7 @@ def submit_selftest_to_sandbox(problem_id, language_type, source_code, stdin_dat 'checker_name': 'diff', 'use_static_analysis': False, 'priority': -1, # 低優先級(自定義測試不影響正式提交) - 'callback_url': f'{settings.BACKEND_BASE_URL}/submissions/custom-test-callback/', # Custom test callback URL + 'callback_url': settings.BACKEND_BASE_URL.rstrip('/'), # Custom test callback URL } # POST {url} # 準備檔案 diff --git a/submissions/test_file/test_problem1_submission.py b/submissions/test_file/test_problem1_submission.py index 14e3d69..7802b19 100644 --- a/submissions/test_file/test_problem1_submission.py +++ b/submissions/test_file/test_problem1_submission.py @@ -266,7 +266,7 @@ def test_direct_sandbox_submit(problem_hash): 'checker_name': 'diff', 'use_static_analysis': False, 'priority': 0, - 'callback_url': f'{settings.BACKEND_BASE_URL}/submissions/callback/', + 'callback_url': settings.BACKEND_BASE_URL.rstrip('/'), } from io import BytesIO From e46730aae8f4f2c9d0933eb142de9d9b612f5716 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sun, 28 Dec 2025 17:46:16 +0800 Subject: [PATCH 064/113] feat: add testcase hash to Problems model and change problem hash from MD5 to SHA256 --- problems/models.py | 7 +++++++ problems/views/api.py | 22 +++++++++++++++------- submissions/sandbox_client.py | 13 ++++++++----- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/problems/models.py b/problems/models.py index 873f280..2941c6c 100644 --- a/problems/models.py +++ b/problems/models.py @@ -108,6 +108,13 @@ class Visibility(models.TextChoices): blank=True, help_text="List of function names that are forbidden when 'forbid-functions' rule is enabled. Required if 'forbid-functions' is in static_analysis_rules." ) + # --- Testcase package hash --- + testcase_hash = models.CharField( + max_length=64, + blank=True, + null=True, + help_text="SHA256 hash of the testcase package (problem.zip). Updated when testcases are uploaded." + ) creator_id = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='created_problems') course_id = models.ForeignKey('courses.Courses', on_delete=models.PROTECT, null=False, blank=False, related_name='courses') created_at = models.DateTimeField(default=timezone.now) diff --git a/problems/views/api.py b/problems/views/api.py index f944b85..a58cab8 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -659,14 +659,22 @@ def build_meta_entry(ss_idx: int): except Exception: pass saved = _storage.save(rel, out_buf) - return api_response({"path": saved.replace('\\','/')}, "Zip uploaded with meta", status_code=201) + + # 計算 SHA256 hash 並儲存到 problem + import hashlib + out_buf.seek(0) + sha256_hash = hashlib.sha256(out_buf.read()).hexdigest() + problem.testcase_hash = sha256_hash + problem.save(update_fields=['testcase_hash']) + + return api_response({"path": saved.replace('\\','/'), "testcase_hash": sha256_hash}, "Zip uploaded with meta", status_code=201) class ProblemTestCaseChecksumView(APIView): """GET /problem//checksum (Sandbox 專用) - 目的:沙盒下載測資 zip 後驗證 MD5 完整性。 + 目的:沙盒下載測資 zip 後驗證 SHA256 完整性。 驗證:使用 query string `token` 與後端設定的 SANDBOX_TOKEN 比對。 - 回傳:{"checksum": ""} + 回傳:{"checksum": ""} 錯誤:401 token 無效;404 題目或測資不存在。 """ permission_classes = [] # 以 sandbox token 驗證,不用一般身份驗證 @@ -681,11 +689,11 @@ def get(self, request, pk: int): rel = os.path.join("testcases", f"p{problem.id}", "problem.zip") if not _storage.exists(rel): raise Http404("Test case archive not found") - # 計算 MD5 + # 計算 SHA256 import hashlib with _storage.open(rel, 'rb') as fh: - md5 = hashlib.md5(fh.read()).hexdigest() - return api_response({"checksum": md5}, "OK", status_code=200) + sha256 = hashlib.sha256(fh.read()).hexdigest() + return api_response({"checksum": sha256}, "OK", status_code=200) class ProblemTestCaseMetaView(APIView): @@ -709,7 +717,7 @@ def get(self, request, pk: int): import zipfile, hashlib, json with _storage.open(rel, 'rb') as fh: data = fh.read() - md5 = hashlib.md5(data).hexdigest() + sha256 = hashlib.sha256(data).hexdigest() from io import BytesIO buffer = BytesIO(data) # 優先讀取 zip 內的 meta.json;若不存在則回退為檔名掃描 diff --git a/submissions/sandbox_client.py b/submissions/sandbox_client.py index e02035d..3c92257 100644 --- a/submissions/sandbox_client.py +++ b/submissions/sandbox_client.py @@ -80,11 +80,14 @@ def submit_to_sandbox(submission): # 3. 轉換語言代碼 language = convert_language_code(submission.language_type) - # 4. 組裝 payload(multipart/form-data) + # 4. 取得測資包 hash(若無則使用 problem_id 作為 fallback) + problem_hash = problem.testcase_hash or f'p{submission.problem_id}' + + # 5. 組裝 payload(multipart/form-data) data = { 'submission_id': str(submission.id), 'problem_id': str(submission.problem_id), - 'problem_hash': f'TODO_HASH_{submission.problem_id}', # TODO: 實現題目包管理後取得真實 hash + 'problem_hash': problem_hash, 'mode': 'normal', # 目前只支援 single file 'language': language, 'file_hash': submission.code_hash, @@ -98,14 +101,14 @@ def submit_to_sandbox(submission): 'callback_url': f'{settings.BACKEND_BASE_URL}/submission/callback/', # Sandbox 判題完成後回傳結果的 URL(注意:是 submission 不是 submissions) } - # 5. 準備檔案 + # 6. 準備檔案 filename = f'solution.{get_file_extension(language)}' file_content = submission.source_code.encode('utf-8') files = { 'file': (filename, BytesIO(file_content), 'text/plain') } - # 6. 發送請求 + # 7. 發送請求 url = f'{SANDBOX_API_URL}/api/v1/submissions' logger.info(f'Submitting to Sandbox: submission_id={submission.id}, problem_id={submission.problem_id}') @@ -122,7 +125,7 @@ def submit_to_sandbox(submission): timeout=SANDBOX_TIMEOUT ) - # 7. 檢查回應 + # 8. 檢查回應 response.raise_for_status() result = response.json() From 0755ef05c1b9a3345ac2008db33e36c67186bf5b Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 17:47:20 +0800 Subject: [PATCH 065/113] fix: I modify callback_url from {settings.BACKEND_BASE_URL}/submissions/custom-test-callback/ to {settings.BACKEND_BASE_URL} and {settings.BACKEND_BASE_URL}/submissions/callback/ to {settings.BACKEND_BASE_URL}. --- submissions/sandbox_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/submissions/sandbox_client.py b/submissions/sandbox_client.py index 2ffb431..548240f 100644 --- a/submissions/sandbox_client.py +++ b/submissions/sandbox_client.py @@ -95,7 +95,7 @@ def submit_to_sandbox(submission): 'checker_name': problem.checker_name if problem.use_custom_checker else 'diff', 'use_static_analysis': False, # TODO: 從 assignment 設定取得 'priority': 0, # 一般優先級 - 'callback_url': f'{settings.BACKEND_BASE_URL}/submissions/callback/', # Sandbox 判題完成後回傳結果的 URL + 'callback_url': f'{settings.BACKEND_BASE_URL}', # Sandbox 判題完成後回傳結果的 URL } # 5. 準備檔案 @@ -185,7 +185,7 @@ def submit_selftest_to_sandbox(problem_id, language_type, source_code, stdin_dat 'checker_name': 'diff', 'use_static_analysis': False, 'priority': -1, # 低優先級(自定義測試不影響正式提交) - 'callback_url': f'{settings.BACKEND_BASE_URL}/submissions/custom-test-callback/', # Custom test callback URL + 'callback_url': f'{settings.BACKEND_BASE_URL}', # Custom test callback URL } # POST {url} # 準備檔案 From b34ef126c4d391981cc565162f738449b385fcae Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sun, 28 Dec 2025 17:51:53 +0800 Subject: [PATCH 066/113] fix: update checksum endpoint and documentation to use SHA256 instead of MD5 --- docs/problems.MD | 12 ++++++------ problems/urls.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/problems.MD b/docs/problems.MD index 8ed7db8..3bc96a9 100644 --- a/docs/problems.MD +++ b/docs/problems.MD @@ -153,17 +153,17 @@ curl "http://127.0.0.1:8000/problem/30/test-case" -H "Authorization: Bearer $TOK 沙盒在判題前會: 1. 下載 zip 檔案。 -2. 呼叫 checksum 端點確認 MD5 是否一致(避免網路傳輸損毀)。 +2. 呼叫 checksum 端點確認 SHA256 是否一致(避免網路傳輸損毀)。 3. 呼叫 meta 端點取得測資對列表(in/out)以便依序執行。 安全驗證:使用 query string `token` 與後端設定的 `SANDBOX_TOKEN` 比對;不需一般登入權限。若 token 缺失或不符 → 401。 -#### 1) 取得 MD5 校驗和 +#### 1) 取得 SHA256 校驗和 - 路徑:`GET /problem//checksum?token=` - 成功回應: ```json { - "data": { "checksum": "d41d8cd98f00b204e9800998ecf8427e" }, + "data": { "checksum": "a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1" }, "message": "OK", "status": "200" } @@ -178,7 +178,7 @@ curl "http://127.0.0.1:8000/problem/30/checksum?token=$SANDBOX_TOKEN" #### 2) 取得測資元資料 - 路徑:`GET /problem//meta?token=` - 回傳欄位: - - `checksum`: 與 /checksum 相同 MD5(方便比對) + - `checksum`: 與 /checksum 相同 SHA256(方便比對) - `task_count`: 總測試對數量 - `missing_pairs`: 缺失配對的 stem 列表(理想應為空) - `tasks`: 陣列,每項包含 `no`, `stem`, `in`, `out` @@ -187,7 +187,7 @@ curl "http://127.0.0.1:8000/problem/30/checksum?token=$SANDBOX_TOKEN" ```json { "data": { - "checksum": "d41d8cd98f00b204e9800998ecf8427e", + "checksum": "a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1", "task_count": 2, "missing_pairs": [], "tasks": [ @@ -241,7 +241,7 @@ curl "http://127.0.0.1:8000/problem/30/testdata?token=$SANDBOX_TOKEN" -o testdat # 2. 取得 checksum 並驗證完整性 CHECKSUM=$(curl -s "http://127.0.0.1:8000/problem/30/checksum?token=$SANDBOX_TOKEN" | jq -r '.data.checksum') -echo "$CHECKSUM testdata.zip" | md5sum -c +echo "$CHECKSUM testdata.zip" | sha256sum -c # 3. 取得測資結構資訊 curl "http://127.0.0.1:8000/problem/30/meta?token=$SANDBOX_TOKEN" | jq '.data.tasks' diff --git a/problems/urls.py b/problems/urls.py index 8f73d25..3aa2d24 100644 --- a/problems/urls.py +++ b/problems/urls.py @@ -42,7 +42,7 @@ # 題目巢狀測資 CRUD(資料表 Test_cases) path("/test-cases", ProblemTestCaseListCreateView.as_view(), name="problem-testcases"), path("/test-cases/", ProblemTestCaseDetailView.as_view(), name="problem-testcase-detail"), - # Sandbox 專用:測資檔案完整性(MD5)、結構資訊、下載 + # Sandbox 專用:測資檔案完整性(SHA256)、結構資訊、下載 path("/checksum", ProblemTestCaseChecksumView.as_view(), name="problem-testcase-checksum"), path("/meta", ProblemTestCaseMetaView.as_view(), name="problem-testcase-meta"), path("/testdata", ProblemTestCasePackageView.as_view(), name="problem-testcase-package"), From 9a142951e8197521a8586fe88aaf744b2c8ce560 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 17:52:15 +0800 Subject: [PATCH 067/113] fix: I fix the problem mentioned by copilot. It about IP filter check. --- submissions/views.py | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/submissions/views.py b/submissions/views.py index 2ad1efa..0c9d7f0 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -110,10 +110,10 @@ def update_user_problem_stats(submission): stats.solve_status = 'fully_solved' elif stats.best_score > 0: stats.solve_status = 'partial_solved' - elif stats.total_submissions > 0: - stats.solve_status = 'attempted' - else: + elif stats.total_submissions == 0: stats.solve_status = 'never_tried' + else: + stats.solve_status = 'attempted' stats.save() @@ -712,22 +712,30 @@ def get_queryset(self): try: # 支援 CIDR 格式 (例如 192.168.1.0/24) 或簡單前綴 (例如 192.168.) if '/' in ip_prefix: - # CIDR 格式:使用 ipaddress 模組 + # CIDR 格式:使用 ipaddress 模組進行正確的 IP 範圍過濾 import ipaddress + from django.db.models import Q + import struct + import socket + network = ipaddress.ip_network(ip_prefix, strict=False) - # 獲取網段範圍 - start_ip = str(network.network_address) - end_ip = str(network.broadcast_address) - # 篩選 IP 在範圍內的提交 - queryset = queryset.filter( - ip_address__gte=start_ip, - ip_address__lte=end_ip - ) + + # 使用 Django ORM 的自定義過濾 + # 由於 IP 地址存儲為字符串,我們需要逐一檢查 + matching_ips = [] + for submission in queryset: + try: + ip_obj = ipaddress.ip_address(submission.ip_address) + if ip_obj in network: + matching_ips.append(submission.id) + except (ValueError, AttributeError): + continue + + queryset = queryset.filter(id__in=matching_ips) else: # 簡單前綴匹配:例如 "192.168." 會匹配所有 192.168.x.x queryset = queryset.filter(ip_address__startswith=ip_prefix) except (ValueError, TypeError) as e: - import logging logger = logging.getLogger(__name__) logger.warning(f'Invalid ip_prefix parameter: {ip_prefix}, error: {e}') pass # 忽略無效的 IP 前綴 From e998410e76b0827597ef9b496c11d75fc1aa97ef Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sun, 28 Dec 2025 18:06:48 +0800 Subject: [PATCH 068/113] feat: add testcase_hash field to problems model for storing SHA256 hash of testcase package --- problems/migrations/0005_add_testcase_hash.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 problems/migrations/0005_add_testcase_hash.py diff --git a/problems/migrations/0005_add_testcase_hash.py b/problems/migrations/0005_add_testcase_hash.py new file mode 100644 index 0000000..fda7b12 --- /dev/null +++ b/problems/migrations/0005_add_testcase_hash.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-12-28 09:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('problems', '0004_problems_forbidden_functions_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='problems', + name='testcase_hash', + field=models.CharField(blank=True, help_text='SHA256 hash of the testcase package (problem.zip). Updated when testcases are uploaded.', max_length=64, null=True), + ), + ] From 240e3107a42671e742975bda11a46ea03e2653a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 10:09:06 +0000 Subject: [PATCH 069/113] Initial plan From 60d021ad07f7932bfcd8b2a9bc616cb9f58d392e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 10:14:33 +0000 Subject: [PATCH 070/113] Address PR review comments: use stored hash, add migration, fix tests Co-authored-by: happylittle7 <7501374+happylittle7@users.noreply.github.com> --- docs/problems.MD | 2 +- .../migrations/0005_problems_testcase_hash.py | 18 +++++++++++++++ problems/tests/test_api.py | 9 +++++++- problems/views/api.py | 22 +++++++------------ 4 files changed, 35 insertions(+), 16 deletions(-) create mode 100644 problems/migrations/0005_problems_testcase_hash.py diff --git a/docs/problems.MD b/docs/problems.MD index 3bc96a9..19d28a5 100644 --- a/docs/problems.MD +++ b/docs/problems.MD @@ -163,7 +163,7 @@ curl "http://127.0.0.1:8000/problem/30/test-case" -H "Authorization: Bearer $TOK - 成功回應: ```json { - "data": { "checksum": "a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1" }, + "data": { "checksum": "EXAMPLE_SHA256_CHECKSUM" }, "message": "OK", "status": "200" } diff --git a/problems/migrations/0005_problems_testcase_hash.py b/problems/migrations/0005_problems_testcase_hash.py new file mode 100644 index 0000000..d098d8b --- /dev/null +++ b/problems/migrations/0005_problems_testcase_hash.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-12-28 10:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('problems', '0004_problems_forbidden_functions_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='problems', + name='testcase_hash', + field=models.CharField(blank=True, help_text='SHA256 hash of the testcase package (problem.zip). Updated when testcases are uploaded.', max_length=64, null=True), + ), + ] diff --git a/problems/tests/test_api.py b/problems/tests/test_api.py index 462fcea..876b38d 100644 --- a/problems/tests/test_api.py +++ b/problems/tests/test_api.py @@ -188,12 +188,19 @@ def test_sandbox_checksum_and_meta(api_client, teacher, course, settings): except Exception: pass _storage.save(rel, mem) + + # 計算並儲存 SHA256 hash + import hashlib + with _storage.open(rel, 'rb') as fh: + sha256_hash = hashlib.sha256(fh.read()).hexdigest() + p.testcase_hash = sha256_hash + p.save(update_fields=['testcase_hash']) # checksum 正確 token res = api_client.get(f"/problem/{p.id}/checksum", {'token': settings.SANDBOX_TOKEN}) assert res.status_code == 200 body = res.json() - assert 'checksum' in body['data'] and len(body['data']['checksum']) == 32 + assert 'checksum' in body['data'] and len(body['data']['checksum']) == 64 # checksum 錯誤 token res_bad = api_client.get(f"/problem/{p.id}/checksum", {'token': 'wrong'}) diff --git a/problems/views/api.py b/problems/views/api.py index a58cab8..0b0b7da 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -660,10 +660,10 @@ def build_meta_entry(ss_idx: int): pass saved = _storage.save(rel, out_buf) - # 計算 SHA256 hash 並儲存到 problem + # 計算已儲存檔案的 SHA256 hash 並儲存到 problem import hashlib - out_buf.seek(0) - sha256_hash = hashlib.sha256(out_buf.read()).hexdigest() + with _storage.open(rel, 'rb') as fh: + sha256_hash = hashlib.sha256(fh.read()).hexdigest() problem.testcase_hash = sha256_hash problem.save(update_fields=['testcase_hash']) @@ -685,15 +685,10 @@ def get(self, request, pk: int): if not token_expected or token_req != token_expected: return api_response(None, "Invalid sandbox token", status_code=401) problem = get_object_or_404(Problems, pk=pk) - from ..services.storage import _storage - rel = os.path.join("testcases", f"p{problem.id}", "problem.zip") - if not _storage.exists(rel): - raise Http404("Test case archive not found") - # 計算 SHA256 - import hashlib - with _storage.open(rel, 'rb') as fh: - sha256 = hashlib.sha256(fh.read()).hexdigest() - return api_response({"checksum": sha256}, "OK", status_code=200) + # 使用儲存的 testcase_hash + if not problem.testcase_hash: + return api_response(None, "Test case hash not available", status_code=404) + return api_response({"checksum": problem.testcase_hash}, "OK", status_code=200) class ProblemTestCaseMetaView(APIView): @@ -714,10 +709,9 @@ def get(self, request, pk: int): rel = os.path.join("testcases", f"p{problem.id}", "problem.zip") if not _storage.exists(rel): raise Http404("Test case archive not found") - import zipfile, hashlib, json + import zipfile, json with _storage.open(rel, 'rb') as fh: data = fh.read() - sha256 = hashlib.sha256(data).hexdigest() from io import BytesIO buffer = BytesIO(data) # 優先讀取 zip 內的 meta.json;若不存在則回退為檔名掃描 From 832cb4bce62ea092fede23723a783781e3d4769d Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 18:52:26 +0800 Subject: [PATCH 071/113] fix: I add use_static_analysis. --- submissions/sandbox_client.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/submissions/sandbox_client.py b/submissions/sandbox_client.py index 281247a..1bac3e7 100644 --- a/submissions/sandbox_client.py +++ b/submissions/sandbox_client.py @@ -93,7 +93,7 @@ def submit_to_sandbox(submission): # 只有在啟用自訂 checker 時才使用設定的 checker_name,否則強制使用 'diff' 'use_checker': problem.use_custom_checker, 'checker_name': problem.checker_name if problem.use_custom_checker else 'diff', - 'use_static_analysis': False, # TODO: 從 assignment 設定取得 + 'use_static_analysis': problem.use_static_analysis, 'priority': 0, # 一般優先級 'callback_url': settings.BACKEND_BASE_URL.rstrip('/'), # Sandbox 判題完成後回傳結果的 URL(注意:是 submission 不是 submissions) } @@ -158,8 +158,16 @@ def submit_selftest_to_sandbox(problem_id, language_type, source_code, stdin_dat """ import uuid import hashlib + from problems.models import Problems try: + # 取得題目資訊(用於靜態分析設定) + try: + problem = Problems.objects.get(id=problem_id) + except Problems.DoesNotExist: + logger.error(f'Problem {problem_id} not found for selftest') + return None + # 產生臨時 ID(不存 DB,只用於追蹤) temp_id = f"selftest-{uuid.uuid4()}" @@ -183,7 +191,7 @@ def submit_selftest_to_sandbox(problem_id, language_type, source_code, stdin_dat 'memory_limit': 262144, # 256 MB 'use_checker': False, 'checker_name': 'diff', - 'use_static_analysis': False, + 'use_static_analysis': problem.use_static_analysis, 'priority': -1, # 低優先級(自定義測試不影響正式提交) 'callback_url': settings.BACKEND_BASE_URL.rstrip('/'), # Custom test callback URL } From 56d20091b08495f4402fd270957049e6df2a52e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 11:27:04 +0000 Subject: [PATCH 072/113] Initial plan From 690e83bf57516e0e7b318c8ca1c2d33272a45ac9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 11:30:54 +0000 Subject: [PATCH 073/113] fix: address review comments for homework update API - Add max_attempts to HomeworkUpdateSerializer for consistent validation - Fix max_attempts to handle null values explicitly - Replace bare except Exception with specific exceptions - Fix docs response field names to use camelCase (problemIds, latePenalty, maxAttempts) - Fix Windows CMD cURL example to escape double quotes properly Co-authored-by: happylittle7 <7501374+happylittle7@users.noreply.github.com> --- assignments/serializers.py | 7 +++++++ assignments/views.py | 26 +++++++------------------- docs/assignments.MD | 19 +++++++------------ 3 files changed, 21 insertions(+), 31 deletions(-) diff --git a/assignments/serializers.py b/assignments/serializers.py index 1b87d43..cf6e50e 100644 --- a/assignments/serializers.py +++ b/assignments/serializers.py @@ -106,6 +106,7 @@ class HomeworkUpdateSerializer(serializers.Serializer): ) scoreboard_status = serializers.IntegerField(required=False, allow_null=True) penalty = serializers.CharField(required=False, allow_blank=True) + max_attempts = serializers.IntegerField(required=False, allow_null=True) def validate(self, attrs): # 時間檢查 @@ -126,6 +127,12 @@ def validate(self, attrs): if not_found: raise serializers.ValidationError({"problem_ids": f"problems not found: {not_found}"}) + # 驗證 max_attempts + if "max_attempts" in attrs: + ma = attrs.get("max_attempts") + if ma is not None and ma != -1 and ma < 1: + raise serializers.ValidationError({"max_attempts": "max_attempts must be -1 or >=1"}) + attrs["_start_dt"] = start_dt attrs["_end_dt"] = end_dt return attrs diff --git a/assignments/views.py b/assignments/views.py index 99af89c..24bc149 100644 --- a/assignments/views.py +++ b/assignments/views.py @@ -7,7 +7,7 @@ from rest_framework import status, permissions from django.db.models import Max,Sum from django.db.models import Sum, Count, Max, Q -from decimal import Decimal +from decimal import Decimal, InvalidOperation from django.shortcuts import get_object_or_404 from django.utils.dateparse import parse_datetime from django.utils import timezone @@ -316,7 +316,7 @@ def put(self, request, homework_id: int): else: try: val = Decimal(raw) - except Exception: + except (TypeError, ValueError, InvalidOperation): return api_response( data=None, message="penalty must be a number string (0~100)", @@ -330,23 +330,11 @@ def put(self, request, homework_id: int): ) hw.late_penalty = val - # max_attempts(如果你想支援就從 request.data 讀;你 serializer 沒這欄) - if "max_attempts" in request.data: - try: - ma = int(request.data.get("max_attempts")) - except Exception: - return api_response( - data=None, - message="max_attempts must be int (-1 or >=1)", - status_code=status.HTTP_400_BAD_REQUEST, - ) - if ma != -1 and ma < 1: - return api_response( - data=None, - message="max_attempts must be -1 or >=1", - status_code=status.HTTP_400_BAD_REQUEST, - ) - hw.max_attempts = ma + # max_attempts 從 serializer validated_data 讀取 + if "max_attempts" in vd: + ma = vd.get("max_attempts") + if ma is not None: + hw.max_attempts = ma hw.save() diff --git a/docs/assignments.MD b/docs/assignments.MD index e0dc94c..2fe1ca4 100644 --- a/docs/assignments.MD +++ b/docs/assignments.MD @@ -93,10 +93,10 @@ cURL 測試範例: "name": "HW1 - Updated", "start": 1735660800, "end": 1736265599, - "problem_ids": [201, 202, 203], + "problemIds": [201, 202, 203], "markdown": "更新後的作業說明", - "late_penalty": "10.00", - "max_attempts": -1 + "latePenalty": "10.00", + "maxAttempts": -1 }, "message": "update homework", "status": "ok" @@ -111,10 +111,10 @@ cURL 測試範例: | name | string | 作業名稱 | | start | int or null | 開始時間(epoch seconds) | | end | int or null | 截止時間(epoch seconds) | -| problem_ids | array | 此作業題目 ID 清單(依 order_index 排序) | +| problemIds | array | 此作業題目 ID 清單(依 order_index 排序) | | markdown | string | 作業說明 | -| late_penalty | string | 逾期扣分百分比(Decimal 字串,0~100) | -| max_attempts | int | 最大嘗試次數(-1 或 >=1) | +| latePenalty | string | 逾期扣分百分比(Decimal 字串,0~100) | +| maxAttempts | int | 最大嘗試次數(-1 或 >=1) | --- @@ -204,12 +204,7 @@ cURL 測試範例: curl -X PUT "http://127.0.0.1:8000/homework//" ^ -H "Authorization: Bearer " ^ -H "Content-Type: application/json" ^ - -d "{ - "name": "HW1 - Updated", - "problem_ids": [201, 202, 203], - "penalty": "10.00", - "max_attempts": -1 - }" + -d "{\"name\": \"HW1 - Updated\", \"problem_ids\": [201, 202, 203], \"penalty\": \"10.00\", \"max_attempts\": -1}" ``` --- From e002bd1b3ff56061ead23c04da19dfa64af3c95d Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 19:42:01 +0800 Subject: [PATCH 074/113] fix: Fix subtask problem and CE problem. --- ..._alter_submissionresult_status_and_more.py | 23 ++++++++ submissions/models.py | 3 +- submissions/views.py | 52 +++++++++++++++---- 3 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 submissions/migrations/0004_alter_submissionresult_status_and_more.py diff --git a/submissions/migrations/0004_alter_submissionresult_status_and_more.py b/submissions/migrations/0004_alter_submissionresult_status_and_more.py new file mode 100644 index 0000000..d6c3d10 --- /dev/null +++ b/submissions/migrations/0004_alter_submissionresult_status_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2025-12-28 11:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0003_remove_editorial_editorials_problem_79b740_idx_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='submissionresult', + name='status', + field=models.CharField(choices=[('accepted', 'Accepted'), ('wrong_answer', 'Wrong Answer'), ('compile_error', 'Compile Error'), ('time_limit_exceeded', 'Time Limit Exceeded'), ('memory_limit_exceeded', 'Memory Limit Exceeded'), ('runtime_error', 'Runtime Error')], max_length=30), + ), + migrations.AlterField( + model_name='submissionresult', + name='test_case_id', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/submissions/models.py b/submissions/models.py index 116ec23..1b9c264 100644 --- a/submissions/models.py +++ b/submissions/models.py @@ -108,6 +108,7 @@ class SubmissionResult(models.Model): STATUS_CHOICES = [ ('accepted', 'Accepted'), ('wrong_answer', 'Wrong Answer'), + ('compile_error', 'Compile Error'), ('time_limit_exceeded', 'Time Limit Exceeded'), ('memory_limit_exceeded', 'Memory Limit Exceeded'), ('runtime_error', 'Runtime Error'), @@ -125,7 +126,7 @@ class SubmissionResult(models.Model): # Foreign keys problem_id = models.IntegerField() submission = models.ForeignKey(Submission, on_delete=models.CASCADE, related_name='results') - test_case_id = models.IntegerField() + test_case_id = models.IntegerField(null=True, blank=True) # CE 時可能為 None test_case_index = models.IntegerField() # Status and scoring diff --git a/submissions/views.py b/submissions/views.py index 0c9d7f0..c13124c 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -1090,20 +1090,32 @@ def submission_output_view(request, id, task_no, case_no): return api_response(None, "task_no not found", 404) # 4. 找 test_case(task_no + case_no) + test_case = None + test_case_id = None try: test_case = Test_cases.objects.get( subtask_id=subtask.id, idx=case_no ) + test_case_id = test_case.id except Test_cases.DoesNotExist: - return api_response(None, "case_no not found", 404) + logger.warning(f'Test case not found: subtask_id={subtask.id}, idx={case_no}') + # CE 等錯誤可能沒有對應的 test_case,繼續嘗試用其他方式查找 # 5. 找結果 try: - result = SubmissionResult.objects.get( - submission_id=submission.id, - test_case_id=test_case.id - ) + if test_case_id: + # 優先用 test_case_id 查找 + result = SubmissionResult.objects.get( + submission_id=submission.id, + test_case_id=test_case_id + ) + else: + # 如果沒有 test_case_id(例如 CE),用 test_case_index 查找 + result = SubmissionResult.objects.get( + submission_id=submission.id, + test_case_index=case_no + ) except SubmissionResult.DoesNotExist: return api_response(None, "output not found", 404) @@ -1926,16 +1938,38 @@ def post(self, request): logger.info(f'Updated submission {submission_id}: status={submission.status}, score={total_score}') # 4. 建立 SubmissionResult 記錄 + # 先取得所有 subtasks 和 test_cases 的對應關係 + from problems.models import Problem_subtasks, Test_cases + subtasks = Problem_subtasks.objects.filter(problem_id=submission.problem_id).order_by('subtask_no') + for test_result in test_results: # 轉換 status 為字串格式(SubmissionResult 使用字串) result_status = test_result.get('status', 'runtime_error') + # 從 test_result 中取得 subtask_no 和 case_no(Sandbox 回傳格式) + subtask_no = test_result.get('subtask_no', 1) # 預設第 1 個 subtask + case_index = test_result.get('case_no', 1) # 預設第 1 個 case + + # 找到對應的 test_case + test_case_id = None + try: + subtask = subtasks.get(subtask_no=subtask_no) + test_case = Test_cases.objects.get(subtask_id=subtask.id, idx=case_index) + test_case_id = test_case.id + except (Problem_subtasks.DoesNotExist, Test_cases.DoesNotExist): + logger.warning(f'Test case not found: subtask_no={subtask_no}, case_no={case_index}') + # CE 或系統錯誤時,即使找不到 test_case 也要儲存錯誤訊息 + if result_status == 'compile_error' or test_result.get('error_message'): + test_case_id = None # 允許 None + else: + continue # 其他情況跳過 + SubmissionResult.objects.create( - submission=submission, # 使用 submission 而非 submission_id + submission=submission, problem_id=submission.problem_id, - test_case_id=test_result.get('test_case_id'), - test_case_index=test_result.get('test_case_index', 0), - status=result_status, # SubmissionResult 的 status 是字串類型 + test_case_id=test_case_id, + test_case_index=case_index, + status=result_status, execution_time=test_result.get('execution_time'), memory_usage=test_result.get('memory_usage'), score=test_result.get('score', 0), From f9f5b4071afb52fecc8cdb9c25c589964d9bb538 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sun, 28 Dec 2025 19:43:11 +0800 Subject: [PATCH 075/113] Update deploy setting --- .github/workflows/deploy.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 45d9163..190ba2c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,9 +3,8 @@ name: Deploy to dev on: push: branches: - - deploy/add-email-sender - dev - - feat/api-SandBox + - finish_all_submission_function concurrency: deploy-dev From 579f76de5f90a39718702e25e232ad27dc4acdfe Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Sun, 28 Dec 2025 19:43:44 +0800 Subject: [PATCH 076/113] Update branch name for deployment workflow --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 190ba2c..045e14c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,7 +4,7 @@ on: push: branches: - dev - - finish_all_submission_function + - feat/finish_all_submission_function concurrency: deploy-dev From 63572bcf0338a09807f453fb0f9913915f2dae92 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 20:49:14 +0800 Subject: [PATCH 077/113] fix: subtask again. --- submissions/sandbox_client.py | 7 +++ .../test_file/test_sandbox_integration.py | 2 +- submissions/views.py | 47 ++++++++++++------- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/submissions/sandbox_client.py b/submissions/sandbox_client.py index 1bac3e7..101d991 100644 --- a/submissions/sandbox_client.py +++ b/submissions/sandbox_client.py @@ -113,6 +113,13 @@ def submit_to_sandbox(submission): headers = {} if SANDBOX_API_KEY: headers['X-API-KEY'] = SANDBOX_API_KEY + logger.info(f'Using API Key: {SANDBOX_API_KEY[:10]}...') # 只顯示前 10 個字元 + else: + logger.warning('SANDBOX_API_KEY is not set!') + + logger.debug(f'Request URL: {url}') + logger.debug(f'Request data: {data}') + logger.debug(f'Request headers: {headers}') response = requests.post( url, diff --git a/submissions/test_file/test_sandbox_integration.py b/submissions/test_file/test_sandbox_integration.py index 885f296..4835d02 100644 --- a/submissions/test_file/test_sandbox_integration.py +++ b/submissions/test_file/test_sandbox_integration.py @@ -13,7 +13,7 @@ import json # 測試配置 -BASE_URL = "http://127.0.0.1:8000" +BASE_URL = "http://127.0.0.1:8443" # Django 後端運行在 8443 端口 SANDBOX_URL = "http://34.81.90.111:8000" def print_section(title): diff --git a/submissions/views.py b/submissions/views.py index c13124c..ccf55e4 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -1105,18 +1105,22 @@ def submission_output_view(request, id, task_no, case_no): # 5. 找結果 try: if test_case_id: - # 優先用 test_case_id 查找 - result = SubmissionResult.objects.get( + # 優先用 test_case_id 查找(取最新的結果) + result = SubmissionResult.objects.filter( submission_id=submission.id, test_case_id=test_case_id - ) + ).order_by('-created_at').first() else: # 如果沒有 test_case_id(例如 CE),用 test_case_index 查找 - result = SubmissionResult.objects.get( + result = SubmissionResult.objects.filter( submission_id=submission.id, test_case_index=case_no - ) - except SubmissionResult.DoesNotExist: + ).order_by('-created_at').first() + + if not result: + return api_response(None, "output not found", 404) + except Exception as e: + logger.error(f'Error fetching submission result: {str(e)}') return api_response(None, "output not found", 404) # 6. 回傳 @@ -1964,17 +1968,26 @@ def post(self, request): else: continue # 其他情況跳過 - SubmissionResult.objects.create( - submission=submission, - problem_id=submission.problem_id, - test_case_id=test_case_id, - test_case_index=case_index, - status=result_status, - execution_time=test_result.get('execution_time'), - memory_usage=test_result.get('memory_usage'), - score=test_result.get('score', 0), - max_score=test_result.get('max_score', 100), - error_message=test_result.get('error_message'), + # 使用 update_or_create 避免重複呼叫 callback 時產生重複記錄 + # 當 test_case_id 為 None 時(CE 情況),用 test_case_index 區分不同測資 + lookup_fields = { + 'submission': submission, + 'problem_id': submission.problem_id, + 'test_case_index': case_index, + } + if test_case_id is not None: + lookup_fields['test_case_id'] = test_case_id + + SubmissionResult.objects.update_or_create( + **lookup_fields, + defaults={ + 'status': result_status, + 'execution_time': test_result.get('execution_time'), + 'memory_usage': test_result.get('memory_usage'), + 'score': test_result.get('score', 0), + 'max_score': test_result.get('max_score', 100), + 'error_message': test_result.get('error_message'), + } ) logger.info(f'Created {len(test_results)} test results for submission {submission_id}') From d72c0a824f8b85d61c68cde4889b7e40f61ee024 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 21:38:47 +0800 Subject: [PATCH 078/113] fix: Fix subtask again. --- submissions/sandbox_client.py | 3 + submissions/test_file/test_callback_apis.py | 1037 +++++++++---------- submissions/test_file/test_ce_callback.py | 515 +++++++++ submissions/views.py | 25 +- 4 files changed, 1010 insertions(+), 570 deletions(-) create mode 100644 submissions/test_file/test_ce_callback.py diff --git a/submissions/sandbox_client.py b/submissions/sandbox_client.py index 101d991..172d208 100644 --- a/submissions/sandbox_client.py +++ b/submissions/sandbox_client.py @@ -130,6 +130,9 @@ def submit_to_sandbox(submission): ) # 7. 檢查回應 + logger.info(f'Sandbox response status: {response.status_code}') + if response.status_code >= 400: + logger.error(f'Sandbox error response: {response.text}') response.raise_for_status() result = response.json() diff --git a/submissions/test_file/test_callback_apis.py b/submissions/test_file/test_callback_apis.py index 6f972d5..d0b0988 100644 --- a/submissions/test_file/test_callback_apis.py +++ b/submissions/test_file/test_callback_apis.py @@ -2,9 +2,9 @@ """ 測試 Sandbox Callback APIs -測試 Backend 接收 Sandbox 判題結果的 callback endpoints: -1. POST /submission/callback/ - 正式提交結果 -2. POST /submission/custom-test-callback/ - 自定義測試結果 +測試以下兩個 API: +1. SubmissionCallbackAPIView - 正式提交的 callback +2. CustomTestCallbackAPIView - 自定義測試的 callback 使用方式: cd /Users/keliangyun/Desktop/software_engineering/back_end @@ -19,685 +19,614 @@ from datetime import datetime # 設定 Django 環境 -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'back_end.settings') django.setup() -from django.conf import settings +from django.contrib.auth import get_user_model from submissions.models import Submission, SubmissionResult, CustomTest -from user.models import User from problems.models import Problems, Problem_subtasks, Test_cases -from courses.models import Courses -import uuid - -# API 設定 -BASE_URL = "http://localhost:8000" -API_KEY = settings.SANDBOX_API_KEY # 從 settings 讀取 - - -class Colors: - """終端顏色""" - GREEN = '\033[92m' - RED = '\033[91m' - YELLOW = '\033[93m' - BLUE = '\033[94m' - CYAN = '\033[96m' - END = '\033[0m' - BOLD = '\033[1m' - - -def print_header(text): - """列印標題""" - print(f"\n{Colors.BOLD}{Colors.CYAN}{'=' * 70}{Colors.END}") - print(f"{Colors.BOLD}{Colors.CYAN}{text.center(70)}{Colors.END}") - print(f"{Colors.BOLD}{Colors.CYAN}{'=' * 70}{Colors.END}\n") - - -def print_success(text): - """列印成功訊息""" - print(f"{Colors.GREEN}✓ {text}{Colors.END}") +from django.conf import settings +User = get_user_model() -def print_error(text): - """列印錯誤訊息""" - print(f"{Colors.RED}✗ {text}{Colors.END}") +# 測試配置 +BACKEND_URL = "http://127.0.0.1:8443" +API_KEY = getattr(settings, 'SANDBOX_API_KEY', 'happylittle7') -def print_info(text): - """列印資訊""" - print(f"{Colors.BLUE}ℹ {text}{Colors.END}") +def print_section(title): + """列印分隔線""" + print("\n" + "=" * 70) + print(f" {title}") + print("=" * 70) -def print_warning(text): - """列印警告""" - print(f"{Colors.YELLOW}⚠ {text}{Colors.END}") +def print_result(success, message): + """列印測試結果""" + status = "✓ 成功" if success else "✗ 失敗" + print(f"{status}: {message}") def create_test_data(): - """建立測試資料""" - print_header("準備測試資料") + """創建測試用的資料""" + print_section("準備測試資料") + # 1. 創建測試用戶 try: - # 1. 確保有測試用戶 - user, created = User.objects.get_or_create( - username='test_callback_user', - defaults={ - 'email': 'test_callback@example.com', - 'real_name': 'Test Callback User', - 'identity': 'student' - } - ) - if created: - user.set_password('testpass123') - user.save() - print_success(f"建立測試用戶: {user.username}") - else: - print_info(f"使用現有測試用戶: {user.username}") - - # 2. 確保有課程(題目需要課程) - course, created = Courses.objects.get_or_create( - name='Test Course for Callback', - defaults={ - 'description': 'Test course for callback testing', - 'teacher_id': user, - 'is_active': True - } - ) - if created: - print_success(f"建立測試課程: {course.name}") - else: - print_info(f"使用現有測試課程: {course.name}") - - # 3. 使用現有題目或建立最簡單的題目 + user = User.objects.get(username='test_callback') + print_result(True, f"使用現有用戶: {user.username}") + except User.DoesNotExist: try: - # 嘗試找任意一個現有題目 - problem = Problems.objects.first() - if not problem: - # 如果沒有題目,建立最基本的題目(只填必要欄位) - problem = Problems.objects.create( - title='Test Problem for Callback', - difficulty='easy', - description='Test problem', - creator_id=user, - course_id=course, - is_public='public' - ) - print_success(f"建立測試題目: {problem.title}") - else: - print_info(f"使用現有題目: ID={problem.id}, {problem.title}") - except Exception as e: - print_error(f"無法存取題目資料: {str(e)}") - raise - - # 4. 確保有 subtask 和 test case - subtask = Problem_subtasks.objects.filter(problem_id=problem).first() - if not subtask: - subtask = Problem_subtasks.objects.create( - problem_id=problem, - subtask_no=1, - weight=100, - time_limit_ms=1000, - memory_limit_mb=256 + user = User.objects.create_user( + username='test_callback', + email='test_callback@example.com', + password='test123456' ) - print_success(f"建立 Subtask 1") - else: - print_info(f"使用現有 Subtask: {subtask.subtask_no}") - - test_case = Test_cases.objects.filter(subtask_id=subtask).first() - if not test_case: - test_case = Test_cases.objects.create( - subtask_id=subtask, - idx=1, - input_path='test/1.in', - output_path='test/1.out', - status='ready' - ) - print_success(f"建立 Test Case 1") - else: - print_info(f"使用現有 Test Case: {test_case.idx}") - - # 5. 建立測試 Submission - submission = Submission.objects.create( - user=user, - problem_id=problem.id, - language_type=2, # Python - source_code='print(sum(map(int, input().split())))', - status=-1, # Pending - score=0 - ) - print_success(f"建立測試 Submission: {submission.id}") - - # 6. 建立測試 CustomTest - custom_test = CustomTest.objects.create( - id=str(uuid.uuid4()), - user=user, - problem_id=problem.id, - language_type=2, # Python - source_code='print("Hello, World!")', - input_data='', - status=0 # Pending - ) - print_success(f"建立測試 CustomTest: {custom_test.id}") - - return { - 'user': user, - 'problem': problem, - 'test_case': test_case, - 'submission': submission, - 'custom_test': custom_test + print_result(True, f"創建新用戶: {user.username}") + except Exception as e: + # 如果 email 已存在,嘗試找到該用戶或使用不同的 email + try: + user = User.objects.get(email='test_callback@example.com') + print_result(True, f"使用現有用戶 (透過 email): {user.username}") + except User.DoesNotExist: + # 使用帶時間戳的 email + import time + unique_email = f'test_callback_{int(time.time())}@example.com' + user = User.objects.create_user( + username='test_callback', + email=unique_email, + password='test123456' + ) + print_result(True, f"創建新用戶 (unique email): {user.username}") + + # 2. 創建測試題目(如果不存在) + try: + problem = Problems.objects.get(id=1) + print_result(True, f"使用現有題目: Problem ID {problem.id}") + except Problems.DoesNotExist: + print_result(False, "找不到 Problem ID 1,請先創建測試題目") + return None, None, None, None + + # 3. 創建 subtask 和 test_case(如果不存在) + subtask, created = Problem_subtasks.objects.get_or_create( + problem_id=problem.id, + subtask_no=1, + defaults={ + 'time_limit_ms': 1000, + 'memory_limit_mb': 256, + 'score': 100 } - - except Exception as e: - print_error(f"建立測試資料失敗: {str(e)}") - import traceback - traceback.print_exc() - return None - - -def test_submission_callback_success(submission_id, test_case_id): - """測試 1: 正式提交 Callback - 成功案例""" - print_header("測試 1: 正式提交 Callback - 成功案例 (Accepted)") - - # 重置 submission - submission = Submission.objects.get(id=submission_id) - submission.status = -1 # Pending - submission.score = 0 - submission.save() - SubmissionResult.objects.filter(submission=submission).delete() - - url = f"{BASE_URL}/submission/callback/" - headers = { - 'Content-Type': 'application/json', - 'X-API-KEY': API_KEY - } - data = { - 'submission_id': str(submission_id), - 'status': 'accepted', - 'score': 100, - 'execution_time': 123, - 'memory_usage': 1024, - 'test_results': [ + ) + if created: + print_result(True, f"創建 Subtask 1") + else: + print_result(True, f"使用現有 Subtask 1") + + test_case, created = Test_cases.objects.get_or_create( + subtask_id=subtask, # 使用 subtask 實例 + idx=1, + defaults={ + 'input_path': '/media/testcases/problem_1/subtask_1/1.in', + 'output_path': '/media/testcases/problem_1/subtask_1/1.out', + 'status': 'ready' + } + ) + if created: + print_result(True, f"創建 Test Case 1") + else: + print_result(True, f"使用現有 Test Case 1 (ID: {test_case.id})") + + # 4. 創建測試提交 + submission = Submission.objects.create( + user=user, + problem_id=problem.id, + language_type=2, # Python + source_code='print(int(input()) + int(input()))', + status='-1', # Pending + score=0 + ) + print_result(True, f"創建測試提交: {submission.id}") + + return user, problem, test_case, submission + + +def test_submission_callback(submission_id, test_case_id): + """測試正式提交的 callback API""" + print_section("測試 1: Submission Callback API (AC 情況)") + + url = f"{BACKEND_URL}/submission/callback/" + + # 準備測試資料(按照文件規格) + payload = { + "submission_id": str(submission_id), + "status": "accepted", + "score": 100, + "execution_time": 123, + "memory_usage": 1024, + "test_results": [ { - 'test_case_id': test_case_id, - 'test_case_index': 1, - 'status': 'accepted', - 'execution_time': 123, - 'memory_usage': 1024, - 'score': 100, - 'max_score': 100, - 'error_message': None + "test_case_id": test_case_id, # 使用實際的資料庫 ID + "test_case_index": 1, # 顯示編號 + "status": "accepted", + "execution_time": 50, + "memory_usage": 512, + "score": 100, + "max_score": 100, + "error_message": None } ] } - print_info(f"POST {url}") - print_info(f"Submission ID: {submission_id}") + headers = { + "Content-Type": "application/json", + "X-API-KEY": API_KEY + } + + print(f"\n請求 URL: {url}") + print(f"API Key: {API_KEY}") + print(f"Payload: {json.dumps(payload, indent=2)}") try: - response = requests.post(url, json=data, headers=headers) - print_info(f"狀態碼: {response.status_code}") - print_info(f"回應: {response.text}") + response = requests.post(url, json=payload, headers=headers, timeout=10) + print(f"\n狀態碼: {response.status_code}") + print(f"響應: {response.text}") if response.status_code == 200: - result = response.json() - if result.get('status') == 'ok': - print_success("測試通過 - Callback 成功處理") - - # 驗證資料庫更新 - submission = Submission.objects.get(id=submission_id) - if submission.status == '0' and submission.score == 100: # '0' = AC - print_success("資料庫驗證通過 - Submission 已更新") - else: - print_error(f"資料庫驗證失敗 - status={submission.status} (期望 '0'), score={submission.score} (期望 100)") - - # 驗證 SubmissionResult 建立 - results_count = SubmissionResult.objects.filter(submission=submission).count() - if results_count == 1: - print_success("資料庫驗證通過 - SubmissionResult 已建立") - else: - print_error(f"資料庫驗證失敗 - 期望 1 筆結果,實際 {results_count} 筆") - - return True + print_result(True, "Callback 處理成功") + + # 驗證資料庫更新 + submission = Submission.objects.get(id=submission_id) + if submission.status == '0': # AC + print_result(True, f"Submission 狀態已更新為 AC") else: - print_error(f"測試失敗 - 回應狀態錯誤: {result}") - return False + print_result(False, f"Submission 狀態錯誤: {submission.status}") + + if submission.score == 100: + print_result(True, f"Submission 分數已更新: {submission.score}") + else: + print_result(False, f"Submission 分數錯誤: {submission.score}") + + # 檢查 SubmissionResult + results = SubmissionResult.objects.filter(submission_id=submission_id) + if results.count() > 0: + print_result(True, f"已創建 {results.count()} 筆 SubmissionResult") + for result in results: + print(f" - Test Case {result.test_case_index}: {result.status}, Score: {result.score}/{result.max_score}") + else: + print_result(False, "沒有創建 SubmissionResult") + + return True else: - print_error(f"測試失敗 - HTTP {response.status_code}") + print_result(False, f"HTTP 狀態碼錯誤: {response.status_code}") return False except Exception as e: - print_error(f"測試失敗 - {str(e)}") - import traceback - traceback.print_exc() + print_result(False, f"請求失敗: {str(e)}") return False -def test_submission_callback_wrong_answer(submission_id, test_case_id): - """測試 2: 正式提交 Callback - Wrong Answer""" - print_header("測試 2: 正式提交 Callback - Wrong Answer") - - # 重置 submission - submission = Submission.objects.get(id=submission_id) - submission.status = -1 - submission.score = 0 - submission.save() - SubmissionResult.objects.filter(submission=submission).delete() - - url = f"{BASE_URL}/submission/callback/" - headers = { - 'Content-Type': 'application/json', - 'X-API-KEY': API_KEY - } - data = { - 'submission_id': str(submission_id), - 'status': 'wrong_answer', - 'score': 50, - 'execution_time': 100, - 'memory_usage': 1024, - 'test_results': [ - { - 'test_case_id': test_case_id, - 'test_case_index': 1, - 'status': 'wrong_answer', - 'execution_time': 100, - 'memory_usage': 1024, - 'score': 50, - 'max_score': 100, - 'error_message': 'Expected: 5, Got: 4' - } - ] - } - - print_info(f"POST {url}") +def test_submission_callback_with_ce(): + """測試編譯錯誤的 callback(test_case_id 為 None)""" + print_section("測試 2: Submission Callback API (Compile Error)") + # 創建新的提交用於測試 CE try: - response = requests.post(url, json=data, headers=headers) - - if response.status_code == 200: - result = response.json() - if result.get('status') == 'ok': - print_success("測試通過 - WA 回應成功處理") - - submission = Submission.objects.get(id=submission_id) - if submission.status == '1' and submission.score == 50: # '1' = WA - print_success("資料庫驗證通過 - WA 狀態正確") - return True - else: - print_error(f"資料庫驗證失敗 - status={submission.status} (期望 '1'), score={submission.score} (期望 50)") - return False - - print_error(f"測試失敗 - HTTP {response.status_code}") + user = User.objects.filter(username='test_callback').first() or User.objects.first() + if not user: + print_result(False, "找不到任何用戶") + return False + problem = Problems.objects.get(id=1) + except Problems.DoesNotExist: + print_result(False, "找不到測試題目") return False - except Exception as e: - print_error(f"測試失敗 - {str(e)}") + print_result(False, f"錯誤: {str(e)}") return False - - -def test_submission_callback_no_api_key(submission_id): - """測試 3: 正式提交 Callback - 缺少 API Key""" - print_header("測試 3: 正式提交 Callback - 缺少 API Key (應失敗)") - url = f"{BASE_URL}/submission/callback/" - headers = { - 'Content-Type': 'application/json' - # 故意不加 X-API-KEY + ce_submission = Submission.objects.create( + user=user, + problem_id=problem.id, + language_type=2, # Python + source_code='print(invalid syntax', # 故意的語法錯誤 + status='-1', # Pending + score=0 + ) + + url = f"{BACKEND_URL}/submission/callback/" + + # CE 的測試資料(test_case_id 為 None) + payload = { + "submission_id": str(ce_submission.id), + "status": "compile_error", + "score": 0, + "execution_time": 0, + "memory_usage": 0, + "test_results": [ + { + "test_case_id": None, # CE 時沒有 test_case_id + "test_case_index": 1, + "status": "compile_error", + "execution_time": 0, + "memory_usage": 0, + "score": 0, + "max_score": 100, + "error_message": "SyntaxError: invalid syntax at line 1" + }, + { + "test_case_id": None, + "test_case_index": 2, + "status": "compile_error", + "execution_time": 0, + "memory_usage": 0, + "score": 0, + "max_score": 100, + "error_message": "SyntaxError: invalid syntax at line 1" + } + ] } - data = { - 'submission_id': str(submission_id), - 'status': 'accepted', - 'score': 100, - 'execution_time': 123, - 'memory_usage': 1024, - 'test_results': [] + + headers = { + "Content-Type": "application/json", + "X-API-KEY": API_KEY } - print_info(f"POST {url}") - print_warning("故意不傳送 API Key") + print(f"\n請求 URL: {url}") + print(f"Submission ID: {ce_submission.id}") + print(f"測試多筆 CE 測資...") try: - response = requests.post(url, json=data, headers=headers) + response = requests.post(url, json=payload, headers=headers, timeout=10) + print(f"\n狀態碼: {response.status_code}") + print(f"響應: {response.text}") - if response.status_code == 401: - print_success("測試通過 - 正確拒絕未授權請求 (401)") + if response.status_code == 200: + print_result(True, "CE Callback 處理成功") + + # 驗證資料庫更新 + submission = Submission.objects.get(id=ce_submission.id) + if submission.status == '2': # CE + print_result(True, f"Submission 狀態已更新為 CE") + else: + print_result(False, f"Submission 狀態錯誤: {submission.status}") + + # 檢查 SubmissionResult(CE 應該也要有記錄) + results = SubmissionResult.objects.filter(submission_id=ce_submission.id) + if results.count() == 2: + print_result(True, f"已創建 {results.count()} 筆 CE SubmissionResult(多筆測資)") + for result in results: + print(f" - Test Case {result.test_case_index}: {result.status}") + if result.error_message: + print(f" Error: {result.error_message}") + else: + print_result(False, f"SubmissionResult 數量錯誤: {results.count()} (預期 2)") + return True else: - print_error(f"測試失敗 - 期望 401,實際 {response.status_code}") + print_result(False, f"HTTP 狀態碼錯誤: {response.status_code}") return False except Exception as e: - print_error(f"測試失敗 - {str(e)}") + print_result(False, f"請求失敗: {str(e)}") return False -def test_submission_callback_wrong_api_key(submission_id): - """測試 4: 正式提交 Callback - 錯誤的 API Key""" - print_header("測試 4: 正式提交 Callback - 錯誤的 API Key (應失敗)") - - url = f"{BASE_URL}/submission/callback/" - headers = { - 'Content-Type': 'application/json', - 'X-API-KEY': 'wrong-api-key-123456' - } - data = { - 'submission_id': str(submission_id), - 'status': 'accepted', - 'score': 100, - 'execution_time': 123, - 'memory_usage': 1024, - 'test_results': [] - } - - print_info(f"POST {url}") - print_warning("傳送錯誤的 API Key") +def test_custom_test_callback(): + """測試自定義測試的 callback API""" + print_section("測試 3: Custom Test Callback API") + # 創建測試用的 CustomTest(如果 model 存在) try: - response = requests.post(url, json=data, headers=headers) - - if response.status_code == 401: - print_success("測試通過 - 正確拒絕錯誤 API Key (401)") - return True - else: - print_error(f"測試失敗 - 期望 401,實際 {response.status_code}") + user = User.objects.filter(username='test_callback').first() or User.objects.first() + if not user: + print_result(False, "找不到任何用戶") return False - + problem = Problems.objects.get(id=1) + + custom_test = CustomTest.objects.create( + user=user, + problem_id=problem.id, + language_type=2, # Python + source_code='print(input())', + stdin='Hello World', + status='pending' + ) + + test_id = custom_test.id + print_result(True, f"創建測試用 CustomTest: {test_id}") + except Exception as e: - print_error(f"測試失敗 - {str(e)}") + print_result(False, f"無法創建 CustomTest: {str(e)}") + print("跳過 Custom Test Callback 測試") return False - - -def test_submission_callback_not_found(): - """測試 5: 正式提交 Callback - Submission 不存在""" - print_header("測試 5: 正式提交 Callback - Submission 不存在 (應失敗)") - fake_id = str(uuid.uuid4()) - url = f"{BASE_URL}/submission/callback/" - headers = { - 'Content-Type': 'application/json', - 'X-API-KEY': API_KEY + url = f"{BACKEND_URL}/submission/custom-test-callback/" + + # 準備測試資料 + payload = { + "submission_id": str(test_id), + "status": "completed", + "stdout": "Hello World\n", + "stderr": "", + "execution_time": 50, + "memory_usage": 512, + "exit_code": 0 } - data = { - 'submission_id': fake_id, - 'status': 'accepted', - 'score': 100, - 'execution_time': 123, - 'memory_usage': 1024, - 'test_results': [] + + headers = { + "Content-Type": "application/json", + "X-API-KEY": API_KEY } - print_info(f"POST {url}") - print_warning(f"使用不存在的 Submission ID: {fake_id}") + print(f"\n請求 URL: {url}") + print(f"Payload: {json.dumps(payload, indent=2)}") try: - response = requests.post(url, json=data, headers=headers) + response = requests.post(url, json=payload, headers=headers, timeout=10) + print(f"\n狀態碼: {response.status_code}") + print(f"響應: {response.text}") - if response.status_code == 404: - print_success("測試通過 - 正確回傳 404 Not Found") + if response.status_code == 200: + print_result(True, "Custom Test Callback 處理成功") + + # 驗證資料庫更新 + custom_test.refresh_from_db() + if custom_test.status == 'completed': + print_result(True, f"CustomTest 狀態已更新為 completed") + else: + print_result(False, f"CustomTest 狀態錯誤: {custom_test.status}") + + if custom_test.actual_output == "Hello World\n": + print_result(True, f"CustomTest 輸出已保存") + else: + print_result(False, f"CustomTest 輸出錯誤: {custom_test.actual_output}") + return True else: - print_error(f"測試失敗 - 期望 404,實際 {response.status_code}") + print_result(False, f"HTTP 狀態碼錯誤: {response.status_code}") return False except Exception as e: - print_error(f"測試失敗 - {str(e)}") + print_result(False, f"請求失敗: {str(e)}") return False -def test_submission_callback_missing_field(): - """測試 6: 正式提交 Callback - 缺少必要欄位""" - print_header("測試 6: 正式提交 Callback - 缺少 submission_id (應失敗)") +def test_api_key_authentication(): + """測試 API Key 認證""" + print_section("測試 4: API Key 認證") - url = f"{BASE_URL}/submission/callback/" - headers = { - 'Content-Type': 'application/json', - 'X-API-KEY': API_KEY - } - data = { - # 故意不傳 submission_id - 'status': 'accepted', - 'score': 100, - 'execution_time': 123, - 'memory_usage': 1024, - 'test_results': [] + try: + user = User.objects.filter(username='test_callback').first() or User.objects.first() + if not user: + print_result(False, "找不到任何用戶") + return False + problem = Problems.objects.get(id=1) + except Problems.DoesNotExist: + print_result(False, "找不到測試題目") + return False + except Exception as e: + print_result(False, f"錯誤: {str(e)}") + return False + + submission = Submission.objects.create( + user=user, + problem_id=problem.id, + language_type=2, + source_code='print("test")', + status='-1', + score=0 + ) + + url = f"{BACKEND_URL}/submission/callback/" + + payload = { + "submission_id": str(submission.id), + "status": "accepted", + "score": 100, + "execution_time": 100, + "memory_usage": 1000, + "test_results": [] } - print_info(f"POST {url}") - print_warning("故意不傳送 submission_id") + # 測試錯誤的 API Key + headers = { + "Content-Type": "application/json", + "X-API-KEY": "wrong_api_key" + } + print("\n測試錯誤的 API Key...") try: - response = requests.post(url, json=data, headers=headers) + response = requests.post(url, json=payload, headers=headers, timeout=10) + print(f"狀態碼: {response.status_code}") - if response.status_code == 400: - print_success("測試通過 - 正確回傳 400 Bad Request") + if response.status_code == 401: + print_result(True, "正確拒絕了錯誤的 API Key (401 Unauthorized)") return True else: - print_error(f"測試失敗 - 期望 400,實際 {response.status_code}") + print_result(False, f"應該回傳 401,但回傳了 {response.status_code}") return False - except Exception as e: - print_error(f"測試失敗 - {str(e)}") + print_result(False, f"請求失敗: {str(e)}") return False -def test_custom_test_callback_success(custom_test_id): - """測試 7: 自定義測試 Callback - 成功案例""" - print_header("測試 7: 自定義測試 Callback - 成功案例") - - # 重置 custom test - custom_test = CustomTest.objects.get(id=custom_test_id) - custom_test.status = 0 # Pending - custom_test.actual_output = None - custom_test.error_message = None - custom_test.save() - - url = f"{BASE_URL}/submission/custom-test-callback/" - headers = { - 'Content-Type': 'application/json', - 'X-API-KEY': API_KEY - } - data = { - 'submission_id': str(custom_test_id), - 'status': 'completed', - 'stdout': 'Hello, World!\n', - 'stderr': '', - 'execution_time': 50, - 'memory_usage': 512, - 'exit_code': 0 - } - - print_info(f"POST {url}") - print_info(f"CustomTest ID: {custom_test_id}") +def test_multiple_test_cases(): + """測試多筆測資的情況""" + print_section("測試 5: 多筆測資 Callback") try: - response = requests.post(url, json=data, headers=headers) - print_info(f"狀態碼: {response.status_code}") - print_info(f"回應: {response.text}") - - if response.status_code == 200: - result = response.json() - if result.get('status') == 'ok': - print_success("測試通過 - Custom Test Callback 成功處理") - - # 驗證資料庫更新 - custom_test = CustomTest.objects.get(id=custom_test_id) - if custom_test.status == 'completed' and custom_test.actual_output == 'Hello, World!\n': - print_success("資料庫驗證通過 - CustomTest 已更新") - return True - else: - print_error(f"資料庫驗證失敗 - status={custom_test.status} (期望 'completed'), actual_output={repr(custom_test.actual_output)}") - return False - - print_error(f"測試失敗 - HTTP {response.status_code}") + user = User.objects.filter(username='test_callback').first() or User.objects.first() + if not user: + print_result(False, "找不到任何用戶") + return False + problem = Problems.objects.get(id=1) + except Problems.DoesNotExist: + print_result(False, "找不到測試題目") return False - except Exception as e: - print_error(f"測試失敗 - {str(e)}") - import traceback - traceback.print_exc() + print_result(False, f"錯誤: {str(e)}") return False - - -def test_custom_test_callback_error(custom_test_id): - """測試 8: 自定義測試 Callback - 錯誤案例""" - print_header("測試 8: 自定義測試 Callback - 執行錯誤") - - # 重置 custom test - custom_test = CustomTest.objects.get(id=custom_test_id) - custom_test.status = 0 # Pending - custom_test.actual_output = None - custom_test.error_message = None - custom_test.save() - - url = f"{BASE_URL}/submission/custom-test-callback/" - headers = { - 'Content-Type': 'application/json', - 'X-API-KEY': API_KEY - } - data = { - 'submission_id': str(custom_test_id), - 'status': 'error', - 'stdout': '', - 'stderr': 'ZeroDivisionError: division by zero', - 'execution_time': 10, - 'memory_usage': 512, - 'exit_code': 1 - } - print_info(f"POST {url}") + # 獲取或創建多個 test case + subtask = Problem_subtasks.objects.filter(problem_id=problem.id).first() - try: - response = requests.post(url, json=data, headers=headers) - - if response.status_code == 200: - result = response.json() - if result.get('status') == 'ok': - print_success("測試通過 - Error 回應成功處理") - - custom_test = CustomTest.objects.get(id=custom_test_id) - if custom_test.status == 'error' and custom_test.error_message: # 'error' 狀態 - print_success("資料庫驗證通過 - Error 狀態正確") - return True - else: - print_error(f"資料庫驗證失敗 - status={custom_test.status} (期望 'error'), error_message={custom_test.error_message}") - return False - - print_error(f"測試失敗 - HTTP {response.status_code}") - return False - - except Exception as e: - print_error(f"測試失敗 - {str(e)}") - return False - - -def test_custom_test_callback_no_auth(custom_test_id): - """測試 9: 自定義測試 Callback - 缺少認證""" - print_header("測試 9: 自定義測試 Callback - 缺少認證 (應失敗)") + test_cases = [] + for i in range(1, 4): # 創建 3 個 test case + tc, created = Test_cases.objects.get_or_create( + subtask_id=subtask, # 使用 subtask 實例 + idx=i, + defaults={ + 'input_path': f'/media/testcases/problem_1/subtask_1/{i}.in', + 'output_path': f'/media/testcases/problem_1/subtask_1/{i}.out', + 'status': 'ready' + } + ) + test_cases.append(tc) + + # 創建新提交 + submission = Submission.objects.create( + user=user, + problem_id=problem.id, + language_type=2, + source_code='print("test")', + status='-1', + score=0 + ) + + url = f"{BACKEND_URL}/submission/callback/" + + # 準備多筆測資的結果 + payload = { + "submission_id": str(submission.id), + "status": "wrong_answer", # 部分錯誤 + "score": 66, + "execution_time": 200, + "memory_usage": 2048, + "test_results": [ + { + "test_case_id": test_cases[0].id, + "test_case_index": 1, + "status": "accepted", + "execution_time": 60, + "memory_usage": 600, + "score": 33, + "max_score": 33, + "error_message": None + }, + { + "test_case_id": test_cases[1].id, + "test_case_index": 2, + "status": "accepted", + "execution_time": 70, + "memory_usage": 700, + "score": 33, + "max_score": 33, + "error_message": None + }, + { + "test_case_id": test_cases[2].id, + "test_case_index": 3, + "status": "wrong_answer", + "execution_time": 70, + "memory_usage": 748, + "score": 0, + "max_score": 34, + "error_message": "Expected: 7, Got: 6" + } + ] + } - url = f"{BASE_URL}/submission/custom-test-callback/" headers = { - 'Content-Type': 'application/json' - # 故意不加 X-API-KEY - } - data = { - 'submission_id': str(custom_test_id), - 'status': 'completed', - 'stdout': 'test', - 'stderr': '', - 'execution_time': 50, - 'memory_usage': 512, - 'exit_code': 0 + "Content-Type": "application/json", + "X-API-KEY": API_KEY } - print_info(f"POST {url}") - print_warning("故意不傳送 API Key") + print(f"\n請求 URL: {url}") + print(f"測試 3 筆測資(2 AC, 1 WA)...") try: - response = requests.post(url, json=data, headers=headers) + response = requests.post(url, json=payload, headers=headers, timeout=10) + print(f"\n狀態碼: {response.status_code}") + print(f"響應: {response.text}") - if response.status_code == 401: - print_success("測試通過 - 正確拒絕未授權請求 (401)") + if response.status_code == 200: + print_result(True, "多筆測資 Callback 處理成功") + + # 驗證資料庫 + submission.refresh_from_db() + results = SubmissionResult.objects.filter(submission_id=submission.id).order_by('test_case_index') + + if results.count() == 3: + print_result(True, f"已創建 3 筆 SubmissionResult") + for result in results: + print(f" - Test Case {result.test_case_index}: {result.status}, Score: {result.score}/{result.max_score}") + if result.error_message: + print(f" Error: {result.error_message}") + else: + print_result(False, f"SubmissionResult 數量錯誤: {results.count()} (預期 3)") + return True else: - print_error(f"測試失敗 - 期望 401,實際 {response.status_code}") + print_result(False, f"HTTP 狀態碼錯誤: {response.status_code}") return False except Exception as e: - print_error(f"測試失敗 - {str(e)}") + print_result(False, f"請求失敗: {str(e)}") return False def main(): - """主測試流程""" - print(f""" -╔══════════════════════════════════════════════════════════════════╗ -║ Sandbox Callback APIs 測試工具 ║ -║ 測試 Backend 接收 Sandbox 判題結果的功能 ║ -╚══════════════════════════════════════════════════════════════════╝ - """) - - # 檢查 Django Server - print_header("前置檢查") + """主函數""" + print("\n╔" + "=" * 68 + "╗") + print("║" + " " * 15 + "Sandbox Callback APIs 測試腳本" + " " * 24 + "║") + print("╚" + "=" * 68 + "╝") + + # 確認 Django 伺服器正在運行 + print_section("檢查 Django 伺服器") try: - # 改用 admin 頁面檢查,因為 /api/ 可能不存在 - response = requests.get(f"{BASE_URL}/admin/", timeout=5, allow_redirects=False) - if response.status_code in [200, 302]: # 200 或重定向都表示 server 運行中 - print_success("Django Server 運行中") - else: - print_error(f"Django Server 異常 - HTTP {response.status_code}") - print_warning("請確認 Django Server 是否正常運行") - return - except requests.RequestException as e: - print_error(f"無法連接到 Django Server: {str(e)}") - print_warning("請先啟動 Django Server: python manage.py runserver") + response = requests.get(f"{BACKEND_URL}/", timeout=5) + print_result(True, f"Django 伺服器運行中 ({BACKEND_URL})") + except Exception as e: + print_result(False, f"無法連接到 Django 伺服器: {str(e)}") + print("\n請確保 Django 伺服器正在運行:") + print(" python manage.py runserver 0.0.0.0:8443") return - # 建立測試資料 - test_data = create_test_data() - if not test_data: - print_error("無法建立測試資料,終止測試") + # 創建測試資料 + user, problem, test_case, submission = create_test_data() + if not submission: + print("\n測試終止:無法創建必要的測試資料") return - submission_id = test_data['submission'].id - custom_test_id = test_data['custom_test'].id - test_case_id = test_data['test_case'].id - # 執行測試 results = [] - # 正式提交 Callback 測試 - results.append(("正式提交 - 成功案例", test_submission_callback_success(submission_id, test_case_id))) - results.append(("正式提交 - Wrong Answer", test_submission_callback_wrong_answer(submission_id, test_case_id))) - results.append(("正式提交 - 缺少 API Key", test_submission_callback_no_api_key(submission_id))) - results.append(("正式提交 - 錯誤 API Key", test_submission_callback_wrong_api_key(submission_id))) - results.append(("正式提交 - Submission 不存在", test_submission_callback_not_found())) - results.append(("正式提交 - 缺少必要欄位", test_submission_callback_missing_field())) + # Test 1: 正常的提交 callback (AC) + results.append(test_submission_callback(submission.id, test_case.id)) - # 自定義測試 Callback 測試 - results.append(("自定義測試 - 成功案例", test_custom_test_callback_success(custom_test_id))) - results.append(("自定義測試 - 執行錯誤", test_custom_test_callback_error(custom_test_id))) - results.append(("自定義測試 - 缺少認證", test_custom_test_callback_no_auth(custom_test_id))) + # Test 2: CE 的提交 callback(多筆測資) + results.append(test_submission_callback_with_ce()) - # 測試總結 - print_header("測試總結") + # Test 3: 自定義測試 callback + results.append(test_custom_test_callback()) - passed = sum(1 for _, result in results if result) - total = len(results) + # Test 4: API Key 認證 + results.append(test_api_key_authentication()) - for test_name, result in results: - if result: - print_success(f"{test_name}") - else: - print_error(f"{test_name}") + # Test 5: 多筆測資 + results.append(test_multiple_test_cases()) - print(f"\n{Colors.BOLD}總計: {passed}/{total} 測試通過{Colors.END}") + # 總結 + print_section("測試總結") + passed = sum(results) + total = len(results) + print(f"通過: {passed}/{total}") if passed == total: - print(f"\n{Colors.GREEN}{Colors.BOLD}🎉 所有測試通過!{Colors.END}") + print("\n🎉 所有測試通過!") else: - print(f"\n{Colors.RED}{Colors.BOLD}⚠️ 有 {total - passed} 個測試失敗{Colors.END}") - - print(f"\n{Colors.CYAN}{'=' * 70}{Colors.END}\n") + print(f"\n⚠️ 有 {total - passed} 個測試失敗") -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/submissions/test_file/test_ce_callback.py b/submissions/test_file/test_ce_callback.py new file mode 100644 index 0000000..369785f --- /dev/null +++ b/submissions/test_file/test_ce_callback.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python +""" +測試 Compile Error (CE) Callback + +專門測試 Sandbox 回傳 CE 的情況,包括: +1. 單筆測資 CE +2. 多筆測資 CE +3. test_case_id 為 None 的情況 +4. error_message 的儲存 + +使用方式: + cd /Users/keliangyun/Desktop/software_engineering/back_end + python submissions/test_file/test_ce_callback.py +""" + +import os +import sys +import django +import requests +import json + +# 設定 Django 環境 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'back_end.settings') +django.setup() + +from django.contrib.auth import get_user_model +from submissions.models import Submission, SubmissionResult +from problems.models import Problems +from django.conf import settings + +User = get_user_model() + +# 測試配置 +BACKEND_URL = "http://127.0.0.1:8443" +API_KEY = getattr(settings, 'SANDBOX_API_KEY', 'happylittle7') + + +def print_section(title): + """列印分隔線""" + print("\n" + "=" * 70) + print(f" {title}") + print("=" * 70) + + +def print_result(success, message): + """列印測試結果""" + status = "✓ 成功" if success else "✗ 失敗" + print(f"{status}: {message}") + + +def create_ce_submission(): + """創建一個用於測試 CE 的提交""" + try: + user = User.objects.first() + if not user: + print_result(False, "找不到任何用戶") + return None + + problem = Problems.objects.first() + if not problem: + print_result(False, "找不到任何題目") + return None + + submission = Submission.objects.create( + user=user, + problem_id=problem.id, + language_type=2, # Python + source_code='print(invalid syntax # 故意的語法錯誤', + status='-1', # Pending + score=0 + ) + + print_result(True, f"創建測試提交: {submission.id}") + return submission + + except Exception as e: + print_result(False, f"創建提交失敗: {str(e)}") + return None + + +def test_single_ce(): + """測試單筆測資的 CE""" + print_section("測試 1: 單筆測資 CE") + + submission = create_ce_submission() + if not submission: + return False + + url = f"{BACKEND_URL}/submission/callback/" + + # 單筆 CE 的 payload + payload = { + "submission_id": str(submission.id), + "status": "compile_error", + "score": 0, + "execution_time": 0, + "memory_usage": 0, + "test_results": [ + { + "test_case_id": None, # CE 時沒有 test_case_id + "test_case_index": 1, + "status": "compile_error", + "execution_time": 0, + "memory_usage": 0, + "score": 0, + "max_score": 100, + "error_message": " File \"\", line 1\n print(invalid syntax\n ^\nSyntaxError: invalid syntax" + } + ] + } + + headers = { + "Content-Type": "application/json", + "X-API-KEY": API_KEY + } + + print(f"\n請求 URL: {url}") + print(f"Submission ID: {submission.id}") + print(f"Payload:\n{json.dumps(payload, indent=2, ensure_ascii=False)}") + + try: + response = requests.post(url, json=payload, headers=headers, timeout=10) + print(f"\n狀態碼: {response.status_code}") + print(f"響應: {response.text}") + + if response.status_code == 200: + print_result(True, "單筆 CE Callback 處理成功") + + # 驗證資料庫 + submission.refresh_from_db() + + # 檢查 Submission 狀態 + if submission.status == '2': # CE + print_result(True, f"Submission 狀態: CE ('{submission.status}')") + else: + print_result(False, f"Submission 狀態錯誤: '{submission.status}' (預期 '2')") + + if submission.score == 0: + print_result(True, f"Submission 分數: {submission.score}") + else: + print_result(False, f"Submission 分數錯誤: {submission.score}") + + # 檢查 SubmissionResult + results = SubmissionResult.objects.filter(submission_id=submission.id) + if results.count() == 1: + print_result(True, f"已創建 1 筆 SubmissionResult") + result = results.first() + print(f" - Test Case Index: {result.test_case_index}") + print(f" - Test Case ID: {result.test_case_id}") + print(f" - Status: {result.status}") + print(f" - Score: {result.score}/{result.max_score}") + if result.error_message: + print(f" - Error Message: {result.error_message[:100]}...") + print_result(True, "Error message 已儲存") + else: + print_result(False, "Error message 為空") + + # 檢查 test_case_id 是否為 None + if result.test_case_id is None: + print_result(True, "test_case_id 正確為 None") + else: + print_result(False, f"test_case_id 應為 None,但為 {result.test_case_id}") + else: + print_result(False, f"SubmissionResult 數量錯誤: {results.count()} (預期 1)") + + return True + else: + print_result(False, f"HTTP 狀態碼錯誤: {response.status_code}") + return False + + except Exception as e: + print_result(False, f"請求失敗: {str(e)}") + import traceback + traceback.print_exc() + return False + + +def test_multiple_ce(): + """測試多筆測資的 CE(每筆都是 CE)""" + print_section("測試 2: 多筆測資 CE") + + submission = create_ce_submission() + if not submission: + return False + + url = f"{BACKEND_URL}/submission/callback/" + + # 多筆 CE 的 payload(模擬 5 筆測資都 CE) + test_results = [] + for i in range(1, 6): + test_results.append({ + "test_case_id": None, + "test_case_index": i, + "status": "compile_error", + "execution_time": 0, + "memory_usage": 0, + "score": 0, + "max_score": 20, + "error_message": f" File \"\", line 1\n print(invalid syntax\n ^\nSyntaxError: invalid syntax (Test Case {i})" + }) + + payload = { + "submission_id": str(submission.id), + "status": "compile_error", + "score": 0, + "execution_time": 0, + "memory_usage": 0, + "test_results": test_results + } + + headers = { + "Content-Type": "application/json", + "X-API-KEY": API_KEY + } + + print(f"\n請求 URL: {url}") + print(f"Submission ID: {submission.id}") + print(f"測試 5 筆測資,每筆都是 CE...") + + try: + response = requests.post(url, json=payload, headers=headers, timeout=10) + print(f"\n狀態碼: {response.status_code}") + print(f"響應: {response.text}") + + if response.status_code == 200: + print_result(True, "多筆 CE Callback 處理成功") + + # 驗證資料庫 + submission.refresh_from_db() + + if submission.status == '2': + print_result(True, f"Submission 狀態: CE") + else: + print_result(False, f"Submission 狀態錯誤: {submission.status}") + + # 檢查 SubmissionResult + results = SubmissionResult.objects.filter(submission_id=submission.id).order_by('test_case_index') + if results.count() == 5: + print_result(True, f"已創建 5 筆 SubmissionResult") + all_ce = all(r.status == 'compile_error' for r in results) + if all_ce: + print_result(True, "所有測資狀態都是 compile_error") + else: + print_result(False, "部分測資狀態不是 compile_error") + + all_have_error = all(r.error_message for r in results) + if all_have_error: + print_result(True, "所有測資都有 error_message") + else: + print_result(False, "部分測資沒有 error_message") + + all_none_test_case = all(r.test_case_id is None for r in results) + if all_none_test_case: + print_result(True, "所有測資的 test_case_id 都是 None") + else: + print_result(False, "部分測資的 test_case_id 不是 None") + + print("\n詳細資訊:") + for result in results: + print(f" - Test Case {result.test_case_index}: {result.status}") + print(f" Error: {result.error_message[:60]}...") + else: + print_result(False, f"SubmissionResult 數量錯誤: {results.count()} (預期 5)") + + return True + else: + print_result(False, f"HTTP 狀態碼錯誤: {response.status_code}") + return False + + except Exception as e: + print_result(False, f"請求失敗: {str(e)}") + import traceback + traceback.print_exc() + return False + + +def test_ce_with_long_error_message(): + """測試帶有長 error message 的 CE""" + print_section("測試 3: 長 Error Message 的 CE") + + submission = create_ce_submission() + if not submission: + return False + + url = f"{BACKEND_URL}/submission/callback/" + + # 模擬一個很長的錯誤訊息 + long_error = """Traceback (most recent call last): + File "", line 1, in + File "/usr/lib/python3.11/some_module.py", line 123, in some_function + raise SyntaxError("invalid syntax") +SyntaxError: invalid syntax + +Additional context: +This is a very long error message that contains multiple lines +and detailed information about what went wrong during compilation. +It might include stack traces, line numbers, and other debugging +information that would be useful for the user to understand +what caused the compilation error. + +Error occurred at line 1, column 15 +Expected: expression +Found: invalid token +""" * 5 # 重複 5 次讓它更長 + + payload = { + "submission_id": str(submission.id), + "status": "compile_error", + "score": 0, + "execution_time": 0, + "memory_usage": 0, + "test_results": [ + { + "test_case_id": None, + "test_case_index": 1, + "status": "compile_error", + "execution_time": 0, + "memory_usage": 0, + "score": 0, + "max_score": 100, + "error_message": long_error + } + ] + } + + headers = { + "Content-Type": "application/json", + "X-API-KEY": API_KEY + } + + print(f"\n請求 URL: {url}") + print(f"Submission ID: {submission.id}") + print(f"Error Message 長度: {len(long_error)} 字元") + + try: + response = requests.post(url, json=payload, headers=headers, timeout=10) + print(f"\n狀態碼: {response.status_code}") + + if response.status_code == 200: + print_result(True, "長 Error Message Callback 處理成功") + + # 驗證資料庫 + results = SubmissionResult.objects.filter(submission_id=submission.id) + if results.count() == 1: + result = results.first() + if result.error_message: + stored_length = len(result.error_message) + print_result(True, f"Error message 已儲存 ({stored_length} 字元)") + print(f"原始長度: {len(long_error)} 字元") + print(f"儲存長度: {stored_length} 字元") + + # 檢查是否完整儲存 + if stored_length == len(long_error): + print_result(True, "Error message 完整儲存") + else: + print_result(False, f"Error message 可能被截斷") + else: + print_result(False, "Error message 為空") + + return True + else: + print_result(False, f"HTTP 狀態碼錯誤: {response.status_code}") + return False + + except Exception as e: + print_result(False, f"請求失敗: {str(e)}") + import traceback + traceback.print_exc() + return False + + +def test_duplicate_ce_callback(): + """測試重複呼叫 CE callback(應該 update 而不是 create)""" + print_section("測試 4: 重複 CE Callback (update_or_create 測試)") + + submission = create_ce_submission() + if not submission: + return False + + url = f"{BACKEND_URL}/submission/callback/" + + payload = { + "submission_id": str(submission.id), + "status": "compile_error", + "score": 0, + "execution_time": 0, + "memory_usage": 0, + "test_results": [ + { + "test_case_id": None, + "test_case_index": 1, + "status": "compile_error", + "execution_time": 0, + "memory_usage": 0, + "score": 0, + "max_score": 100, + "error_message": "First error message" + } + ] + } + + headers = { + "Content-Type": "application/json", + "X-API-KEY": API_KEY + } + + print(f"\n請求 URL: {url}") + print(f"Submission ID: {submission.id}") + + try: + # 第一次呼叫 + print("\n第一次呼叫 callback...") + response1 = requests.post(url, json=payload, headers=headers, timeout=10) + print(f"狀態碼: {response1.status_code}") + + if response1.status_code != 200: + print_result(False, "第一次呼叫失敗") + return False + + # 檢查第一次的結果 + results1 = SubmissionResult.objects.filter(submission_id=submission.id) + count1 = results1.count() + print_result(True, f"第一次呼叫後有 {count1} 筆 SubmissionResult") + + # 修改 error message 並第二次呼叫 + payload["test_results"][0]["error_message"] = "Updated error message (second call)" + + print("\n第二次呼叫 callback(應該更新而不是新增)...") + response2 = requests.post(url, json=payload, headers=headers, timeout=10) + print(f"狀態碼: {response2.status_code}") + + if response2.status_code != 200: + print_result(False, "第二次呼叫失敗") + return False + + # 檢查第二次的結果 + results2 = SubmissionResult.objects.filter(submission_id=submission.id) + count2 = results2.count() + + if count2 == count1: + print_result(True, f"第二次呼叫後仍然只有 {count2} 筆 SubmissionResult(沒有重複)") + + # 檢查 error message 是否被更新 + result = results2.first() + if "Updated error message" in result.error_message: + print_result(True, "Error message 已更新") + print(f" 更新後的 message: {result.error_message}") + else: + print_result(False, "Error message 沒有更新") + print(f" 實際 message: {result.error_message}") + else: + print_result(False, f"產生了重複記錄!第一次: {count1},第二次: {count2}") + + return True + + except Exception as e: + print_result(False, f"請求失敗: {str(e)}") + import traceback + traceback.print_exc() + return False + + +def main(): + """主函數""" + print("\n╔" + "=" * 68 + "╗") + print("║" + " " * 15 + "Compile Error (CE) 測試腳本" + " " * 26 + "║") + print("╚" + "=" * 68 + "╝") + + # 確認 Django 伺服器正在運行 + print_section("檢查 Django 伺服器") + try: + response = requests.get(f"{BACKEND_URL}/", timeout=5) + print_result(True, f"Django 伺服器運行中 ({BACKEND_URL})") + except Exception as e: + print_result(False, f"無法連接到 Django 伺服器: {str(e)}") + print("\n請確保 Django 伺服器正在運行:") + print(" python manage.py runserver 0.0.0.0:8443") + return + + # 執行測試 + results = [] + + # Test 1: 單筆 CE + results.append(test_single_ce()) + + # Test 2: 多筆 CE + results.append(test_multiple_ce()) + + # Test 3: 長 error message + results.append(test_ce_with_long_error_message()) + + # Test 4: 重複 callback + results.append(test_duplicate_ce_callback()) + + # 總結 + print_section("測試總結") + passed = sum(results) + total = len(results) + print(f"通過: {passed}/{total}") + + if passed == total: + print("\n🎉 所有 CE 測試通過!") + print("\n重點驗證項目:") + print(" ✓ CE 狀態正確儲存") + print(" ✓ test_case_id 可以是 None") + print(" ✓ error_message 正確儲存") + print(" ✓ 多筆測資 CE 都能處理") + print(" ✓ update_or_create 避免重複記錄") + else: + print(f"\n⚠️ 有 {total - passed} 個測試失敗") + + +if __name__ == '__main__': + main() diff --git a/submissions/views.py b/submissions/views.py index ccf55e4..6e39b7a 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -1950,22 +1950,15 @@ def post(self, request): # 轉換 status 為字串格式(SubmissionResult 使用字串) result_status = test_result.get('status', 'runtime_error') - # 從 test_result 中取得 subtask_no 和 case_no(Sandbox 回傳格式) - subtask_no = test_result.get('subtask_no', 1) # 預設第 1 個 subtask - case_index = test_result.get('case_no', 1) # 預設第 1 個 case + # 從 test_result 中取得 test_case_id 和 test_case_index(按照文件規格) + test_case_id = test_result.get('test_case_id') # 資料庫 ID + test_case_index = test_result.get('test_case_index', 1) # 顯示編號 - # 找到對應的 test_case - test_case_id = None - try: - subtask = subtasks.get(subtask_no=subtask_no) - test_case = Test_cases.objects.get(subtask_id=subtask.id, idx=case_index) - test_case_id = test_case.id - except (Problem_subtasks.DoesNotExist, Test_cases.DoesNotExist): - logger.warning(f'Test case not found: subtask_no={subtask_no}, case_no={case_index}') - # CE 或系統錯誤時,即使找不到 test_case 也要儲存錯誤訊息 - if result_status == 'compile_error' or test_result.get('error_message'): - test_case_id = None # 允許 None - else: + # CE 或系統錯誤時,test_case_id 可能為 None + if test_case_id is None: + logger.warning(f'Test case ID is None for case_index={test_case_index}') + # CE 或有 error_message 時允許 None + if result_status != 'compile_error' and not test_result.get('error_message'): continue # 其他情況跳過 # 使用 update_or_create 避免重複呼叫 callback 時產生重複記錄 @@ -1973,7 +1966,7 @@ def post(self, request): lookup_fields = { 'submission': submission, 'problem_id': submission.problem_id, - 'test_case_index': case_index, + 'test_case_index': test_case_index, } if test_case_id is not None: lookup_fields['test_case_id'] = test_case_id From 63dd77823c94dab6ec22caabf90fd867e97d75f8 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 21:52:41 +0800 Subject: [PATCH 079/113] fix: I add error messenge detail. --- submissions/views.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/submissions/views.py b/submissions/views.py index 6e39b7a..beda3eb 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -1118,10 +1118,18 @@ def submission_output_view(request, id, task_no, case_no): ).order_by('-created_at').first() if not result: - return api_response(None, "output not found", 404) + return api_response( + None, + f"找不到測資結果:submission_id={submission.id}, task_no={task_no}, case_no={case_no}, test_case_id={test_case_id}", + 404 + ) except Exception as e: logger.error(f'Error fetching submission result: {str(e)}') - return api_response(None, "output not found", 404) + return api_response( + None, + f"查詢測資結果時發生錯誤:{str(e)}", + 404 + ) # 6. 回傳 payload = { From 391ec5378a2f563a3f83bc349e62baf6ceab5d50 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 22:34:42 +0800 Subject: [PATCH 080/113] fix: Fix subtask again. --- submissions/views.py | 93 +++++++++++++++++++++++++++----------------- 1 file changed, 57 insertions(+), 36 deletions(-) diff --git a/submissions/views.py b/submissions/views.py index beda3eb..33bc9e1 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -1950,48 +1950,69 @@ def post(self, request): logger.info(f'Updated submission {submission_id}: status={submission.status}, score={total_score}') # 4. 建立 SubmissionResult 記錄 - # 先取得所有 subtasks 和 test_cases 的對應關係 from problems.models import Problem_subtasks, Test_cases - subtasks = Problem_subtasks.objects.filter(problem_id=submission.problem_id).order_by('subtask_no') - for test_result in test_results: - # 轉換 status 為字串格式(SubmissionResult 使用字串) - result_status = test_result.get('status', 'runtime_error') + # 特殊處理:如果是 CE/SE 且 test_results 數量不足,為所有測資創建相同的錯誤記錄 + if judge_status in ['compile_error', 'system_error']: + # 取得該題目的所有測資 + all_test_cases = [] + subtasks = Problem_subtasks.objects.filter(problem_id=submission.problem_id).order_by('subtask_no') + for subtask in subtasks: + test_cases = Test_cases.objects.filter(subtask_id=subtask.id).order_by('idx') + all_test_cases.extend(test_cases) - # 從 test_result 中取得 test_case_id 和 test_case_index(按照文件規格) - test_case_id = test_result.get('test_case_id') # 資料庫 ID - test_case_index = test_result.get('test_case_index', 1) # 顯示編號 + # 取得錯誤訊息(從第一筆 test_result 或使用預設值) + error_message = data.get('error_message', f'{judge_status.replace("_", " ").title()}') + if test_results and len(test_results) > 0: + error_message = test_results[0].get('error_message', error_message) - # CE 或系統錯誤時,test_case_id 可能為 None - if test_case_id is None: - logger.warning(f'Test case ID is None for case_index={test_case_index}') - # CE 或有 error_message 時允許 None - if result_status != 'compile_error' and not test_result.get('error_message'): - continue # 其他情況跳過 + # 先刪除可能存在的舊記錄 + SubmissionResult.objects.filter(submission=submission).delete() - # 使用 update_or_create 避免重複呼叫 callback 時產生重複記錄 - # 當 test_case_id 為 None 時(CE 情況),用 test_case_index 區分不同測資 - lookup_fields = { - 'submission': submission, - 'problem_id': submission.problem_id, - 'test_case_index': test_case_index, - } - if test_case_id is not None: - lookup_fields['test_case_id'] = test_case_id + # 為每個測資創建相同的錯誤記錄 + for test_case in all_test_cases: + SubmissionResult.objects.create( + submission=submission, + problem_id=submission.problem_id, + test_case_id=None, # CE/SE 時不關聯具體測資 + test_case_index=test_case.idx, + status=judge_status, + execution_time=0, + memory_usage=0, + score=0, + max_score=100, + error_message=error_message, + ) - SubmissionResult.objects.update_or_create( - **lookup_fields, - defaults={ - 'status': result_status, - 'execution_time': test_result.get('execution_time'), - 'memory_usage': test_result.get('memory_usage'), - 'score': test_result.get('score', 0), - 'max_score': test_result.get('max_score', 100), - 'error_message': test_result.get('error_message'), - } - ) - - logger.info(f'Created {len(test_results)} test results for submission {submission_id}') + logger.info(f'Created {len(all_test_cases)} {judge_status} results for all test cases of submission {submission_id}') + else: + # 正常情況:處理每個測資結果 + # 先刪除可能存在的舊記錄 + SubmissionResult.objects.filter(submission=submission).delete() + + for test_result in test_results: + # 轉換 status 為字串格式(SubmissionResult 使用字串) + result_status = test_result.get('status', 'runtime_error') + + # 從 test_result 中取得 test_case_id 和 test_case_index(按照文件規格) + test_case_id = test_result.get('test_case_id') # 資料庫 ID + test_case_index = test_result.get('test_case_index', 1) # 顯示編號 + + # 創建新記錄 + SubmissionResult.objects.create( + submission=submission, + problem_id=submission.problem_id, + test_case_id=test_case_id, + test_case_index=test_case_index, + status=result_status, + execution_time=test_result.get('execution_time', 0), + memory_usage=test_result.get('memory_usage', 0), + score=test_result.get('score', 0), + max_score=test_result.get('max_score', 100), + error_message=test_result.get('error_message'), + ) + + logger.info(f'Created {len(test_results)} test results for submission {submission_id}') # 5. 更新 UserProblemSolveStatus(全域層級) update_user_problem_stats(submission) From 88128d305d814512165c12cd4da137c9515b8666 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 22:38:15 +0800 Subject: [PATCH 081/113] fix: I add more error messages. --- submissions/views.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/submissions/views.py b/submissions/views.py index 33bc9e1..54ad93f 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -1118,9 +1118,27 @@ def submission_output_view(request, id, task_no, case_no): ).order_by('-created_at').first() if not result: + # 提供詳細的診斷資訊 + all_results = SubmissionResult.objects.filter(submission_id=submission.id) + total_results = all_results.count() + + # 列出該 submission 所有現有的測資結果 + existing_cases = [] + for r in all_results[:10]: # 最多顯示 10 筆 + existing_cases.append(f"case_index={r.test_case_index}(test_case_id={r.test_case_id})") + + error_detail = ( + f"找不到測資結果。" + f"查詢參數: submission_id={submission.id}, task_no={task_no}, case_no={case_no}, " + f"test_case_id={test_case_id}. " + f"該 submission 共有 {total_results} 筆測資結果" + f"{': ' + ', '.join(existing_cases) if existing_cases else '(無任何測資結果)'}. " + f"可能原因: 1) 判題尚未完成 2) test_case_id 不匹配 3) case_no 超出範圍" + ) + return api_response( None, - f"找不到測資結果:submission_id={submission.id}, task_no={task_no}, case_no={case_no}, test_case_id={test_case_id}", + error_detail, 404 ) except Exception as e: From c95644b88184a97be7cc38e6d2f312a101de5c80 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 23:02:53 +0800 Subject: [PATCH 082/113] fix: I modify SubmissionResult PK. --- ...alter_submissionresult_options_and_more.py | 40 +++++++++ submissions/models.py | 16 ++-- submissions/serializers.py | 2 +- submissions/views.py | 85 ++++++++++--------- 4 files changed, 93 insertions(+), 50 deletions(-) create mode 100644 submissions/migrations/0005_alter_submissionresult_options_and_more.py diff --git a/submissions/migrations/0005_alter_submissionresult_options_and_more.py b/submissions/migrations/0005_alter_submissionresult_options_and_more.py new file mode 100644 index 0000000..5689dd5 --- /dev/null +++ b/submissions/migrations/0005_alter_submissionresult_options_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.7 on 2025-12-28 15:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0004_alter_submissionresult_status_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='submissionresult', + options={'ordering': ['subtask_id', 'test_case_index']}, + ), + migrations.RemoveIndex( + model_name='submissionresult', + name='submission__submiss_cb29e7_idx', + ), + migrations.AddField( + model_name='submissionresult', + name='subtask_id', + field=models.IntegerField(default=0), + preserve_default=False, + ), + migrations.AlterField( + model_name='submissionresult', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterUniqueTogether( + name='submissionresult', + unique_together={('submission', 'subtask_id', 'test_case_index')}, + ), + migrations.AddIndex( + model_name='submissionresult', + index=models.Index(fields=['submission', 'subtask_id', 'test_case_index'], name='submission__submiss_37b35f_idx'), + ), + ] diff --git a/submissions/models.py b/submissions/models.py index 1b9c264..0475068 100644 --- a/submissions/models.py +++ b/submissions/models.py @@ -120,14 +120,14 @@ class SubmissionResult(models.Model): ('solved', 'Solved'), ] - # Primary key - UUID - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + # Foreign keys - 複合主鍵的一部分 + submission = models.ForeignKey(Submission, on_delete=models.CASCADE, related_name='results') + subtask_id = models.IntegerField() # 複合主鍵:第幾個子題 + test_case_index = models.IntegerField() # 複合主鍵:第幾筆測資(相對於 subtask) - # Foreign keys + # Other foreign keys problem_id = models.IntegerField() - submission = models.ForeignKey(Submission, on_delete=models.CASCADE, related_name='results') test_case_id = models.IntegerField(null=True, blank=True) # CE 時可能為 None - test_case_index = models.IntegerField() # Status and scoring status = models.CharField(max_length=30, choices=STATUS_CHOICES) @@ -150,10 +150,12 @@ class SubmissionResult(models.Model): created_at = models.DateTimeField(auto_now_add=True) class Meta: + # 複合主鍵:submission + subtask_id + test_case_index + unique_together = [['submission', 'subtask_id', 'test_case_index']] indexes = [ - models.Index(fields=['submission', 'test_case_index']), + models.Index(fields=['submission', 'subtask_id', 'test_case_index']), ] - ordering = ['test_case_index'] + ordering = ['subtask_id', 'test_case_index'] db_table = 'submission_results' def __str__(self): diff --git a/submissions/serializers.py b/submissions/serializers.py index 8dff035..8d44b1c 100644 --- a/submissions/serializers.py +++ b/submissions/serializers.py @@ -151,7 +151,7 @@ class SubmissionResultSerializer(serializers.ModelSerializer): class Meta: model = SubmissionResult fields = '__all__' - read_only_fields = ['id', 'created_at'] + read_only_fields = ['created_at'] # 移除 'id',因為不再有單一主鍵 class UserProblemStatsSerializer(serializers.ModelSerializer): diff --git a/submissions/views.py b/submissions/views.py index 54ad93f..4e048db 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -1090,6 +1090,7 @@ def submission_output_view(request, id, task_no, case_no): return api_response(None, "task_no not found", 404) # 4. 找 test_case(task_no + case_no) + # 注意:我們嘗試找 test_case 來驗證,但實際查詢 SubmissionResult 時主要用 test_case_index test_case = None test_case_id = None try: @@ -1100,22 +1101,16 @@ def submission_output_view(request, id, task_no, case_no): test_case_id = test_case.id except Test_cases.DoesNotExist: logger.warning(f'Test case not found: subtask_id={subtask.id}, idx={case_no}') - # CE 等錯誤可能沒有對應的 test_case,繼續嘗試用其他方式查找 + # 即使找不到 test_case,也繼續用 test_case_index 查詢 # 5. 找結果 + # 重要:查詢需要 subtask_id + test_case_index(因為 test_case_index 只是相對於 subtask 的編號) try: - if test_case_id: - # 優先用 test_case_id 查找(取最新的結果) - result = SubmissionResult.objects.filter( - submission_id=submission.id, - test_case_id=test_case_id - ).order_by('-created_at').first() - else: - # 如果沒有 test_case_id(例如 CE),用 test_case_index 查找 - result = SubmissionResult.objects.filter( - submission_id=submission.id, - test_case_index=case_no - ).order_by('-created_at').first() + result = SubmissionResult.objects.filter( + submission_id=submission.id, + subtask_id=subtask.id, + test_case_index=case_no + ).order_by('-created_at').first() if not result: # 提供詳細的診斷資訊 @@ -1984,50 +1979,56 @@ def post(self, request): if test_results and len(test_results) > 0: error_message = test_results[0].get('error_message', error_message) - # 先刪除可能存在的舊記錄 - SubmissionResult.objects.filter(submission=submission).delete() - - # 為每個測資創建相同的錯誤記錄 + # 為每個測資創建或更新錯誤記錄 for test_case in all_test_cases: - SubmissionResult.objects.create( + SubmissionResult.objects.update_or_create( submission=submission, - problem_id=submission.problem_id, - test_case_id=None, # CE/SE 時不關聯具體測資 + subtask_id=test_case.subtask_id.id, test_case_index=test_case.idx, - status=judge_status, - execution_time=0, - memory_usage=0, - score=0, - max_score=100, - error_message=error_message, + defaults={ + 'problem_id': submission.problem_id, + 'test_case_id': None, # CE/SE 時不關聯具體測資 + 'status': judge_status, + 'execution_time': 0, + 'memory_usage': 0, + 'score': 0, + 'max_score': 100, + 'error_message': error_message, + } ) logger.info(f'Created {len(all_test_cases)} {judge_status} results for all test cases of submission {submission_id}') else: # 正常情況:處理每個測資結果 - # 先刪除可能存在的舊記錄 - SubmissionResult.objects.filter(submission=submission).delete() - for test_result in test_results: # 轉換 status 為字串格式(SubmissionResult 使用字串) result_status = test_result.get('status', 'runtime_error') - # 從 test_result 中取得 test_case_id 和 test_case_index(按照文件規格) - test_case_id = test_result.get('test_case_id') # 資料庫 ID - test_case_index = test_result.get('test_case_index', 1) # 顯示編號 + # 重要:Sandbox 文件中的欄位映射 + # - Sandbox 的 test_case_id → Backend 的 subtask_id(subtask 的資料庫 ID) + # - Sandbox 的 test_case_index → Backend 的 test_case_index(測資編號) + subtask_id = test_result.get('test_case_id') # Sandbox 用這個欄位傳 subtask ID + test_case_index = test_result.get('test_case_index', 1) # 測資編號 + + if not subtask_id: + logger.warning(f'Missing subtask_id (test_case_id) in test_result') + continue - # 創建新記錄 - SubmissionResult.objects.create( + # 創建或更新記錄 + SubmissionResult.objects.update_or_create( submission=submission, - problem_id=submission.problem_id, - test_case_id=test_case_id, + subtask_id=subtask_id, test_case_index=test_case_index, - status=result_status, - execution_time=test_result.get('execution_time', 0), - memory_usage=test_result.get('memory_usage', 0), - score=test_result.get('score', 0), - max_score=test_result.get('max_score', 100), - error_message=test_result.get('error_message'), + defaults={ + 'problem_id': submission.problem_id, + 'test_case_id': None, # 實際的 test_case_id 不使用 + 'status': result_status, + 'execution_time': test_result.get('execution_time', 0), + 'memory_usage': test_result.get('memory_usage', 0), + 'score': test_result.get('score', 0), + 'max_score': test_result.get('max_score', 100), + 'error_message': test_result.get('error_message'), + } ) logger.info(f'Created {len(test_results)} test results for submission {submission_id}') From c6ea49c38af5ede8906fbebf90ac881202bc57de Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 23:08:14 +0800 Subject: [PATCH 083/113] fix: I fix seed_data. --- user/management/commands/seed_data.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/user/management/commands/seed_data.py b/user/management/commands/seed_data.py index 94b8a17..c19e07e 100644 --- a/user/management/commands/seed_data.py +++ b/user/management/commands/seed_data.py @@ -767,8 +767,9 @@ def create_submission_results(self, submission, problem, overall_status): SubmissionResult.objects.create( problem_id=problem.id, submission=submission, + subtask_id=test_case.subtask_id.id, # 必填:subtask 的資料庫 ID test_case_id=test_case.id, - test_case_index=idx, + test_case_index=test_case.idx, # 使用實際的 test_case.idx 而不是 enumerate 的 idx status=tc_status, execution_time=random.randint(10, 500) if tc_status != 'time_limit_exceeded' else random.randint(1000, 3000), memory_usage=random.randint(256000, 512000) if tc_status == 'memory_limit_exceeded' else random.randint(1000, 30000), From f4d71027dab455c9a6af1fc42855fbf63f5c4c9f Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 23:17:21 +0800 Subject: [PATCH 084/113] fix: Add more Error message. --- submissions/views.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/submissions/views.py b/submissions/views.py index 4e048db..3abcfc0 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -1120,15 +1120,14 @@ def submission_output_view(request, id, task_no, case_no): # 列出該 submission 所有現有的測資結果 existing_cases = [] for r in all_results[:10]: # 最多顯示 10 筆 - existing_cases.append(f"case_index={r.test_case_index}(test_case_id={r.test_case_id})") + existing_cases.append(f"subtask_id={r.subtask_id}, case_index={r.test_case_index}") error_detail = ( f"找不到測資結果。" - f"查詢參數: submission_id={submission.id}, task_no={task_no}, case_no={case_no}, " - f"test_case_id={test_case_id}. " + f"查詢條件: submission_id={submission.id}, subtask_id={subtask.id}, case_no={case_no}. " f"該 submission 共有 {total_results} 筆測資結果" f"{': ' + ', '.join(existing_cases) if existing_cases else '(無任何測資結果)'}. " - f"可能原因: 1) 判題尚未完成 2) test_case_id 不匹配 3) case_no 超出範圍" + f"可能原因: 1) 判題尚未完成 2) subtask_id 不匹配 3) case_no 超出範圍" ) return api_response( From b11bd78c8c29fa1e1561fafe4dc29bc47d2751ac Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Sun, 28 Dec 2025 23:19:07 +0800 Subject: [PATCH 085/113] fix: I fix AC or orther status cannot work problem. --- submissions/views.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/submissions/views.py b/submissions/views.py index 3abcfc0..824b44a 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -2004,13 +2004,24 @@ def post(self, request): result_status = test_result.get('status', 'runtime_error') # 重要:Sandbox 文件中的欄位映射 - # - Sandbox 的 test_case_id → Backend 的 subtask_id(subtask 的資料庫 ID) - # - Sandbox 的 test_case_index → Backend 的 test_case_index(測資編號) - subtask_id = test_result.get('test_case_id') # Sandbox 用這個欄位傳 subtask ID + # - Sandbox 的 test_case_id → 實際是 subtask_no(第幾個 subtask) + # - Sandbox 的 test_case_index → 測資編號(相對於 subtask) + subtask_no = test_result.get('test_case_id') # Sandbox 用這個欄位傳 subtask 編號 test_case_index = test_result.get('test_case_index', 1) # 測資編號 - if not subtask_id: - logger.warning(f'Missing subtask_id (test_case_id) in test_result') + if not subtask_no: + logger.warning(f'Missing subtask_no (test_case_id) in test_result') + continue + + # 根據 subtask_no 找到實際的 subtask_id + try: + subtask = Problem_subtasks.objects.get( + problem_id=submission.problem_id, + subtask_no=subtask_no + ) + subtask_id = subtask.id + except Problem_subtasks.DoesNotExist: + logger.warning(f'Subtask not found: problem_id={submission.problem_id}, subtask_no={subtask_no}') continue # 創建或更新記錄 From a5416bdc2508f8ac1beab6978d9b3b12fbbd0281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=A5=95=E5=A9=B7?= Date: Sun, 28 Dec 2025 23:21:18 +0800 Subject: [PATCH 086/113] fix: adjust homework stats api state structure --- assignments/serializers.py | 39 +++++ assignments/views.py | 325 +++++++++++++------------------------ docs/assignments.MD | 101 ++++++------ 3 files changed, 204 insertions(+), 261 deletions(-) diff --git a/assignments/serializers.py b/assignments/serializers.py index cf6e50e..a4ada20 100644 --- a/assignments/serializers.py +++ b/assignments/serializers.py @@ -294,3 +294,42 @@ class Meta: "status_display", ] read_only_fields = fields +class AcSubmissionRatioSerializer(serializers.Serializer): + ac = serializers.IntegerField() + tried = serializers.IntegerField() + + +class HomeworkProblemStateSerializer(serializers.Serializer): + """ + 單一題目的 state(不含 top10) + """ + problemId = serializers.IntegerField() + numUsersTried = serializers.IntegerField() + numAcUsers = serializers.IntegerField() + acSubmissionRatio = AcSubmissionRatioSerializer() + averageScore = serializers.FloatField() + standardDeviation = serializers.FloatField() + + +class HomeworkStatsPageSerializer(serializers.Serializer): + """ + GET /homework/{id}/stats 用 + 對應你前端 stats 頁面需要的結構 + """ + name = serializers.CharField() + markdown = serializers.CharField(allow_blank=True) + start = serializers.IntegerField(allow_null=True) + end = serializers.IntegerField(allow_null=True) + penalty = serializers.CharField(allow_blank=True) + + problemIds = serializers.ListField( + child=serializers.IntegerField() + ) + + # stats 頁目前不一定用到,但欄位要存在 + studentStatus = serializers.DictField() + + # key 是 problemId(字串),value 是 HomeworkProblemState + state = serializers.DictField( + child=HomeworkProblemStateSerializer() + ) \ No newline at end of file diff --git a/assignments/views.py b/assignments/views.py index 24bc149..716a65b 100644 --- a/assignments/views.py +++ b/assignments/views.py @@ -7,13 +7,14 @@ from rest_framework import status, permissions from django.db.models import Max,Sum from django.db.models import Sum, Count, Max, Q -from decimal import Decimal, InvalidOperation +from decimal import Decimal from django.shortcuts import get_object_or_404 from django.utils.dateparse import parse_datetime from django.utils import timezone from collections import defaultdict from .serializers import to_epoch_from_dt import datetime +import math from assignments.models import Assignments, Assignment_problems from courses.models import Courses, Course_members @@ -30,6 +31,9 @@ HomeworkDeadlineSerializer, HomeworkStatsSerializer, HomeworkScoreboardSerializer, + AcSubmissionRatioSerializer, + HomeworkProblemStateSerializer, + HomeworkStatsPageSerializer, ) # --------- 權限 & 工具 --------- @@ -67,6 +71,12 @@ def require_course_member_or_staff(request_user, course): message="permission denied: user is not a member of this course", status_code=status.HTTP_403_FORBIDDEN, ) +def pop_std(xs: list[float]) -> float: + if not xs: + return 0.0 + mean = sum(xs) / len(xs) + var = sum((x - mean) ** 2 for x in xs) / len(xs) # population variance + return math.sqrt(var) # --------- 統一回傳格式 --------- def api_response(data=None, message="OK", status_code=200): @@ -316,7 +326,7 @@ def put(self, request, homework_id: int): else: try: val = Decimal(raw) - except (TypeError, ValueError, InvalidOperation): + except Exception: return api_response( data=None, message="penalty must be a number string (0~100)", @@ -330,11 +340,23 @@ def put(self, request, homework_id: int): ) hw.late_penalty = val - # max_attempts 從 serializer validated_data 讀取 - if "max_attempts" in vd: - ma = vd.get("max_attempts") - if ma is not None: - hw.max_attempts = ma + # max_attempts(如果你想支援就從 request.data 讀;你 serializer 沒這欄) + if "max_attempts" in request.data: + try: + ma = int(request.data.get("max_attempts")) + except Exception: + return api_response( + data=None, + message="max_attempts must be int (-1 or >=1)", + status_code=status.HTTP_400_BAD_REQUEST, + ) + if ma != -1 and ma < 1: + return api_response( + data=None, + message="max_attempts must be -1 or >=1", + status_code=status.HTTP_400_BAD_REQUEST, + ) + hw.max_attempts = ma hw.save() @@ -356,12 +378,11 @@ def put(self, request, homework_id: int): "name": hw.title, "start": to_epoch_from_dt(hw.start_time), "end": to_epoch_from_dt(hw.due_time), - "problemIds": collect_problem_ids(hw), + "problem_ids": collect_problem_ids(hw), "markdown": hw.description or "", - "latePenalty": str(hw.late_penalty), # 文件要寫清楚:字串/數字 - "maxAttempts": hw.max_attempts, + "late_penalty": str(hw.late_penalty), + "max_attempts": hw.max_attempts, } - return api_response( data=payload, message="update homework", @@ -666,49 +687,6 @@ def get(self, request, homework_id: int): ) # --------- NEW: GET /homework//stats --------- class HomeworkStatsView(APIView): - """ - GET /homework/{homework_id}/stats - 取得指定作業的統計資訊(只有課程教師或 TA 可看) - - 回傳格式(包在 api_response 的 data 裡): - - { - "homework_id": 1, - "course_id": 3, - "title": "HW1", - "description": "...", - "start_time": "...", - "due_time": "...", - "problem_count": 3, - "overview": { - "participant_count": 25, - "total_submission_count": 120, - "avg_submissions_per_user": 4.8, - "avg_total_score": 230.5, - "max_total_score": 300, - "solved_all_problem_user_count": 8, - "partially_solved_user_count": 12, - "unsolved_all_problem_user_count": 5 - }, - "problems": [ - { - "problem_id": 101, - "order_index": 1, - "weight": "1.00", - "title": "A - Two Sum", - "participant_count": 20, - "total_submission_count": 80, - "avg_attempts_per_user": 4.0, - "avg_score": 75.5, - "ac_user_count": 10, - "partial_user_count": 5, - "unsolved_user_count": 5, - "first_ac_time": "2025-11-02T12:34:56Z" - } - ] - } - """ - def get(self, request, homework_id: int): # 1) 找作業 try: @@ -720,7 +698,7 @@ def get(self, request, homework_id: int): status_code=status.HTTP_404_NOT_FOUND, ) - # 2) 權限檢查:沿用你原本的 is_teacher_or_ta + # 2) 權限:老師/TA if not is_teacher_or_ta(request.user, hw.course): return api_response( data=None, @@ -728,181 +706,100 @@ def get(self, request, homework_id: int): status_code=status.HTTP_403_FORBIDDEN, ) - # 3) 抓作業題目列表 - problems_qs = Assignment_problems.objects.filter( - assignment=hw, - is_active=True, - ).select_related("problem") - - problems = list(problems_qs) - problem_ids = [p.problem_id for p in problems] + # 3) 作業題目(依 order_index 排序) + aps = list( + Assignment_problems.objects.filter(assignment=hw, is_active=True) + .select_related("problem") + .order_by("order_index", "id") + ) + problem_ids = [ap.problem_id for ap in aps] - # 如果沒有題目 → 回傳空統計 - if not problems: + # 4) 沒題目:仍回前端能吃的結構(state 空) + if not problem_ids: payload = { - "homework_id": hw.id, - "course_id": hw.course_id, - "title": hw.title, - "description": hw.description, - "start_time": hw.start_time, - "due_time": hw.due_time, - "problem_count": 0, - "overview": { - "participant_count": 0, - "total_submission_count": 0, - "avg_submissions_per_user": 0.0, - "avg_total_score": 0.0, - "max_total_score": 0, - "solved_all_problem_user_count": 0, - "partially_solved_user_count": 0, - "unsolved_all_problem_user_count": 0, - }, - "problems": [], + "name": hw.title, + "markdown": hw.description or "", + "start": to_epoch_from_dt(hw.start_time), + "end": to_epoch_from_dt(hw.due_time), + "penalty": "", + "problemIds": [], + "studentStatus": {}, + "state": {}, } - serializer = HomeworkStatsSerializer(payload) return api_response( - data=serializer.data, - message="取得作業統計成功(目前作業沒有題目)", + data=payload, + message="get homework stats", status_code=status.HTTP_200_OK, ) - # 4) 用 UserProblemStats 當統計基礎 - stats_qs = UserProblemStats.objects.filter( - assignment_id=hw.id, - problem_id__in=problem_ids, - ).select_related("user", "best_submission") - - # 4-1) 整體 overview - participant_count = stats_qs.values("user_id").distinct().count() - total_submission_count = ( - stats_qs.aggregate(total=Sum("total_submissions"))["total"] or 0 - ) - - from collections import defaultdict - user_summary = defaultdict( - lambda: { - "total_score": 0, - "max_total": 0, - "solved_count": 0, - } - ) - - for s in stats_qs: - u = s.user_id - user_summary[u]["total_score"] += s.best_score - user_summary[u]["max_total"] += s.max_possible_score - if s.solve_status == "solved": - user_summary[u]["solved_count"] += 1 - - user_count = len(user_summary) - if user_count > 0: - avg_total_score = sum( - v["total_score"] for v in user_summary.values() - ) / user_count - else: - avg_total_score = 0.0 - - total_problems = len(problems) - max_total_score = 100 * total_problems # 先假設每題滿分 100 - - solved_all_problem_user_count = 0 - partially_solved_user_count = 0 - unsolved_all_problem_user_count = 0 - - if total_problems > 0: - for v in user_summary.values(): - if v["solved_count"] == 0: - unsolved_all_problem_user_count += 1 - elif v["solved_count"] == total_problems: - solved_all_problem_user_count += 1 - else: - partially_solved_user_count += 1 - - avg_submissions_per_user = ( - float(total_submission_count) / participant_count - if participant_count > 0 - else 0.0 + # 5) 統計來源:UserProblemStats(每人每題一筆) + stats_qs = list( + UserProblemStats.objects.filter( + assignment_id=hw.id, + problem_id__in=problem_ids, + ).only( + "user_id", + "problem_id", + "best_score", + "solve_status", + "total_submissions", + ) ) - overview = { - "participant_count": participant_count, - "total_submission_count": total_submission_count, - "avg_submissions_per_user": avg_submissions_per_user, - "avg_total_score": avg_total_score, - "max_total_score": max_total_score, - "solved_all_problem_user_count": solved_all_problem_user_count, - "partially_solved_user_count": partially_solved_user_count, - "unsolved_all_problem_user_count": unsolved_all_problem_user_count, - } - - # 4-2) 每題的統計 - stats_by_problem = {p.problem_id: [] for p in problems} + # 6) 依題目分組 + by_pid: dict[int, list[UserProblemStats]] = {pid: [] for pid in problem_ids} for s in stats_qs: - stats_by_problem[s.problem_id].append(s) - - problem_stats_list = [] - for ap in problems: - s_list = stats_by_problem.get(ap.problem_id, []) - - participant_ids_p = {s.user_id for s in s_list} - participant_count_p = len(participant_ids_p) - total_submission_count_p = sum(s.total_submissions for s in s_list) - - if participant_count_p > 0: - avg_attempts_per_user = ( - float(total_submission_count_p) / participant_count_p - ) - else: - avg_attempts_per_user = 0.0 - - if s_list: - avg_score_p = sum(s.best_score for s in s_list) / len(s_list) - else: - avg_score_p = 0.0 - - ac_user_count = sum(1 for s in s_list if s.solve_status == "solved") - partial_user_count = sum(1 for s in s_list if s.solve_status == "partial") - unsolved_user_count = sum(1 for s in s_list if s.solve_status == "unsolved") + by_pid.setdefault(s.problem_id, []).append(s) + + # 7) 組 state(每題) + state = {} + for ap in aps: + pid = ap.problem_id + s_list = by_pid.get(pid, []) + + # tried users:有 stats 的人數(通常代表有至少一次提交) + tried_user_ids = {s.user_id for s in s_list} + num_users_tried = len(tried_user_ids) + + # AC users:solve_status == solved + num_ac_users = sum(1 for s in s_list if s.solve_status == "solved") + + # 分數用 best_score + scores = [float(s.best_score or 0) for s in s_list] + avg_score = (sum(scores) / len(scores)) if scores else 0.0 + std_score = pop_std(scores) + + # 你目前沒有「AC submissions」明細,先用 AC users / tried users 給前端顯示 + state[str(pid)] = { + "problemId": pid, + "numUsersTried": num_users_tried, + "numAcUsers": num_ac_users, + "acSubmissionRatio": { + "ac": num_ac_users, + "tried": num_users_tried, + }, + "averageScore": round(avg_score, 2), + "standardDeviation": round(std_score, 2), + } - first_ac_time = min( - (s.first_ac_time for s in s_list if s.first_ac_time is not None), - default=None, - ) + # 8) 最終 payload:沿用前端吃的 homework detail 欄位 + state + payload = { + "name": hw.title, + "markdown": hw.description or "", + "start": to_epoch_from_dt(hw.start_time), + "end": to_epoch_from_dt(hw.due_time), + "penalty": "", + "problemIds": problem_ids, - problem_stats_list.append( - { - "problem_id": ap.problem_id, - "order_index": ap.order_index, - "weight": ap.weight, - "title": str(ap.problem), - "participant_count": participant_count_p, - "total_submission_count": total_submission_count_p, - "avg_attempts_per_user": avg_attempts_per_user, - "avg_score": avg_score_p, - "ac_user_count": ac_user_count, - "partial_user_count": partial_user_count, - "unsolved_user_count": unsolved_user_count, - "first_ac_time": first_ac_time, - } - ) + # stats 頁若不需要 studentStatus,就留空避免爆大包 + "studentStatus": {}, - # 5) 組 payload → serializer 驗證 → api_response - payload = { - "homework_id": hw.id, - "course_id": hw.course_id, - "title": hw.title, - "description": hw.description, - "start_time": hw.start_time, - "due_time": hw.due_time, - "problem_count": len(problems), - "overview": overview, - "problems": problem_stats_list, + "state": state, } - - serializer = HomeworkStatsSerializer(payload) + serializer = HomeworkStatsPageSerializer(payload) return api_response( data=serializer.data, - message="取得作業統計成功", + message="get homework stats", status_code=status.HTTP_200_OK, ) # --------- GET /homework//scoreboard --------- diff --git a/docs/assignments.MD b/docs/assignments.MD index 2fe1ca4..fd22ad2 100644 --- a/docs/assignments.MD +++ b/docs/assignments.MD @@ -587,22 +587,29 @@ cURL 範例(僅針對本 API) curl -X GET "http://127.0.0.1:8000/homework/1/submissions" -H "Authorization: Bearer " -H "Accept: application/json" ``` -**路徑:** +# 作業統計 API:GET /homework//stats/ + +本端點回傳指定作業的**整體統計(overview)**與**各題彙總統計(problems)**。 + +> 回傳格式採統一 `api_response` 包裝:`{ data, message, status }` + +--- + +## 路徑 `GET /homework//stats/` -**說明:** -回傳指定作業的整體統計資訊(overview)與各題目的解題統計(problems)。 +--- + +## 權限 -**權限:** -需登入,且必須為該課程的老師或助教。 +- 需登入 +- 且必須為該課程的**老師或助教(TA)** --- ## 回傳資料結構(data) -以下為 `data` 的完整結構: - ```json { "homework_id": 1, @@ -621,17 +628,17 @@ curl -X GET "http://127.0.0.1:8000/homework/1/submissions" -H "Authorization: ## data:作業基本資訊欄位 -| 欄位 | 型別 | 說明 | -| -------------- | --------- | -------------------------------------- | -| homework_id | int | 作業 ID | -| course_id | uuid | 所屬課程 ID | -| title | string | 作業標題 | -| description | string | 作業說明 | -| start_time | string | 開始時間(ISO 8601) | -| due_time | string | 截止時間(ISO 8601) | -| problem_count | int | 題目數 | -| overview | object | 作業整體統計 | -| problems | array | 各題目的統計資訊 | +| 欄位 | 型別 | 說明 | +|---|---|---| +| homework_id | int | 作業 ID(Assignments.id) | +| course_id | uuid | 所屬課程 ID(Assignments.course_id) | +| title | string | 作業標題(Assignments.title) | +| description | string / null | 作業說明(Assignments.description) | +| start_time | string / null | 開始時間(ISO 8601) | +| due_time | string / null | 截止時間(ISO 8601) | +| problem_count | int | 題目數(啟用題目數量) | +| overview | object | 作業整體統計 | +| problems | array | 各題目的統計資訊 | --- @@ -650,16 +657,16 @@ curl -X GET "http://127.0.0.1:8000/homework/1/submissions" -H "Authorization: } ``` -| 欄位 | 型別 | 說明 | -| ---------------------------------- | ------ | ------------------------------------------------------------ | -| participant_count | int | 至少提交過一次作業內任一題的學生數 | -| total_submission_count | int | 全作業 submission 數總和 | -| avg_submissions_per_user | float | 平均每人提交筆數 | -| avg_total_score | float | 平均每位學生(所有題目的 best_score 相加)之總分 | -| max_total_score | int | 作業最大可能得分(題目數 × 100) | -| solved_all_problem_user_count | int | 全題 AC 的使用者數 | -| partially_solved_user_count | int | 部分 AC 的使用者數 | -| unsolved_all_problem_user_count | int | 全部未 AC 的使用者數 | +| 欄位 | 型別 | 說明 | +|---|---|---| +| participant_count | int | 至少提交過一次作業內任一題的使用者數 | +| total_submission_count | int | 全作業 submission 數總和(各題 total_submissions 加總) | +| avg_submissions_per_user | float | 平均每人提交筆數(total_submission_count / participant_count) | +| avg_total_score | float | 平均每位使用者的總分(各題 best_score 加總後再取平均) | +| max_total_score | int | 作業理論滿分(預設:題目數 × 100;若未來改為權重/題目滿分,需同步調整) | +| solved_all_problem_user_count | int | 全題皆 solved 的使用者數 | +| partially_solved_user_count | int | solved 題數介於 1 ~ (題目數-1) 的使用者數 | +| unsolved_all_problem_user_count | int | solved 題數為 0 的使用者數 | --- @@ -684,20 +691,20 @@ curl -X GET "http://127.0.0.1:8000/homework/1/submissions" -H "Authorization: } ``` -| 欄位 | 型別 | 說明 | -| ----------------------- | ----------- | ------------------------------------------------------------ | -| problem_id | int | 題目 ID | -| order_index | int | 題目排序 | -| weight | string | 作業內題目權重(Decimal) | -| title | string | 題目名稱 | -| participant_count | int | 對此題有提交紀錄的使用者數 | -| total_submission_count | int | 此題 submission 數總和 | -| avg_attempts_per_user | float | 平均提交次數 | -| avg_score | float | best_score 平均值 | -| ac_user_count | int | AC 的使用者數 | -| partial_user_count | int | 部分通過(partial)的使用者數 | -| unsolved_user_count | int | 未通過的使用者數 | -| first_ac_time | string/null | 最早 AC 時間(無人 AC 則 null) | +| 欄位 | 型別 | 說明 | +|---|---|---| +| problem_id | int | 題目 ID(Problems.id) | +| order_index | int | 作業內題目排序(Assignment_problems.order_index) | +| weight | string | 作業內題目權重(Decimal 字串) | +| title | string | 題目顯示名稱(通常為 Problems 的字串表示) | +| participant_count | int | 對此題有提交紀錄的使用者數 | +| total_submission_count | int | 此題 submission 數總和(total_submissions 加總) | +| avg_attempts_per_user | float | 平均提交次數(total_submission_count / participant_count) | +| avg_score | float | 此題 best_score 平均值 | +| ac_user_count | int | solved 的使用者數 | +| partial_user_count | int | partial 的使用者數 | +| unsolved_user_count | int | unsolved 的使用者數 | +| first_ac_time | string / null | 最早 AC 時間(無人 AC 則 null) | --- @@ -707,7 +714,7 @@ curl -X GET "http://127.0.0.1:8000/homework/1/submissions" -H "Authorization: { "data": { "homework_id": 1, - "course_id": "469e303a-6d9e-4dda-831dafe4c1ffdab5", + "course_id": "469e303a-6d9e-4dda-831d-afe4c1ffdab5", "title": "HW1 - Intro", "description": "第一份作業內容", "start_time": "2025-11-01T00:00:00Z", @@ -771,18 +778,18 @@ curl -X GET "http://127.0.0.1:8000/homework/1/submissions" -H "Authorization: --- -## cURL 測試(Windows CMD) +## cURL 測試範例(Windows CMD) -```cmd +```bat curl -X GET "http://127.0.0.1:8000/homework//stats/" ^ -H "Authorization: Bearer " ^ -H "Accept: application/json" ``` 請將: - -- `` 替換為實際作業 ID +- `` 替換為實際作業 ID - `` 替換為登入後取得的 JWT access token + ## 作業排行榜 API 路徑:GET /homework//scoreboard/ From de607d72d81428282a3dbca9ecee580dc3f61fb9 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Mon, 29 Dec 2025 00:05:49 +0800 Subject: [PATCH 087/113] fix: Fix subtask problem. --- submissions/views.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/submissions/views.py b/submissions/views.py index 824b44a..d1574cf 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -1997,6 +1997,35 @@ def post(self, request): ) logger.info(f'Created {len(all_test_cases)} {judge_status} results for all test cases of submission {submission_id}') + elif judge_status == 'accepted' and len(test_results) == 0: + # 特殊處理:AC 但沒有回傳 test_results,為所有測資創建 AC 記錄 + all_test_cases = [] + subtasks = Problem_subtasks.objects.filter(problem_id=submission.problem_id).order_by('subtask_no') + for subtask in subtasks: + test_cases = Test_cases.objects.filter(subtask_id=subtask.id).order_by('idx') + all_test_cases.extend(test_cases) + + total_cases = len(all_test_cases) + score_per_case = submission.max_score // total_cases if total_cases > 0 else 0 + + for test_case in all_test_cases: + SubmissionResult.objects.update_or_create( + submission=submission, + subtask_id=test_case.subtask_id.id, + test_case_index=test_case.idx, + defaults={ + 'problem_id': submission.problem_id, + 'test_case_id': None, + 'status': 'accepted', + 'execution_time': execution_time // total_cases if total_cases > 0 else execution_time, + 'memory_usage': memory_usage, + 'score': score_per_case, + 'max_score': score_per_case, + 'error_message': None, + } + ) + + logger.info(f'Created {len(all_test_cases)} accepted results for all test cases of submission {submission_id}') else: # 正常情況:處理每個測資結果 for test_result in test_results: From 7d35c334dbf59c1b3e323971f9e12c49a67190ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:15:13 +0000 Subject: [PATCH 088/113] Initial plan From 6cc3389fcfc1113407e2bf20eff28a2b131ae355 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Mon, 29 Dec 2025 00:17:29 +0800 Subject: [PATCH 089/113] fix: I add some log to debug. --- submissions/views.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/submissions/views.py b/submissions/views.py index d1574cf..28f14ae 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -1999,6 +1999,7 @@ def post(self, request): logger.info(f'Created {len(all_test_cases)} {judge_status} results for all test cases of submission {submission_id}') elif judge_status == 'accepted' and len(test_results) == 0: # 特殊處理:AC 但沒有回傳 test_results,為所有測資創建 AC 記錄 + logger.info(f'Processing test_result: specail AC with no test_results') all_test_cases = [] subtasks = Problem_subtasks.objects.filter(problem_id=submission.problem_id).order_by('subtask_no') for subtask in subtasks: @@ -2038,6 +2039,8 @@ def post(self, request): subtask_no = test_result.get('test_case_id') # Sandbox 用這個欄位傳 subtask 編號 test_case_index = test_result.get('test_case_index', 1) # 測資編號 + logger.info(f'Processing test_result: subtask_no={subtask_no}, test_case_index={test_case_index}, status={result_status}') + if not subtask_no: logger.warning(f'Missing subtask_no (test_case_id) in test_result') continue @@ -2049,12 +2052,13 @@ def post(self, request): subtask_no=subtask_no ) subtask_id = subtask.id + logger.info(f'Found subtask: subtask_no={subtask_no} -> subtask_id={subtask_id}') except Problem_subtasks.DoesNotExist: - logger.warning(f'Subtask not found: problem_id={submission.problem_id}, subtask_no={subtask_no}') + logger.error(f'Subtask not found: problem_id={submission.problem_id}, subtask_no={subtask_no}') continue # 創建或更新記錄 - SubmissionResult.objects.update_or_create( + result, created = SubmissionResult.objects.update_or_create( submission=submission, subtask_id=subtask_id, test_case_index=test_case_index, @@ -2069,6 +2073,7 @@ def post(self, request): 'error_message': test_result.get('error_message'), } ) + logger.info(f'SubmissionResult {"created" if created else "updated"}: subtask_id={subtask_id}, test_case_index={test_case_index}') logger.info(f'Created {len(test_results)} test results for submission {submission_id}') From 7181c38f6f5484a7ededebb6f1d4870702fd1c01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:18:24 +0000 Subject: [PATCH 090/113] fix: apply review comments - use specific exceptions and remove redundant setdefault Co-authored-by: happylittle7 <7501374+happylittle7@users.noreply.github.com> --- assignments/views.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/assignments/views.py b/assignments/views.py index 716a65b..5fec5e4 100644 --- a/assignments/views.py +++ b/assignments/views.py @@ -8,6 +8,7 @@ from django.db.models import Max,Sum from django.db.models import Sum, Count, Max, Q from decimal import Decimal +import decimal from django.shortcuts import get_object_or_404 from django.utils.dateparse import parse_datetime from django.utils import timezone @@ -326,7 +327,7 @@ def put(self, request, homework_id: int): else: try: val = Decimal(raw) - except Exception: + except (ValueError, decimal.InvalidOperation): return api_response( data=None, message="penalty must be a number string (0~100)", @@ -344,7 +345,7 @@ def put(self, request, homework_id: int): if "max_attempts" in request.data: try: ma = int(request.data.get("max_attempts")) - except Exception: + except (ValueError, TypeError): return api_response( data=None, message="max_attempts must be int (-1 or >=1)", @@ -749,7 +750,7 @@ def get(self, request, homework_id: int): # 6) 依題目分組 by_pid: dict[int, list[UserProblemStats]] = {pid: [] for pid in problem_ids} for s in stats_qs: - by_pid.setdefault(s.problem_id, []).append(s) + by_pid[s.problem_id].append(s) # 7) 組 state(每題) state = {} From c8860b9edb53de02f4cd288000aeeb811f41f444 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Mon, 29 Dec 2025 00:32:32 +0800 Subject: [PATCH 091/113] fix: I try to fix some problem. --- submissions/views.py | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/submissions/views.py b/submissions/views.py index 28f14ae..e48aba2 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -1924,7 +1924,8 @@ def post(self, request): memory_usage = data.get('memory_usage', 0) test_results = data.get('test_results', []) - logger.info(f'Received callback for submission {submission_id}: status={judge_status}, score={total_score}') + logger.info(f'Received callback for submission {submission_id}: status={judge_status}, score={total_score}, test_results count={len(test_results)}') + logger.debug(f'Full callback payload: {data}') if not submission_id: return api_response( @@ -2029,6 +2030,11 @@ def post(self, request): logger.info(f'Created {len(all_test_cases)} accepted results for all test cases of submission {submission_id}') else: # 正常情況:處理每個測資結果 + logger.info(f'Processing {len(test_results)} test results normally') + + if len(test_results) == 0: + logger.warning(f'Submission {submission_id} has status={judge_status} but test_results is empty!') + for test_result in test_results: # 轉換 status 為字串格式(SubmissionResult 使用字串) result_status = test_result.get('status', 'runtime_error') @@ -2039,12 +2045,19 @@ def post(self, request): subtask_no = test_result.get('test_case_id') # Sandbox 用這個欄位傳 subtask 編號 test_case_index = test_result.get('test_case_index', 1) # 測資編號 - logger.info(f'Processing test_result: subtask_no={subtask_no}, test_case_index={test_case_index}, status={result_status}') + logger.info(f'Processing test_result: subtask_no={subtask_no} (type={type(subtask_no)}), test_case_index={test_case_index}, status={result_status}') - if not subtask_no: + if subtask_no is None: logger.warning(f'Missing subtask_no (test_case_id) in test_result') continue + # 容錯:嘗試轉換 subtask_no 為整數(防止型別不匹配) + try: + subtask_no = int(subtask_no) + except (ValueError, TypeError): + logger.error(f'Invalid subtask_no format: {subtask_no}') + continue + # 根據 subtask_no 找到實際的 subtask_id try: subtask = Problem_subtasks.objects.get( @@ -2054,8 +2067,18 @@ def post(self, request): subtask_id = subtask.id logger.info(f'Found subtask: subtask_no={subtask_no} -> subtask_id={subtask_id}') except Problem_subtasks.DoesNotExist: - logger.error(f'Subtask not found: problem_id={submission.problem_id}, subtask_no={subtask_no}') - continue + # 容錯:如果找不到,嘗試 subtask_no+1(因為 Sandbox 可能從 0 開始) + try: + logger.warning(f'Subtask not found with subtask_no={subtask_no}, trying subtask_no={subtask_no+1}') + subtask = Problem_subtasks.objects.get( + problem_id=submission.problem_id, + subtask_no=subtask_no + 1 + ) + subtask_id = subtask.id + logger.info(f'Found subtask with adjusted index: subtask_no={subtask_no+1} -> subtask_id={subtask_id}') + except Problem_subtasks.DoesNotExist: + logger.error(f'Subtask not found even with adjusted index: problem_id={submission.problem_id}, subtask_no={subtask_no} or {subtask_no+1}') + continue # 創建或更新記錄 result, created = SubmissionResult.objects.update_or_create( From 957226af5cadc18886958ef63b5d7be29f14b205 Mon Sep 17 00:00:00 2001 From: happylittle7 Date: Mon, 29 Dec 2025 01:09:47 +0800 Subject: [PATCH 092/113] fix: update quota check logic for user submissions --- submissions/views.py | 79 ++++++++++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 24 deletions(-) diff --git a/submissions/views.py b/submissions/views.py index e48aba2..5be75b7 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -837,31 +837,9 @@ def post(self, request, *args, **kwargs): status_code=status.HTTP_403_FORBIDDEN ) - # 3. Quota 檢查 - 檢查用戶是否還有提交配額 - from .models import UserProblemQuota - try: - with transaction.atomic(): - quota = UserProblemQuota.objects.select_for_update().get( - user=user, - problem_id=problem_id, - assignment_id__isnull=True # 全域配額 - ) - if quota.remaining_attempts == 0: - return api_response( - data=None, - message="you have used all your quotas", - status_code=status.HTTP_403_FORBIDDEN - ) - # 減少配額(如果不是無限制) - if quota.remaining_attempts > 0: - quota.remaining_attempts -= 1 - quota.save() - except UserProblemQuota.DoesNotExist: - # 沒有配額限制,允許提交 - pass - - # 4. 題目權限檢查 + # 3. 題目權限檢查(先檢查題目是否存在,並獲取 quota 設定) from problems.models import Problems + from .models import UserProblemQuota try: problem = Problems.objects.get(id=problem_id) @@ -902,6 +880,59 @@ def post(self, request, *args, **kwargs): status_code=status.HTTP_404_NOT_FOUND ) + # 4. Quota 檢查 - 檢查用戶是否還有提交配額 + # 先檢查題目層級的 total_quota 設定 + problem_quota = problem.total_quota # -1 = 無限制, >= 0 = 有限制 + + if problem_quota >= 0: + # 題目有配額限制,需要檢查/建立 UserProblemQuota 記錄 + with transaction.atomic(): + quota, created = UserProblemQuota.objects.select_for_update().get_or_create( + user=user, + problem_id=problem_id, + assignment_id=None, # 全域配額 + defaults={ + 'total_quota': problem_quota, + 'remaining_attempts': problem_quota + } + ) + + # 如果記錄已存在但 total_quota 與題目設定不同,可能是題目設定更新了 + # 這裡我們不自動更新,保持原有配額(避免用戶突然獲得更多或更少配額) + + if quota.remaining_attempts == 0: + return api_response( + data=None, + message="you have used all your quotas", + status_code=status.HTTP_403_FORBIDDEN + ) + + # 減少配額 + if quota.remaining_attempts > 0: + quota.remaining_attempts -= 1 + quota.save() + else: + # 題目無配額限制,但仍檢查是否有手動設定的 UserProblemQuota + try: + with transaction.atomic(): + quota = UserProblemQuota.objects.select_for_update().get( + user=user, + problem_id=problem_id, + assignment_id__isnull=True + ) + if quota.remaining_attempts == 0: + return api_response( + data=None, + message="you have used all your quotas", + status_code=status.HTTP_403_FORBIDDEN + ) + if quota.remaining_attempts > 0: + quota.remaining_attempts -= 1 + quota.save() + except UserProblemQuota.DoesNotExist: + # 沒有任何配額限制,允許提交 + pass + submission = serializer.save() # NOJ 格式響應 From 6d4190a5d08c29d38457f1cb8f95ad82d367438e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=A5=95=E5=A9=B7?= Date: Mon, 29 Dec 2025 01:12:45 +0800 Subject: [PATCH 093/113] feat(assignments): add ip_whitelist field --- assignments/models.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/assignments/models.py b/assignments/models.py index e553eaf..dd89597 100644 --- a/assignments/models.py +++ b/assignments/models.py @@ -3,7 +3,38 @@ from django.conf import settings from django.db import models from django.db.models import Q, F - +import ipaddress +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +def validate_cidr_whitelist(value: str): + """ + 允許空字串(代表不限制)。 + 允許多筆 CIDR,用逗號或換行分隔,例如: + 192.168.1.0/24 + 10.0.0.0/8, 140.112.0.0/16 + 2001:db8::/32 + """ + if not value: + return + + raw_parts = value.replace("\n", ",").split(",") + parts = [p.strip() for p in raw_parts if p.strip()] + if not parts: + return + + errors = [] + for p in parts: + try: + ipaddress.ip_network(p, strict=False) + except ValueError: + errors.append(p) + + if errors: + raise ValidationError( + _("Invalid CIDR(s): %(items)s"), + params={"items": ", ".join(errors)}, + ) class Assignments(models.Model): """作業本體""" @@ -49,6 +80,7 @@ class Status(models.TextChoices): ) ip_restriction = models.TextField(blank=True) + ip_whitelist = models.TextField(blank=True, validators=[validate_cidr_whitelist]) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) From 8adf5210a55e5829e9958831e05ab7d2bcae0a84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:18:09 +0000 Subject: [PATCH 094/113] Initial plan From 4d2f9fc24b1cf2041170023894c62ec15a4291bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:22:26 +0000 Subject: [PATCH 095/113] Fix transaction safety, race conditions, add docs and comprehensive quota tests Co-authored-by: happylittle7 <7501374+happylittle7@users.noreply.github.com> --- .../test_file/test_submission_views_api.py | 281 +++++++++++++++++- submissions/views.py | 65 ++-- 2 files changed, 324 insertions(+), 22 deletions(-) diff --git a/submissions/test_file/test_submission_views_api.py b/submissions/test_file/test_submission_views_api.py index 97bfb53..ebd0cef 100644 --- a/submissions/test_file/test_submission_views_api.py +++ b/submissions/test_file/test_submission_views_api.py @@ -18,7 +18,7 @@ from rest_framework import status from rest_framework.authtoken.models import Token -from ..models import Submission, SubmissionResult +from ..models import Submission, SubmissionResult, UserProblemQuota from problems.models import Problems from courses.models import Courses, Course_members from ..serializers import ( @@ -1126,6 +1126,285 @@ def test_permission_after_role_change(self): self.assertEqual(self.get_api_message(response), "no permission") # NOJ format +class TestSubmissionQuotaEnforcement(SubmissionAPITestSetup, APITestCase): + """測試提交配額執行邏輯""" + + def setUp(self): + """設置測試環境""" + self.create_test_users() + self.create_test_courses() + self.create_test_problems() + + # Create a problem with quota limit for testing + self.problem_with_quota = Problems.objects.create( + id=3001, + title='Quota Test Problem', + description='Problem with submission quota', + course_id=self.course1, + creator_id=self.teacher, + is_public='course', + difficulty=Problems.Difficulty.EASY, + total_quota=3 # Allow 3 submissions + ) + + # Create a problem without quota limit + self.problem_unlimited = Problems.objects.create( + id=3002, + title='Unlimited Test Problem', + description='Problem with unlimited submissions', + course_id=self.course1, + creator_id=self.teacher, + is_public='course', + difficulty=Problems.Difficulty.EASY, + total_quota=-1 # Unlimited + ) + + self.client = APIClient() + self.url = reverse('submissions-list') + + def test_problem_with_quota_limit_initialization(self): + """測試有配額限制的題目,首次提交時會初始化配額記錄""" + self.client.force_authenticate(user=self.student1) + + # 首次提交 + data = { + 'problem_id': self.problem_with_quota.id, + 'language_type': 2, # Python + 'source_code': 'print("Hello World")' + } + response = self.client.post(self.url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # 檢查是否創建了配額記錄 + quota = UserProblemQuota.objects.get( + user=self.student1, + problem_id=self.problem_with_quota.id, + assignment_id=None + ) + self.assertEqual(quota.total_quota, 3) + self.assertEqual(quota.remaining_attempts, 2) # 3 - 1 + + def test_problem_with_quota_limit_enforcement(self): + """測試配額限制執行:用戶提交到達上限後應被拒絕""" + self.client.force_authenticate(user=self.student1) + + data = { + 'problem_id': self.problem_with_quota.id, + 'language_type': 2, + 'source_code': 'print("Hello World")' + } + + # 提交 3 次(配額為 3) + for i in range(3): + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # 第 4 次提交應該被拒絕 + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn('quota', response.json()['message'].lower()) + + def test_problem_without_quota_limit(self): + """測試無配額限制的題目,允許無限次提交""" + self.client.force_authenticate(user=self.student1) + + data = { + 'problem_id': self.problem_unlimited.id, + 'language_type': 2, + 'source_code': 'print("Hello World")' + } + + # 提交多次,都應該成功 + for i in range(5): + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # 檢查不應該有配額記錄(如果沒有手動設置) + quota_exists = UserProblemQuota.objects.filter( + user=self.student1, + problem_id=self.problem_unlimited.id, + assignment_id=None + ).exists() + self.assertFalse(quota_exists) + + def test_manual_quota_override_on_unlimited_problem(self): + """測試在無配額限制的題目上手動設置用戶配額""" + self.client.force_authenticate(user=self.student1) + + # 手動為用戶創建配額限制(管理員操作) + UserProblemQuota.objects.create( + user=self.student1, + problem_id=self.problem_unlimited.id, + assignment_id=None, + total_quota=2, + remaining_attempts=2 + ) + + data = { + 'problem_id': self.problem_unlimited.id, + 'language_type': 2, + 'source_code': 'print("Hello World")' + } + + # 提交 2 次應該成功 + for i in range(2): + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # 第 3 次應該被拒絕 + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_admin_unlimited_quota_override(self): + """測試管理員設置 -1 (無限) 配額覆蓋題目配額限制""" + self.client.force_authenticate(user=self.student1) + + # 手動為用戶設置無限配額(管理員操作) + UserProblemQuota.objects.create( + user=self.student1, + problem_id=self.problem_with_quota.id, + assignment_id=None, + total_quota=-1, + remaining_attempts=-1 # 無限 + ) + + data = { + 'problem_id': self.problem_with_quota.id, + 'language_type': 2, + 'source_code': 'print("Hello World")' + } + + # 提交多次,都應該成功(即使題目有配額限制) + for i in range(5): + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # 檢查配額記錄仍然是 -1 + quota = UserProblemQuota.objects.get( + user=self.student1, + problem_id=self.problem_with_quota.id, + assignment_id=None + ) + self.assertEqual(quota.remaining_attempts, -1) + + def test_quota_per_user_isolation(self): + """測試配額在用戶之間是獨立的""" + self.client.force_authenticate(user=self.student1) + + data = { + 'problem_id': self.problem_with_quota.id, + 'language_type': 2, + 'source_code': 'print("Hello World")' + } + + # student1 提交 3 次(用完配額) + for i in range(3): + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # student1 第 4 次被拒絕 + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # 切換到 student2 + self.client.force_authenticate(user=self.student2) + + # student2 應該仍然可以提交(有自己的配額) + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_concurrent_submission_race_condition(self): + """測試並發提交時的競爭條件處理""" + from django.db import connection + from threading import Thread + import time + + self.client.force_authenticate(user=self.student1) + + # 創建一個配額為 1 的問題 + problem_race = Problems.objects.create( + id=3003, + title='Race Condition Test', + description='Test concurrent submissions', + course_id=self.course1, + creator_id=self.teacher, + is_public='course', + difficulty=Problems.Difficulty.EASY, + total_quota=1 # Only 1 submission allowed + ) + + results = [] + + def submit(): + # Create a new client for each thread + client = APIClient() + client.force_authenticate(user=self.student1) + + data = { + 'problem_id': problem_race.id, + 'language_type': 2, + 'source_code': 'print("Concurrent test")' + } + + try: + response = client.post(self.url, data, format='json') + results.append(response.status_code) + except Exception as e: + results.append(str(e)) + + # 嘗試同時提交 2 次 + threads = [Thread(target=submit) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join() + + # 應該有一個成功 (201),一個失敗 (403) + success_count = results.count(status.HTTP_201_CREATED) + forbidden_count = results.count(status.HTTP_403_FORBIDDEN) + + # 至少應該有一個成功的 + self.assertGreaterEqual(success_count, 1) + # 配額應該被正確執行(只允許 1 次提交) + self.assertLessEqual(success_count, 1) + + def test_quota_not_synced_with_problem_changes(self): + """測試題目配額變更時,已存在的用戶配額不會自動同步""" + self.client.force_authenticate(user=self.student1) + + # 首次提交,初始化配額為 3 + data = { + 'problem_id': self.problem_with_quota.id, + 'language_type': 2, + 'source_code': 'print("Hello World")' + } + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # 檢查配額 + quota = UserProblemQuota.objects.get( + user=self.student1, + problem_id=self.problem_with_quota.id, + assignment_id=None + ) + self.assertEqual(quota.total_quota, 3) + self.assertEqual(quota.remaining_attempts, 2) + + # 修改題目配額為 5 + self.problem_with_quota.total_quota = 5 + self.problem_with_quota.save() + + # 再次提交 + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # 檢查用戶配額沒有改變(保持公平性) + quota.refresh_from_db() + self.assertEqual(quota.total_quota, 3) # 仍然是 3,不是 5 + self.assertEqual(quota.remaining_attempts, 1) # 2 - 1 + + # 運行測試的輔助函數 if __name__ == '__main__': # 運行特定測試類 diff --git a/submissions/views.py b/submissions/views.py index 5be75b7..909b44d 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -884,18 +884,37 @@ def post(self, request, *args, **kwargs): # 先檢查題目層級的 total_quota 設定 problem_quota = problem.total_quota # -1 = 無限制, >= 0 = 有限制 - if problem_quota >= 0: - # 題目有配額限制,需要檢查/建立 UserProblemQuota 記錄 - with transaction.atomic(): - quota, created = UserProblemQuota.objects.select_for_update().get_or_create( - user=user, - problem_id=problem_id, - assignment_id=None, # 全域配額 - defaults={ - 'total_quota': problem_quota, - 'remaining_attempts': problem_quota - } - ) + # Wrap quota check and submission save in a single atomic transaction + # to ensure consistency (prevent quota decrement without submission save) + with transaction.atomic(): + if problem_quota >= 0: + # 題目有配額限制,需要檢查/建立 UserProblemQuota 記錄 + # Use explicit try-except pattern to avoid race condition with get_or_create + try: + # Try to get existing quota record with row-level lock + quota = UserProblemQuota.objects.select_for_update().get( + user=user, + problem_id=problem_id, + assignment_id=None # 全域配額 + ) + except UserProblemQuota.DoesNotExist: + # Record doesn't exist, create it + # The unique constraint will handle concurrent creates + try: + quota = UserProblemQuota.objects.create( + user=user, + problem_id=problem_id, + assignment_id=None, + total_quota=problem_quota, + remaining_attempts=problem_quota + ) + except Exception: + # If another thread created it concurrently, get it with lock + quota = UserProblemQuota.objects.select_for_update().get( + user=user, + problem_id=problem_id, + assignment_id=None + ) # 如果記錄已存在但 total_quota 與題目設定不同,可能是題目設定更新了 # 這裡我們不自動更新,保持原有配額(避免用戶突然獲得更多或更少配額) @@ -908,13 +927,14 @@ def post(self, request, *args, **kwargs): ) # 減少配額 + # Note: remaining_attempts can be -1 (unlimited) if manually set by admin + # In this case, we preserve the unlimited quota by not decrementing if quota.remaining_attempts > 0: quota.remaining_attempts -= 1 quota.save() - else: - # 題目無配額限制,但仍檢查是否有手動設定的 UserProblemQuota - try: - with transaction.atomic(): + else: + # 題目無配額限制,但仍檢查是否有手動設定的 UserProblemQuota + try: quota = UserProblemQuota.objects.select_for_update().get( user=user, problem_id=problem_id, @@ -926,14 +946,17 @@ def post(self, request, *args, **kwargs): message="you have used all your quotas", status_code=status.HTTP_403_FORBIDDEN ) + # Note: remaining_attempts can be -1 (unlimited) if manually set + # In this case, we preserve the unlimited quota by not decrementing if quota.remaining_attempts > 0: quota.remaining_attempts -= 1 quota.save() - except UserProblemQuota.DoesNotExist: - # 沒有任何配額限制,允許提交 - pass - - submission = serializer.save() + except UserProblemQuota.DoesNotExist: + # 沒有任何配額限制,允許提交 + pass + + # Save submission within the same transaction to ensure atomicity + submission = serializer.save() # NOJ 格式響應 return api_response( From d2204744abf7570b041148cef5ba748dc1707b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=A5=95=E5=A9=B7?= Date: Mon, 29 Dec 2025 01:23:38 +0800 Subject: [PATCH 096/113] feat(assignments): add ip_whitelist field --- .../0004_assignments_ip_whitelist.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 assignments/migrations/0004_assignments_ip_whitelist.py diff --git a/assignments/migrations/0004_assignments_ip_whitelist.py b/assignments/migrations/0004_assignments_ip_whitelist.py new file mode 100644 index 0000000..4b66458 --- /dev/null +++ b/assignments/migrations/0004_assignments_ip_whitelist.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.7 on 2025-12-28 17:21 + +import assignments.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assignments', '0003_initial'), + ] + + operations = [ + migrations.AddField( + model_name='assignments', + name='ip_whitelist', + field=models.TextField(blank=True, validators=[assignments.models.validate_cidr_whitelist]), + ), + ] From 69bd922e9d4afba79418105c53ee6029e869d3da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:26:03 +0000 Subject: [PATCH 097/113] Fix test URL and remove duplicate migrations Co-authored-by: happylittle7 <7501374+happylittle7@users.noreply.github.com> --- .../migrations/0005_problems_testcase_hash.py | 18 ------------------ .../test_file/test_submission_views_api.py | 2 +- 2 files changed, 1 insertion(+), 19 deletions(-) delete mode 100644 problems/migrations/0005_problems_testcase_hash.py diff --git a/problems/migrations/0005_problems_testcase_hash.py b/problems/migrations/0005_problems_testcase_hash.py deleted file mode 100644 index d098d8b..0000000 --- a/problems/migrations/0005_problems_testcase_hash.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.7 on 2025-12-28 10:11 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('problems', '0004_problems_forbidden_functions_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='problems', - name='testcase_hash', - field=models.CharField(blank=True, help_text='SHA256 hash of the testcase package (problem.zip). Updated when testcases are uploaded.', max_length=64, null=True), - ), - ] diff --git a/submissions/test_file/test_submission_views_api.py b/submissions/test_file/test_submission_views_api.py index ebd0cef..fe93cb1 100644 --- a/submissions/test_file/test_submission_views_api.py +++ b/submissions/test_file/test_submission_views_api.py @@ -1160,7 +1160,7 @@ def setUp(self): ) self.client = APIClient() - self.url = reverse('submissions-list') + self.url = reverse('submissions:submission-list-create') def test_problem_with_quota_limit_initialization(self): """測試有配額限制的題目,首次提交時會初始化配額記錄""" From bc7c91cc163f333152f72ae8e05e133f6cd2a430 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:27:53 +0000 Subject: [PATCH 098/113] Address code review feedback: use IntegrityError and consistent query params Co-authored-by: happylittle7 <7501374+happylittle7@users.noreply.github.com> --- submissions/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/submissions/views.py b/submissions/views.py index 909b44d..f28ca94 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -1,5 +1,5 @@ from django.shortcuts import render, get_object_or_404 -from django.db import models, transaction +from django.db import models, transaction, IntegrityError from django.http import Http404 from rest_framework import generics, status, permissions from rest_framework.response import Response @@ -908,7 +908,7 @@ def post(self, request, *args, **kwargs): total_quota=problem_quota, remaining_attempts=problem_quota ) - except Exception: + except IntegrityError: # If another thread created it concurrently, get it with lock quota = UserProblemQuota.objects.select_for_update().get( user=user, @@ -938,7 +938,7 @@ def post(self, request, *args, **kwargs): quota = UserProblemQuota.objects.select_for_update().get( user=user, problem_id=problem_id, - assignment_id__isnull=True + assignment_id=None ) if quota.remaining_attempts == 0: return api_response( From 3d4c1c8c67248c5146a97314b1b47b0a0bdeb63c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:29:43 +0000 Subject: [PATCH 099/113] Use __isnull=True for get() queries and remove unused imports Co-authored-by: happylittle7 <7501374+happylittle7@users.noreply.github.com> --- submissions/test_file/test_submission_views_api.py | 2 -- submissions/views.py | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/submissions/test_file/test_submission_views_api.py b/submissions/test_file/test_submission_views_api.py index fe93cb1..b430079 100644 --- a/submissions/test_file/test_submission_views_api.py +++ b/submissions/test_file/test_submission_views_api.py @@ -1316,9 +1316,7 @@ def test_quota_per_user_isolation(self): def test_concurrent_submission_race_condition(self): """測試並發提交時的競爭條件處理""" - from django.db import connection from threading import Thread - import time self.client.force_authenticate(user=self.student1) diff --git a/submissions/views.py b/submissions/views.py index f28ca94..daf931c 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -895,7 +895,7 @@ def post(self, request, *args, **kwargs): quota = UserProblemQuota.objects.select_for_update().get( user=user, problem_id=problem_id, - assignment_id=None # 全域配額 + assignment_id__isnull=True # Global quota (no assignment) ) except UserProblemQuota.DoesNotExist: # Record doesn't exist, create it @@ -913,7 +913,7 @@ def post(self, request, *args, **kwargs): quota = UserProblemQuota.objects.select_for_update().get( user=user, problem_id=problem_id, - assignment_id=None + assignment_id__isnull=True ) # 如果記錄已存在但 total_quota 與題目設定不同,可能是題目設定更新了 @@ -938,7 +938,7 @@ def post(self, request, *args, **kwargs): quota = UserProblemQuota.objects.select_for_update().get( user=user, problem_id=problem_id, - assignment_id=None + assignment_id__isnull=True # Global quota (no assignment) ) if quota.remaining_attempts == 0: return api_response( From faaab377ab828af54191d140c66cedfe59384542 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:31:19 +0000 Subject: [PATCH 100/113] Use consistent assignment_id=None and simplify test assertions Co-authored-by: happylittle7 <7501374+happylittle7@users.noreply.github.com> --- submissions/test_file/test_submission_views_api.py | 5 ++--- submissions/views.py | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/submissions/test_file/test_submission_views_api.py b/submissions/test_file/test_submission_views_api.py index b430079..b5187d6 100644 --- a/submissions/test_file/test_submission_views_api.py +++ b/submissions/test_file/test_submission_views_api.py @@ -1362,10 +1362,9 @@ def submit(): success_count = results.count(status.HTTP_201_CREATED) forbidden_count = results.count(status.HTTP_403_FORBIDDEN) - # 至少應該有一個成功的 - self.assertGreaterEqual(success_count, 1) # 配額應該被正確執行(只允許 1 次提交) - self.assertLessEqual(success_count, 1) + self.assertEqual(success_count, 1) + self.assertEqual(forbidden_count, 1) def test_quota_not_synced_with_problem_changes(self): """測試題目配額變更時,已存在的用戶配額不會自動同步""" diff --git a/submissions/views.py b/submissions/views.py index daf931c..aa414b4 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -895,7 +895,7 @@ def post(self, request, *args, **kwargs): quota = UserProblemQuota.objects.select_for_update().get( user=user, problem_id=problem_id, - assignment_id__isnull=True # Global quota (no assignment) + assignment_id=None # Global quota (no assignment) ) except UserProblemQuota.DoesNotExist: # Record doesn't exist, create it @@ -913,7 +913,7 @@ def post(self, request, *args, **kwargs): quota = UserProblemQuota.objects.select_for_update().get( user=user, problem_id=problem_id, - assignment_id__isnull=True + assignment_id=None ) # 如果記錄已存在但 total_quota 與題目設定不同,可能是題目設定更新了 @@ -938,7 +938,7 @@ def post(self, request, *args, **kwargs): quota = UserProblemQuota.objects.select_for_update().get( user=user, problem_id=problem_id, - assignment_id__isnull=True # Global quota (no assignment) + assignment_id=None # Global quota (no assignment) ) if quota.remaining_attempts == 0: return api_response( From 17a1df4d5855e7a22a196d647736740b52d7f46e Mon Sep 17 00:00:00 2001 From: RokuSennyou <167313316+RokuSennyou@users.noreply.github.com> Date: Mon, 29 Dec 2025 01:35:41 +0800 Subject: [PATCH 101/113] Update assignments/models.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- assignments/models.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/assignments/models.py b/assignments/models.py index dd89597..9b80125 100644 --- a/assignments/models.py +++ b/assignments/models.py @@ -80,7 +80,11 @@ class Status(models.TextChoices): ) ip_restriction = models.TextField(blank=True) - ip_whitelist = models.TextField(blank=True, validators=[validate_cidr_whitelist]) + ip_whitelist = models.TextField( + blank=True, + default="", + validators=[validate_cidr_whitelist], + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) From 334dd73c8214e87aead6f946e0b82c1c32e6098f Mon Sep 17 00:00:00 2001 From: chewinggum-master <41247003s@gapps.ntnu.edu.tw> Date: Mon, 29 Dec 2025 02:24:16 +0800 Subject: [PATCH 102/113] =?UTF-8?q?feat:alloewdNetwork=E7=9A=84=E6=AC=84?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0004_problems_allowed_network.py | 22 ++++++++++++++++ problems/models.py | 25 +++++++++++++++++++ problems/serializers/core.py | 15 ++++++++--- problems/views/api.py | 1 + 4 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 problems/migrations/0004_problems_allowed_network.py diff --git a/problems/migrations/0004_problems_allowed_network.py b/problems/migrations/0004_problems_allowed_network.py new file mode 100644 index 0000000..ad8e2ce --- /dev/null +++ b/problems/migrations/0004_problems_allowed_network.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.7 on 2025-12-29 + +from django.db import migrations, models + + +def default_allowed_network(): + return [] + + +class Migration(migrations.Migration): + + dependencies = [ + ('problems', '0003_problems_checker_name_problems_use_custom_checker'), + ] + + operations = [ + migrations.AddField( + model_name='problems', + name='allowed_network', + field=models.JSONField(default=default_allowed_network), + ), + ] diff --git a/problems/models.py b/problems/models.py index 67edd56..7ceca31 100644 --- a/problems/models.py +++ b/problems/models.py @@ -15,6 +15,21 @@ def unlimited_or_nonnegative(value: int): def default_supported_langs(): return ["c", "cpp", "java", "python"] +def default_allowed_network(): + return [] + +def _is_valid_domain(value: str) -> bool: + import re + if not isinstance(value, str): + return False + v = value.strip().lower() + if not v: + return False + # Simple domain pattern: labels separated by '.', no scheme/path/port + # Allows subdomains; disallows wildcards and protocols + pattern = r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])\.)+[a-z]{2,}$" + return re.match(pattern, v) is not None + class Tags(models.Model): id = models.AutoField(primary_key=True) @@ -66,6 +81,9 @@ class Visibility(models.TextChoices): hint = models.TextField(blank=True, null=True) subtask_description = models.TextField(blank=True, null=True) supported_languages = models.JSONField(default=default_supported_langs) + # Allowed outbound network hosts during sandbox evaluation; empty list blocks network. + # Stores plain domains like "example.com"; no scheme, path, or port. + allowed_network = models.JSONField(default=default_allowed_network) # --- Solution code for test generation (not editorials) --- solution_code = models.TextField(blank=True, null=True, help_text="Optional: reference solution code used for test generation.") solution_code_language = models.CharField(max_length=50, blank=True, null=True, help_text="Optional: language of solution code. Required if solution code is provided.") @@ -122,6 +140,13 @@ def clean(self): lang = (self.solution_code_language or '').strip() if code and not lang: raise ValidationError({'solution_code_language': '當提供 solution code 時,必須指定其語言。'}) + # Validate allowed_network entries + nets = self.allowed_network or [] + if not isinstance(nets, (list, tuple)): + raise ValidationError({'allowed_network': '必須為字串陣列(domain list)。'}) + bad = [d for d in nets if not _is_valid_domain(str(d))] + if bad: + raise ValidationError({'allowed_network': f'格式錯誤的網域:{", ".join(map(str, bad))}(請使用如 example.com 的純網域)'}) class Problem_subtasks(models.Model): diff --git a/problems/serializers/core.py b/problems/serializers/core.py index 70031b7..ecabfe4 100644 --- a/problems/serializers/core.py +++ b/problems/serializers/core.py @@ -75,6 +75,9 @@ class ProblemSerializer(serializers.ModelSerializer): ) course_name = serializers.CharField(source="course_id.name", read_only=True) submit_count = serializers.IntegerField(read_only=True, default=0) + allowed_network = serializers.ListField( + child=serializers.CharField(max_length=255), required=False + ) class Meta: model = Problems @@ -84,7 +87,7 @@ class Meta: "like_count", "view_count", "total_quota", "description", "input_description", "output_description", "sample_input", "sample_output", "hint", - "subtask_description", "supported_languages", + "subtask_description", "supported_languages", "allowed_network", # solution code fields for test generation "solution_code", "solution_code_language", # custom checker settings @@ -185,6 +188,9 @@ class ProblemDetailSerializer(serializers.ModelSerializer): child=serializers.IntegerField(min_value=1), write_only=True, required=False ) subtasks = SubtaskSerializer(many=True, read_only=True) + allowed_network = serializers.ListField( + child=serializers.CharField(max_length=255), required=False + ) class Meta: model = Problems @@ -194,7 +200,7 @@ class Meta: "like_count", "view_count", "total_quota", "description", "input_description", "output_description", "sample_input", "sample_output", "hint", - "subtask_description", "supported_languages", + "subtask_description", "supported_languages", "allowed_network", # custom checker settings "use_custom_checker", "checker_name", "creator_id", "course_id", @@ -221,6 +227,9 @@ class ProblemStudentSerializer(serializers.ModelSerializer): submit_count = serializers.IntegerField(read_only=True, default=0) high_score = serializers.IntegerField(read_only=True, default=0) is_liked_by_user = serializers.SerializerMethodField() + allowed_network = serializers.ListField( + child=serializers.CharField(max_length=255), required=False + ) class Meta: model = Problems @@ -230,7 +239,7 @@ class Meta: "total_quota", "description", "input_description", "output_description", "sample_input", "sample_output", "hint", - "subtask_description", "supported_languages", + "subtask_description", "supported_languages", "allowed_network", # custom checker settings (read-only for students) "use_custom_checker", "checker_name", "course_id", diff --git a/problems/views/api.py b/problems/views/api.py index d6ec524..3c15af2 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -1102,6 +1102,7 @@ def _to_list(text: str): }, 'tags': tags_data, 'allowedLanguage': allowed_lang_mask, + 'allowedNetwork': list(getattr(problem, 'allowed_network', []) or []), 'courses': courses_data, 'quota': getattr(problem, 'total_quota', -1), 'defaultCode': default_code, From 4c51426e21fbdbc5eed7d236519729a7498525e2 Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Mon, 29 Dec 2025 03:08:18 +0800 Subject: [PATCH 103/113] feat: I add some data for sending data to SandBox. --- submissions/sandbox_client.py | 69 +++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/submissions/sandbox_client.py b/submissions/sandbox_client.py index 99eeb8d..3cfc0a6 100644 --- a/submissions/sandbox_client.py +++ b/submissions/sandbox_client.py @@ -101,14 +101,46 @@ def submit_to_sandbox(submission): 'callback_url': settings.BACKEND_BASE_URL.rstrip('/'), # Sandbox 判題完成後回傳結果的 URL(注意:是 submission 不是 submissions) } - # 6. 準備檔案 + # 6. 靜態分析設定(從 problem.static_analysis_rules 和 forbidden_functions 組合) + if problem.use_static_analysis and problem.static_analysis_rules: + rules = problem.static_analysis_rules or [] + config_parts = [] + + for rule in rules: + if rule == 'forbid-loops': + config_parts.append('--forbid-loops') + elif rule == 'forbid-arrays': + config_parts.append('--forbid-arrays') + elif rule == 'forbid-stl': + config_parts.append('--forbid-stl') + elif rule == 'forbid-functions': + # forbidden_functions 會單獨傳遞 + config_parts.append('--forbid-functions') + + if config_parts: + data['static_analysis_config'] = ' '.join(config_parts) + + # 如果有禁止函數列表,加入 payload + if 'forbid-functions' in rules and problem.forbidden_functions: + # 將列表轉為逗號分隔字串 + data['forbidden_functions'] = ','.join(problem.forbidden_functions) + + # 7. 網路設定(從 problem.allowed_network) + if problem.allowed_network: + data['allow_network'] = True + # 將列表轉為逗號分隔字串 + data['network_whitelist'] = ','.join(problem.allowed_network) + else: + data['allow_network'] = False + + # 8. 準備檔案 filename = f'solution.{get_file_extension(language)}' file_content = submission.source_code.encode('utf-8') files = { 'file': (filename, BytesIO(file_content), 'text/plain') } - # 7. 發送請求 + # 9. 發送請求 url = f'{SANDBOX_API_URL}/api/v1/submissions' logger.info(f'Submitting to Sandbox: submission_id={submission.id}, problem_id={submission.problem_id}') @@ -132,7 +164,7 @@ def submit_to_sandbox(submission): timeout=SANDBOX_TIMEOUT ) - # 7. 檢查回應 + # 10. 檢查回應 logger.info(f'Sandbox response status: {response.status_code}') if response.status_code >= 400: logger.error(f'Sandbox error response: {response.text}') @@ -208,7 +240,36 @@ def submit_selftest_to_sandbox(problem_id, language_type, source_code, stdin_dat 'priority': -1, # 低優先級(自定義測試不影響正式提交) 'callback_url': settings.BACKEND_BASE_URL.rstrip('/'), # Custom test callback URL } - # POST {url} + + # 靜態分析設定(從 problem.static_analysis_rules 和 forbidden_functions 組合) + if problem.use_static_analysis and problem.static_analysis_rules: + rules = problem.static_analysis_rules or [] + config_parts = [] + + for rule in rules: + if rule == 'forbid-loops': + config_parts.append('--forbid-loops') + elif rule == 'forbid-arrays': + config_parts.append('--forbid-arrays') + elif rule == 'forbid-stl': + config_parts.append('--forbid-stl') + elif rule == 'forbid-functions': + config_parts.append('--forbid-functions') + + if config_parts: + data['static_analysis_config'] = ' '.join(config_parts) + + # 如果有禁止函數列表,加入 payload + if 'forbid-functions' in rules and problem.forbidden_functions: + data['forbidden_functions'] = ','.join(problem.forbidden_functions) + + # 網路設定(從 problem.allowed_network) + if problem.allowed_network: + data['allow_network'] = True + data['network_whitelist'] = ','.join(problem.allowed_network) + else: + data['allow_network'] = False + # 準備檔案 filename = f'solution.{get_file_extension(language)}' files = { From d52f5128d1752894388a8250f6668a353d87e00e Mon Sep 17 00:00:00 2001 From: chewinggum-master <41247003s@gapps.ntnu.edu.tw> Date: Mon, 29 Dec 2025 03:08:32 +0800 Subject: [PATCH 104/113] =?UTF-8?q?feat:=E6=94=AF=E6=8F=B4localhost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- problems/models.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/problems/models.py b/problems/models.py index 7ceca31..52fb4f6 100644 --- a/problems/models.py +++ b/problems/models.py @@ -25,10 +25,24 @@ def _is_valid_domain(value: str) -> bool: v = value.strip().lower() if not v: return False - # Simple domain pattern: labels separated by '.', no scheme/path/port + + # Allow localhost for testing + if v == 'localhost': + return True + + # Allow IPv4 addresses + ipv4_pattern = r'^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$' + if re.match(ipv4_pattern, v): + return True + + # Allow IPv6 addresses (simplified pattern) + if ':' in v and re.match(r'^[0-9a-f:]+$', v): + return True + + # Standard domain pattern: labels separated by '.', no scheme/path/port # Allows subdomains; disallows wildcards and protocols - pattern = r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])\.)+[a-z]{2,}$" - return re.match(pattern, v) is not None + domain_pattern = r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])\.)+[a-z]{2,}$" + return re.match(domain_pattern, v) is not None class Tags(models.Model): From 0a370b27b643757e7edbfaf9a170e6e38b4953be Mon Sep 17 00:00:00 2001 From: noyapoyo Date: Mon, 29 Dec 2025 03:28:07 +0800 Subject: [PATCH 105/113] feat: Add some test code. --- .../migrations/0006_merge_20251228_1914.py | 14 ++++++++ submissions/test_file/test_custom_test.py | 34 ++++--------------- .../test_file/test_sandbox_integration.py | 12 +++---- 3 files changed, 25 insertions(+), 35 deletions(-) create mode 100644 problems/migrations/0006_merge_20251228_1914.py diff --git a/problems/migrations/0006_merge_20251228_1914.py b/problems/migrations/0006_merge_20251228_1914.py new file mode 100644 index 0000000..62224b9 --- /dev/null +++ b/problems/migrations/0006_merge_20251228_1914.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.7 on 2025-12-28 19:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('problems', '0004_problems_allowed_network'), + ('problems', '0005_add_testcase_hash'), + ] + + operations = [ + ] diff --git a/submissions/test_file/test_custom_test.py b/submissions/test_file/test_custom_test.py index 9659956..289b2d7 100644 --- a/submissions/test_file/test_custom_test.py +++ b/submissions/test_file/test_custom_test.py @@ -34,7 +34,7 @@ from problems.models import Problems # 測試配置 -BASE_URL = "http://127.0.0.1:8000" +BASE_URL = "http://127.0.0.1:8443" # Django 後端運行在 8443 埠 SANDBOX_URL = "http://34.81.90.111:8000" def print_section(title): @@ -380,8 +380,6 @@ def test_celery_task_direct(): traceback.print_exc() return None -def main(): - """主測試流程""" def main(): """主測試流程""" print(""" @@ -409,42 +407,24 @@ def main(): if create_problem != 'y': print("\n測試取消") print("\n[步驟] 快速創建題目步驟:") - print(" 1. python submissions/test_file/create_test_problem.py") - print(" 2. python submissions/test_file/test_custom_test.py") - return - - # 準備測試環境 - print_section("環境準備") - - print("\n1. 檢查用戶...") - user = get_or_create_test_user() - token = get_jwt_token(user) - print(f"OK: Token 已生成") - - print("\n2. 檢查題目...") - problem_id = 1 - if not check_problem_exists(problem_id): - print(f"\nWarning : 題目 {problem_id} 不存在,請先創建題目") - create_problem = input("是否繼續測試?(y/N): ").strip().lower() - if create_problem != 'y': - print("測試取消") + print(" python manage.py seed_data") return print("\n3. 檢查 Backend 服務...") try: response = requests.get(f"{BASE_URL}/", timeout=5) - print(f"OK: Backend 服務運行中 (狀態碼: {response.status_code})") + print(f"[OK] Backend 服務運行中 (狀態碼: {response.status_code})") except Exception as e: - print(f"Not good: Backend 服務無法連接: {e}") - print("請確認 Django 開發伺服器已啟動") + print(f"[ERROR] Backend 服務無法連接: {e}") + print("請確認 Django 開發伺服器已啟動:python manage.py runserver") return print("\n4. 檢查 Sandbox API...") try: response = requests.get(f"{SANDBOX_URL}/docs", timeout=5) - print(f"OK: Sandbox API 可訪問") + print(f"[OK] Sandbox API 可訪問") except Exception as e: - print(f"Warning : Sandbox API 無法連接: {e}") + print(f"[WARNING] Sandbox API 無法連接: {e}") print("測試將繼續,但可能無法完成判題") # 開始測試 diff --git a/submissions/test_file/test_sandbox_integration.py b/submissions/test_file/test_sandbox_integration.py index 4835d02..30829f6 100644 --- a/submissions/test_file/test_sandbox_integration.py +++ b/submissions/test_file/test_sandbox_integration.py @@ -89,17 +89,13 @@ def test_submission_flow_with_auth(token, problem_id=1): print(f" 請求失敗: {e}") return None - # 步驟 2: 上傳程式碼 + # 步驟 2: 上傳程式碼(或檔案) print(f"\n[步驟 2] 上傳程式碼到 {submission_id}...") - source_code = """ -def solve(): - a, b = map(int, input().split()) - print(a + b) - -if __name__ == '__main__': - solve() + source_code = """name = input() +print(f"Hello, {name}!") """ + # 使用文字提交(source_code) payload = {"source_code": source_code} try: From 7d0047a951facdf9c7295d30436b4b578eaec381 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 19:35:25 +0000 Subject: [PATCH 106/113] Initial plan From 6d209bf4a373fdbc83bb54ab934470be4c2d79ba Mon Sep 17 00:00:00 2001 From: chewinggum-master <41247003s@gapps.ntnu.edu.tw> Date: Mon, 29 Dec 2025 03:38:13 +0800 Subject: [PATCH 107/113] =?UTF-8?q?feat:like=E6=9C=83=E5=9B=9E=E5=82=B3cou?= =?UTF-8?q?rse=5Fname?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- problems/serializers/core.py | 3 ++- problems/views/api.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/problems/serializers/core.py b/problems/serializers/core.py index ecabfe4..6f9aa22 100644 --- a/problems/serializers/core.py +++ b/problems/serializers/core.py @@ -230,6 +230,7 @@ class ProblemStudentSerializer(serializers.ModelSerializer): allowed_network = serializers.ListField( child=serializers.CharField(max_length=255), required=False ) + course_name = serializers.CharField(source="course_id.name", read_only=True) class Meta: model = Problems @@ -242,7 +243,7 @@ class Meta: "subtask_description", "supported_languages", "allowed_network", # custom checker settings (read-only for students) "use_custom_checker", "checker_name", - "course_id", + "course_id", "course_name", "created_at", "tags", "tag_ids", "subtasks", "submit_count", "high_score", "is_liked_by_user", diff --git a/problems/views/api.py b/problems/views/api.py index 3c15af2..328f8c8 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -1507,7 +1507,7 @@ class UserLikedProblemsView(generics.ListAPIView): def get_queryset(self): user = self.request.user liked_ids = ProblemLike.objects.filter(user=user).values_list('problem', flat=True) - return Problems.objects.filter(id__in=liked_ids).order_by('-created_at') + return Problems.objects.filter(id__in=liked_ids).select_related('course_id').order_by('-created_at') def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) From 41bdde4f3d96de6cac50b0de8311ebfa5d80f361 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 19:38:33 +0000 Subject: [PATCH 108/113] refactor: Extract duplicated static analysis and network config logic into helper functions Co-authored-by: happylittle7 <7501374+happylittle7@users.noreply.github.com> --- submissions/sandbox_client.py | 121 +++++++++++++++++++--------------- 1 file changed, 68 insertions(+), 53 deletions(-) diff --git a/submissions/sandbox_client.py b/submissions/sandbox_client.py index 3cfc0a6..f6bd924 100644 --- a/submissions/sandbox_client.py +++ b/submissions/sandbox_client.py @@ -46,6 +46,66 @@ def get_file_extension(language): return extension_map.get(language, 'txt') +def build_static_analysis_config(problem): + """ + Build static analysis configuration from problem settings + + Args: + problem: Problems model instance + + Returns: + dict: Dictionary with 'static_analysis_config' and optionally 'forbidden_functions' + """ + config = {} + + if problem.use_static_analysis and problem.static_analysis_rules: + rules = problem.static_analysis_rules + config_parts = [] + + for rule in rules: + if rule == 'forbid-loops': + config_parts.append('--forbid-loops') + elif rule == 'forbid-arrays': + config_parts.append('--forbid-arrays') + elif rule == 'forbid-stl': + config_parts.append('--forbid-stl') + elif rule == 'forbid-functions': + # forbidden_functions 會單獨傳遞 + config_parts.append('--forbid-functions') + + if config_parts: + config['static_analysis_config'] = ' '.join(config_parts) + + # 如果有禁止函數列表,加入 payload + if 'forbid-functions' in rules and problem.forbidden_functions: + # 將列表轉為逗號分隔字串 + config['forbidden_functions'] = ','.join(problem.forbidden_functions) + + return config + + +def build_network_config(problem): + """ + Build network configuration from problem settings + + Args: + problem: Problems model instance + + Returns: + dict: Dictionary with 'allow_network' and optionally 'network_whitelist' + """ + config = {} + + if problem.allowed_network: + config['allow_network'] = True + # 將列表轉為逗號分隔字串 + config['network_whitelist'] = ','.join(problem.allowed_network) + else: + config['allow_network'] = False + + return config + + def submit_to_sandbox(submission): """ 將 submission 提交到 Sandbox 進行判題 @@ -102,36 +162,12 @@ def submit_to_sandbox(submission): } # 6. 靜態分析設定(從 problem.static_analysis_rules 和 forbidden_functions 組合) - if problem.use_static_analysis and problem.static_analysis_rules: - rules = problem.static_analysis_rules or [] - config_parts = [] - - for rule in rules: - if rule == 'forbid-loops': - config_parts.append('--forbid-loops') - elif rule == 'forbid-arrays': - config_parts.append('--forbid-arrays') - elif rule == 'forbid-stl': - config_parts.append('--forbid-stl') - elif rule == 'forbid-functions': - # forbidden_functions 會單獨傳遞 - config_parts.append('--forbid-functions') - - if config_parts: - data['static_analysis_config'] = ' '.join(config_parts) - - # 如果有禁止函數列表,加入 payload - if 'forbid-functions' in rules and problem.forbidden_functions: - # 將列表轉為逗號分隔字串 - data['forbidden_functions'] = ','.join(problem.forbidden_functions) + static_analysis_config = build_static_analysis_config(problem) + data.update(static_analysis_config) # 7. 網路設定(從 problem.allowed_network) - if problem.allowed_network: - data['allow_network'] = True - # 將列表轉為逗號分隔字串 - data['network_whitelist'] = ','.join(problem.allowed_network) - else: - data['allow_network'] = False + network_config = build_network_config(problem) + data.update(network_config) # 8. 準備檔案 filename = f'solution.{get_file_extension(language)}' @@ -242,33 +278,12 @@ def submit_selftest_to_sandbox(problem_id, language_type, source_code, stdin_dat } # 靜態分析設定(從 problem.static_analysis_rules 和 forbidden_functions 組合) - if problem.use_static_analysis and problem.static_analysis_rules: - rules = problem.static_analysis_rules or [] - config_parts = [] - - for rule in rules: - if rule == 'forbid-loops': - config_parts.append('--forbid-loops') - elif rule == 'forbid-arrays': - config_parts.append('--forbid-arrays') - elif rule == 'forbid-stl': - config_parts.append('--forbid-stl') - elif rule == 'forbid-functions': - config_parts.append('--forbid-functions') - - if config_parts: - data['static_analysis_config'] = ' '.join(config_parts) - - # 如果有禁止函數列表,加入 payload - if 'forbid-functions' in rules and problem.forbidden_functions: - data['forbidden_functions'] = ','.join(problem.forbidden_functions) + static_analysis_config = build_static_analysis_config(problem) + data.update(static_analysis_config) # 網路設定(從 problem.allowed_network) - if problem.allowed_network: - data['allow_network'] = True - data['network_whitelist'] = ','.join(problem.allowed_network) - else: - data['allow_network'] = False + network_config = build_network_config(problem) + data.update(network_config) # 準備檔案 filename = f'solution.{get_file_extension(language)}' From bebe131c34a527c0ab8e5d446cea0196fd7c6e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=A5=95=E5=A9=B7?= Date: Mon, 29 Dec 2025 04:01:30 +0800 Subject: [PATCH 109/113] fix: align homework scoreboard response keys with frontend --- assignments/serializers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assignments/serializers.py b/assignments/serializers.py index a4ada20..5c11484 100644 --- a/assignments/serializers.py +++ b/assignments/serializers.py @@ -239,7 +239,7 @@ class ScoreboardRowSerializer(serializers.Serializer): rank = serializers.IntegerField() user_id = serializers.UUIDField() username = serializers.CharField() - real_name = serializers.CharField() + real_name = serializers.CharField(allow_null=True, required=False) total_score = serializers.IntegerField() max_total_score = serializers.IntegerField() @@ -251,8 +251,8 @@ class ScoreboardRowSerializer(serializers.Serializer): problems = ScoreboardProblemSerializer(many=True) class HomeworkScoreboardSerializer(serializers.Serializer): - assignment_id = serializers.IntegerField() - title = serializers.CharField() + homework_id = serializers.IntegerField() + homework_title = serializers.CharField() course_id = serializers.UUIDField() items = ScoreboardRowSerializer(many=True) From 89895dfccd61822c57646a7a3f14b552b5155d28 Mon Sep 17 00:00:00 2001 From: chewinggum-master <41247003s@gapps.ntnu.edu.tw> Date: Mon, 29 Dec 2025 04:40:16 +0800 Subject: [PATCH 110/113] =?UTF-8?q?feat:=E6=AC=8A=E9=99=90bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- problems/views/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/problems/views/api.py b/problems/views/api.py index 56d9344..b5aca9b 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -1067,7 +1067,8 @@ def get(self, request, pk): if not (user.is_staff or user.is_superuser or getattr(user, 'identity', None) in ['admin', 'teacher'] or problem.creator_id == user): if visibility_normalized == 'course' and problem.course_id: from courses.models import Course_members - is_course_member = Course_members.objects.filter(course_id=problem.course_id, user_id=user).exists() + # 必須先檢查 user 是否已認證,否則 AnonymousUser 會導致 UUID 轉換失敗 + is_course_member = user.is_authenticated and Course_members.objects.filter(course_id=problem.course_id, user_id=user).exists() if not is_course_member: return api_response(None, "Not enough permission", status_code=403) else: From f7d8d4b65441bc9013aca6796b8e88168333751b Mon Sep 17 00:00:00 2001 From: BlankTsai Date: Mon, 29 Dec 2025 04:52:12 +0800 Subject: [PATCH 111/113] =?UTF-8?q?fix(rbac):=20=E4=BF=AE=E5=BE=A9=20RBAC?= =?UTF-8?q?=20=E6=B8=AC=E8=A9=A6=20authentication=20=E5=95=8F=E9=A1=8C?= =?UTF-8?q?=E4=B8=A6=E5=AF=A6=E7=8F=BE=E7=AE=A1=E7=90=86=E5=93=A1=E7=89=B9?= =?UTF-8?q?=E6=AC=8A\n\n=E4=BF=AE=E6=94=B9=E5=85=A7=E5=AE=B9=EF=BC=9A\n1.?= =?UTF-8?q?=20=E8=A8=BB=E8=A7=A3=E6=8E=89=E8=A6=86=E8=93=8B=E5=85=A8?= =?UTF-8?q?=E5=9F=9F=E9=85=8D=E7=BD=AE=E7=9A=84=20authentication=5Fclasses?= =?UTF-8?q?=20(auths,=20api=5Ftokens,=20copycat)\n2.=20=E5=AF=A6=E7=8F=BE?= =?UTF-8?q?=20copycat=20=E7=AE=A1=E7=90=86=E5=93=A1=E7=89=B9=E6=AC=8A=20-?= =?UTF-8?q?=20=E5=85=81=E8=A8=B1=20superuser=20=E8=A8=AA=E5=95=8F=E6=89=80?= =?UTF-8?q?=E6=9C=89=E8=AA=B2=E7=A8=8B=E5=A0=B1=E5=91=8A\n3.=20=E4=BF=AE?= =?UTF-8?q?=E5=BE=A9=E6=89=80=E6=9C=89=20RBAC=20=E6=B8=AC=E8=A9=A6?= =?UTF-8?q?=EF=BC=9A\n=20=20=20-=20=E4=BD=BF=E7=94=A8=20JWT=20token=20?= =?UTF-8?q?=E8=AA=8D=E8=AD=89=E6=9B=BF=E4=BB=A3=20force=5Fauthenticate\n?= =?UTF-8?q?=20=20=20-=20=E6=AD=A3=E7=A2=BA=E5=89=B5=E5=BB=BA=20UserProfile?= =?UTF-8?q?=20=E4=B8=A6=E8=A8=AD=E7=BD=AE=20email=5Fverified\n=20=20=20-?= =?UTF-8?q?=20=E4=BF=AE=E6=AD=A3=E6=B8=AC=E8=A9=A6=E6=95=B8=E6=93=9A?= =?UTF-8?q?=E6=AC=84=E4=BD=8D=E5=90=8D=E7=A8=B1=E5=92=8C=E5=80=BC\n\n?= =?UTF-8?q?=E6=B8=AC=E8=A9=A6=E7=B5=90=E6=9E=9C=EF=BC=9A\n-=20auths:=209/9?= =?UTF-8?q?=20=E9=80=9A=E9=81=8E=20(100%)\n-=20copycat:=2013/13=20?= =?UTF-8?q?=E9=80=9A=E9=81=8E=20(100%)\n-=20=E6=95=B4=E9=AB=94=EF=BC=9A28/?= =?UTF-8?q?34=20=E9=80=9A=E9=81=8E=20(82.4%=EF=BC=8C=E5=89=A9=E9=A4=98?= =?UTF-8?q?=E7=82=BA=20api-tokens=20=E5=B7=B2=E7=9F=A5=E5=95=8F=E9=A1=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api_tokens/views/api_tokens.py | 4 +- auths/views/activity.py | 4 +- auths/views/login_logs.py | 6 +- copycat/views.py | 14 +- tests/rbac_tests/test_api_tokens_rbac.py | 165 ++++++++++++++++++++ tests/rbac_tests/test_auths_rbac.py | 97 ++++++++++++ tests/rbac_tests/test_copycat_rbac.py | 182 +++++++++++++++++++++++ tests/rbac_tests/test_debug_auth.py | 39 +++++ 8 files changed, 501 insertions(+), 10 deletions(-) create mode 100644 tests/rbac_tests/test_api_tokens_rbac.py create mode 100644 tests/rbac_tests/test_auths_rbac.py create mode 100644 tests/rbac_tests/test_copycat_rbac.py create mode 100644 tests/rbac_tests/test_debug_auth.py diff --git a/api_tokens/views/api_tokens.py b/api_tokens/views/api_tokens.py index b19f0e8..9a259e2 100644 --- a/api_tokens/views/api_tokens.py +++ b/api_tokens/views/api_tokens.py @@ -36,7 +36,7 @@ class ApiTokenListView(APIView): - POST: 為當前使用者建立一個新的 API Token。 """ - authentication_classes = [ApiTokenAuthentication, JWTAuthentication, SessionAuthentication] + # authentication_classes = [ApiTokenAuthentication, JWTAuthentication, SessionAuthentication] # Commented to use global default # permission_classes = [IsAuthenticated, TokenHasScope] # Removed to use global default def get(self, request): @@ -72,7 +72,7 @@ class ApiTokenDetailView(APIView): - DELETE: 刪除指定的 Token """ - authentication_classes = [ApiTokenAuthentication, JWTAuthentication, SessionAuthentication] + # authentication_classes = [ApiTokenAuthentication, JWTAuthentication, SessionAuthentication] # Commented to use global default # permission_classes = [IsAuthenticated] # Removed to use global default def get_object(self, request, tokenId): diff --git a/auths/views/activity.py b/auths/views/activity.py index 2cca921..6b4a56d 100644 --- a/auths/views/activity.py +++ b/auths/views/activity.py @@ -29,7 +29,7 @@ class UserActivityCreateView(APIView): POST /auth/activity 記錄使用者活動。 """ - authentication_classes = [SessionAuthentication] + # authentication_classes = [SessionAuthentication] # Commented to use global default (includes JWTAuthentication) # permission_classes = [IsAuthenticated] # Removed to use global default (IsAuthenticated + IsEmailVerified) def post(self, request): @@ -71,7 +71,7 @@ class UserActivityListView(APIView): GET /auth/activity// 查看特定使用者的活動記錄 (僅限管理員)。 """ - authentication_classes = [SessionAuthentication] + # authentication_classes = [SessionAuthentication] # Commented to use global default (includes JWTAuthentication) permission_classes = [IsAdminUser, IsEmailVerified] def get(self, request, user_id): diff --git a/auths/views/login_logs.py b/auths/views/login_logs.py index 6349b54..f5f0b9a 100644 --- a/auths/views/login_logs.py +++ b/auths/views/login_logs.py @@ -35,7 +35,7 @@ def api_response(data=None, message="OK", status_code=200): class LoginLogListView(APIView): - authentication_classes = [SessionAuthentication] + # authentication_classes = [SessionAuthentication] # Commented to use global default (includes JWTAuthentication) # permission_classes = [IsAuthenticated] # Removed to use global default def get(self, request): @@ -52,7 +52,7 @@ class UserLoginLogListView(APIView): 列出「特定使用者」的所有登入日誌。 (API: GET /auth/login-logs/) """ - authentication_classes = [SessionAuthentication] + # authentication_classes = [SessionAuthentication] # Commented to use global default (includes JWTAuthentication) permission_classes = [IsAdminUser, IsEmailVerified] def get(self, request, user_id): @@ -70,7 +70,7 @@ class SuspiciousLoginListView(APIView): 列出所有「異常」的登入日誌。 (API: GET /auth/suspicious-activities/) """ - authentication_classes = [SessionAuthentication] + # authentication_classes = [SessionAuthentication] # Commented to use global default (includes JWTAuthentication) permission_classes = [IsAdminUser, IsEmailVerified] def get(self, request): diff --git a/copycat/views.py b/copycat/views.py index cf9c064..6a288db 100644 --- a/copycat/views.py +++ b/copycat/views.py @@ -21,14 +21,22 @@ def api_response(data=None, message="OK", status_code=200): return Response({"data": data, "message": message, "status": status_str}, status=status_code) class CopycatView(APIView): - authentication_classes = [SessionAuthentication, JWTAuthentication, ApiTokenAuthentication] - permission_classes = [IsAuthenticated, IsEmailVerified] + # authentication_classes = [SessionAuthentication, JWTAuthentication, ApiTokenAuthentication] # Commented to use global default + # permission_classes = [IsAuthenticated, IsEmailVerified] # Commented to use global default def _has_problem_edit_permission(self, user, problem_id): """ 檢查使用者是否有該題目的編輯權限 - 必須是該題所屬課程的老師或助教 + 必須是該題所屬課程的老師或助教,或者是管理員 """ + # 管理員擁有所有權限 + if user.is_superuser: + try: + problem = Problems.objects.select_related('course_id').get(pk=problem_id) + return True, None + except Problems.DoesNotExist: + return False, "題目不存在" + try: problem = Problems.objects.select_related('course_id').get(pk=problem_id) except Problems.DoesNotExist: diff --git a/tests/rbac_tests/test_api_tokens_rbac.py b/tests/rbac_tests/test_api_tokens_rbac.py new file mode 100644 index 0000000..b338f9c --- /dev/null +++ b/tests/rbac_tests/test_api_tokens_rbac.py @@ -0,0 +1,165 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from django.contrib.auth import get_user_model +from api_tokens.models import ApiToken +from rest_framework_simplejwt.tokens import RefreshToken + +User = get_user_model() + +class ApiTokenRBACTests(APITestCase): + def _authenticate(self, user): + """Helper method to authenticate user with JWT token""" + token = str(RefreshToken.for_user(user).access_token) + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}') + + def setUp(self): + from user.models import UserProfile + + self.user1 = User.objects.create_user(username='user1', password='password', email='user1@example.com') + p1, _ = UserProfile.objects.get_or_create(user=self.user1) + p1.email_verified = True + p1.save() + + self.user2 = User.objects.create_user(username='user2', password='password', email='user2@example.com') + p2, _ = UserProfile.objects.get_or_create(user=self.user2) + p2.email_verified = True + p2.save() + + self.teacher = User.objects.create_user(username='teacher', password='password', email='teacher@example.com') + pt, _ = UserProfile.objects.get_or_create(user=self.teacher) + pt.email_verified = True + pt.save() + + self.ta = User.objects.create_user(username='ta', password='password', email='ta@example.com') + pta, _ = UserProfile.objects.get_or_create(user=self.ta) + pta.email_verified = True + pta.save() + + self.student = User.objects.create_user(username='student', password='password', email='student@example.com') + ps, _ = UserProfile.objects.get_or_create(user=self.student) + ps.email_verified = True + ps.save() + + self.admin = User.objects.create_superuser(username='admin', password='password', email='admin@example.com') + pa, _ = UserProfile.objects.get_or_create(user=self.admin) + pa.email_verified = True + pa.save() + + # Create a course to assign roles + from courses.models import Courses, Course_members + self.course = Courses.objects.create(name='Test Course', teacher_id=self.teacher, student_limit=100) + Course_members.objects.create(course_id=self.course, user_id=self.ta, role=Course_members.Role.TA) + Course_members.objects.create(course_id=self.course, user_id=self.student, role=Course_members.Role.STUDENT) + + # User1 creates a token + self.token1 = ApiToken.objects.create( + user=self.user1, + name='User1 Token', + token_hash='hash123', + prefix='prefix' + ) + self.list_url = reverse('api-token-list') + self.detail_url = reverse('api-token-detail', args=[self.token1.id]) + + def test_user_can_list_own_tokens(self): + self._authenticate(self.user1) + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Should see 1 token + self.assertEqual(len(response.data['data']), 1) + + def test_user_cannot_list_others_tokens(self): + # User2 lists tokens, should not see User1's + self._authenticate(self.user2) + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data['data']), 0) + + def test_user_can_delete_own_token(self): + self._authenticate(self.user1) + response = self.client.delete(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(ApiToken.objects.filter(id=self.token1.id).exists()) + + def test_user_cannot_delete_others_token(self): + self._authenticate(self.user2) + response = self.client.delete(self.detail_url) + # API returns 404 if not found (because filtered by user) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + # Token should still exist + self.assertTrue(ApiToken.objects.filter(id=self.token1.id).exists()) + + def test_user_cannot_get_others_token_details(self): + self._authenticate(self.user2) + response = self.client.get(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_teacher_can_create_token(self): + """ Teacher should be able to create API tokens """ + self._authenticate(self.teacher) + data = {'name': 'Teacher Token', 'permissions': ['read']} + response = self.client.post(self.list_url, data, format='json') + # This test will likely FAIL because the feature is not implemented yet + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIn('full_token', response.data['data']) + + def test_ta_can_create_token(self): + """ TA should be able to create API tokens """ + self._authenticate(self.ta) + data = {'name': 'TA Token', 'permissions': ['read']} + response = self.client.post(self.list_url, data, format='json') + # This test will likely FAIL because the feature is not implemented yet + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIn('full_token', response.data['data']) + + def test_student_cannot_create_token(self): + """ Student should NOT be able to create API tokens """ + self._authenticate(self.student) + data = {'name': 'Student Token', 'permissions': ['read']} + response = self.client.post(self.list_url, data, format='json') + # This test will likely FAIL because the feature is not implemented yet + # Expected: 403 Forbidden, but currently returns 201 Created + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_admin_can_create_token(self): + """ Admin should be able to create API tokens """ + self._authenticate(self.admin) + data = {'name': 'Admin Token', 'permissions': ['read', 'write']} + response = self.client.post(self.list_url, data, format='json') + # This test will likely FAIL because the feature is not implemented yet + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_admin_can_view_all_tokens(self): + """ Admin should be able to view all users' tokens """ + # Create tokens for different users + ApiToken.objects.create(user=self.teacher, name='Teacher Token', token_hash='hash_t', prefix='prefix_t') + ApiToken.objects.create(user=self.student, name='Student Token', token_hash='hash_s', prefix='prefix_s') + + self._authenticate(self.admin) + response = self.client.get(self.list_url) + # This test will likely FAIL - need admin endpoint to see all tokens + # Currently only returns admin's own tokens + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Should see tokens from multiple users (at least 3: user1, teacher, student) + # This assertion will likely fail + self.assertGreaterEqual(len(response.data['data']), 3) + + def test_admin_can_delete_any_token(self): + """ Admin should be able to delete any user's token """ + # Create a token for teacher + teacher_token = ApiToken.objects.create( + user=self.teacher, + name='Teacher Token To Delete', + token_hash='hash_del', + prefix='prefix_del' + ) + + self._authenticate(self.admin) + delete_url = reverse('api-token-detail', args=[teacher_token.id]) + response = self.client.delete(delete_url) + # This test will likely FAIL - admin needs special permission + # Currently returns 404 because token is filtered by user + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(ApiToken.objects.filter(id=teacher_token.id).exists()) + diff --git a/tests/rbac_tests/test_auths_rbac.py b/tests/rbac_tests/test_auths_rbac.py new file mode 100644 index 0000000..f768ae0 --- /dev/null +++ b/tests/rbac_tests/test_auths_rbac.py @@ -0,0 +1,97 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from django.contrib.auth import get_user_model +from auths.models import LoginLog +from rest_framework_simplejwt.tokens import RefreshToken + +User = get_user_model() + +class AuthsRBACTests(APITestCase): + def _authenticate(self, user): + """Helper method to authenticate user with JWT token""" + token = str(RefreshToken.for_user(user).access_token) + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}') + + def setUp(self): + from user.models import UserProfile + + self.user1 = User.objects.create_user(username='user1', password='password', email='user1@example.com') + profile1, _ = UserProfile.objects.get_or_create(user=self.user1) + profile1.email_verified = True + profile1.save() + + self.user2 = User.objects.create_user(username='user2', password='password', email='user2@example.com') + profile2, _ = UserProfile.objects.get_or_create(user=self.user2) + profile2.email_verified = True + profile2.save() + + self.admin = User.objects.create_superuser(username='admin', password='password', email='admin@example.com') + profile_admin, _ = UserProfile.objects.get_or_create(user=self.admin) + profile_admin.email_verified = True + profile_admin.save() + + # Create some logs + LoginLog.objects.create(user=self.user1, username=self.user1.username, login_status='success', ip_address='127.0.0.1') + + self.self_logs_url = reverse('login-log-list-self') + self.user_logs_url = reverse('user-login-log-list', args=[self.user1.id]) + self.stats_url = reverse('user-submission-activity', args=[self.user1.id]) + + def test_user_can_view_own_logs(self): + self._authenticate(self.user1) + response = self.client.get(self.self_logs_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data['data']), 1) + + def test_user_cannot_view_others_logs_via_admin_api(self): + # User2 tries to access User1's logs via admin endpoint + self._authenticate(self.user2) + response = self.client.get(self.user_logs_url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_admin_can_view_others_logs(self): + self._authenticate(self.admin) + response = self.client.get(self.user_logs_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Should see User1's logs + self.assertEqual(len(response.data['data']), 1) + + def test_user_can_view_others_submission_stats(self): + # Public profile scenario + self._authenticate(self.user2) + response = self.client.get(self.stats_url) + # Should be allowed (200) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_regular_user_cannot_view_suspicious_activities(self): + """ Regular user (teacher/student/TA) cannot access suspicious activities """ + self._authenticate(self.user1) + response = self.client.get(reverse('suspicious-activity-list')) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_admin_can_view_suspicious_activities(self): + """ Admin can access suspicious activities """ + self._authenticate(self.admin) + response = self.client.get(reverse('suspicious-activity-list')) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_regular_user_cannot_view_others_activities(self): + """ Regular user cannot view other users' activity records """ + self._authenticate(self.user2) + response = self.client.get(reverse('user-activity-list', args=[self.user1.id])) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_admin_can_view_others_activities(self): + """ Admin can view other users' activity records """ + self._authenticate(self.admin) + response = self.client.get(reverse('user-activity-list', args=[self.user1.id])) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_user_can_create_own_activity(self): + """ Regular user can create their own activity records """ + self._authenticate(self.user1) + data = {'activity_type': 'view_problem', 'metadata': {'problem_id': '123'}} + response = self.client.post(reverse('activity-create'), data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + diff --git a/tests/rbac_tests/test_copycat_rbac.py b/tests/rbac_tests/test_copycat_rbac.py new file mode 100644 index 0000000..555d288 --- /dev/null +++ b/tests/rbac_tests/test_copycat_rbac.py @@ -0,0 +1,182 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from django.contrib.auth import get_user_model +from courses.models import Courses, Course_members +from problems.models import Problems +from rest_framework_simplejwt.tokens import RefreshToken + +User = get_user_model() + +class CopycatRBACTests(APITestCase): + def _authenticate(self, user): + """Helper method to authenticate user with JWT token""" + token = str(RefreshToken.for_user(user).access_token) + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}') + + def setUp(self): + from user.models import UserProfile + + # Create users + self.teacher = User.objects.create_user(username='teacher', password='password', email='teacher@example.com') + pt, _ = UserProfile.objects.get_or_create(user=self.teacher) + pt.email_verified = True + pt.save() + + self.ta = User.objects.create_user(username='ta', password='password', email='ta@example.com') + pta, _ = UserProfile.objects.get_or_create(user=self.ta) + pta.email_verified = True + pta.save() + + self.student = User.objects.create_user(username='student', password='password', email='student@example.com') + ps, _ = UserProfile.objects.get_or_create(user=self.student) + ps.email_verified = True + ps.save() + + self.outsider = User.objects.create_user(username='outsider', password='password', email='outsider@example.com') + po, _ = UserProfile.objects.get_or_create(user=self.outsider) + po.email_verified = True + po.save() + + # Create admin + self.admin = User.objects.create_superuser(username='admin', password='password', email='admin@example.com') + pa, _ = UserProfile.objects.get_or_create(user=self.admin) + pa.email_verified = True + pa.save() + + # Create another teacher for cross-course testing + self.teacher2 = User.objects.create_user(username='teacher2', password='password', email='teacher2@example.com') + pt2, _ = UserProfile.objects.get_or_create(user=self.teacher2) + pt2.email_verified = True + pt2.save() + + # Create Course 1 + self.course = Courses.objects.create(name='Test Course', teacher_id=self.teacher, student_limit=100) + + # Add members to Course 1 + Course_members.objects.create(course_id=self.course, user_id=self.ta, role=Course_members.Role.TA) + Course_members.objects.create(course_id=self.course, user_id=self.student, role=Course_members.Role.STUDENT) + + # Create Course 2 (for cross-course testing) + self.course2 = Courses.objects.create(name='Test Course 2', teacher_id=self.teacher2, student_limit=100) + + # Create Problem in Course 1 + self.problem = Problems.objects.create( + title='Test Problem', + description='desc', + course_id=self.course, + creator_id=self.teacher + ) + + # Create Problem in Course 2 + self.problem2 = Problems.objects.create( + title='Test Problem 2', + description='desc2', + course_id=self.course2, + creator_id=self.teacher2 + ) + + self.url = reverse('copycat') + + def test_teacher_create_report(self): + """ Teacher should be able to create a report """ + self._authenticate(self.teacher) + data = {'problem_id': self.problem.id, 'language': 'python'} + response = self.client.post(self.url, data) + # Assuming MOSS might not run, but permission check passes. + # API returns 202 on success, or 429 if pending. + self.assertIn(response.status_code, [status.HTTP_202_ACCEPTED, status.HTTP_200_OK]) + + def test_ta_create_report(self): + """ TA should be able to create a report """ + self._authenticate(self.ta) + data = {'problem_id': self.problem.id, 'language': 'python'} + response = self.client.post(self.url, data) + self.assertIn(response.status_code, [status.HTTP_202_ACCEPTED, status.HTTP_200_OK]) + + def test_student_create_report_forbidden(self): + """ Student should be forbidden from creating a report """ + self._authenticate(self.student) + data = {'problem_id': self.problem.id, 'language': 'python'} + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_outsider_create_report_forbidden(self): + """ Outsider should be forbidden """ + self._authenticate(self.outsider) + data = {'problem_id': self.problem.id, 'language': 'python'} + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_teacher_view_report(self): + """ Teacher should be able to view report """ + self._authenticate(self.teacher) + response = self.client.get(self.url, {'problem_id': self.problem.id}) + # Even if 404 (no report yet), permission check happens first. + # But wait, code checks permission then queries. If no report, returns 404. + # If permission denied, returns 403. + # So we verify it is NOT 403. + self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_student_view_report_forbidden(self): + """ Student should be forbidden from viewing report """ + self._authenticate(self.student) + response = self.client.get(self.url, {'problem_id': self.problem.id}) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_admin_can_create_report_any_course(self): + """ Admin should be able to create report for any course's problem """ + self._authenticate(self.admin) + data = {'problem_id': self.problem.id, 'language': 'python'} + response = self.client.post(self.url, data) + # This test will likely FAIL - admin privilege not implemented yet + self.assertIn(response.status_code, [status.HTTP_202_ACCEPTED, status.HTTP_200_OK, status.HTTP_429_TOO_MANY_REQUESTS]) + + def test_admin_can_view_report_any_course(self): + """ Admin should be able to view report for any course """ + self._authenticate(self.admin) + response = self.client.get(self.url, {'problem_id': self.problem.id}) + # This test will likely FAIL - admin privilege not implemented yet + # Should not return 403 Forbidden + self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_teacher_cannot_create_report_other_course(self): + """ Teacher A cannot create report for Teacher B's course """ + self._authenticate(self.teacher) + data = {'problem_id': self.problem2.id, 'language': 'python'} # problem2 belongs to teacher2's course + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_teacher_cannot_view_report_other_course(self): + """ Teacher A cannot view report for Teacher B's course """ + self._authenticate(self.teacher) + response = self.client.get(self.url, {'problem_id': self.problem2.id}) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_ta_cannot_create_report_other_course(self): + """ TA cannot create report for problems in courses they're not part of """ + self._authenticate(self.ta) + data = {'problem_id': self.problem2.id, 'language': 'python'} + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_ta_cannot_view_report_other_course(self): + """ TA cannot view report for problems in courses they're not part of """ + self._authenticate(self.ta) + response = self.client.get(self.url, {'problem_id': self.problem2.id}) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_teacher2_can_create_report_own_course(self): + """ Teacher 2 can create report for their own course """ + self._authenticate(self.teacher2) + data = {'problem_id': self.problem2.id, 'language': 'python'} + response = self.client.post(self.url, data) + self.assertIn(response.status_code, [status.HTTP_202_ACCEPTED, status.HTTP_200_OK, status.HTTP_429_TOO_MANY_REQUESTS]) + + def test_teacher2_can_view_report_own_course(self): + """ Teacher 2 can view report for their own course """ + self._authenticate(self.teacher2) + response = self.client.get(self.url, {'problem_id': self.problem2.id}) + # Even if no report exists (404), should not be 403 Forbidden + self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN) + diff --git a/tests/rbac_tests/test_debug_auth.py b/tests/rbac_tests/test_debug_auth.py new file mode 100644 index 0000000..7777470 --- /dev/null +++ b/tests/rbac_tests/test_debug_auth.py @@ -0,0 +1,39 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from django.contrib.auth import get_user_model +from rest_framework_simplejwt.tokens import RefreshToken + +User = get_user_model() + +class DebugAuthTest(APITestCase): + def setUp(self): + from user.models import UserProfile + + self.user = User.objects.create_user(username='testuser', password='password', email='test@example.com') + profile, created = UserProfile.objects.get_or_create(user=self.user, defaults={'email_verified': True}) + if not created: + profile.email_verified = True + profile.save() + + def test_user_authentication_and_profile(self): + """Test if user can authenticate and has email_verified=True""" + token = str(RefreshToken.for_user(self.user).access_token) + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}') + + # Check user profile exists and is verified + from user.models import UserProfile + profile = UserProfile.objects.get(user=self.user) + self.assertTrue(profile.email_verified) + + # Try a simple API call + response = self.client.get(reverse('login-log-list-self')) + print(f"Response status: {response.status_code}") + print(f"Response data: {response.data if hasattr(response, 'data') else response.content}") + + # If 403, print more debug info + if response.status_code == 403: + print(f"User: {self.user}") + print(f"User.is_authenticated: {self.user.is_authenticated}") + print(f"Profile email verification using is_email_verified(): {self.user.is_email_verified()}") From 1ecb33e7ac867313bc8b9426cc9fc353ba9804ab Mon Sep 17 00:00:00 2001 From: BlankTsai Date: Mon, 29 Dec 2025 04:52:26 +0800 Subject: [PATCH 112/113] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20assignments?= =?UTF-8?q?=20serializer=20=E9=A1=9E=E5=9E=8B=E5=92=8C=E5=85=B6=E4=BB=96?= =?UTF-8?q?=20bug\n\n-=20=E4=BF=AE=E6=AD=A3=20course=5Fid=20=E9=A1=9E?= =?UTF-8?q?=E5=9E=8B=E5=BE=9E=20UUIDField=20=E6=94=B9=E7=82=BA=20IntegerFi?= =?UTF-8?q?eld\n-=20=E4=BF=AE=E6=AD=A3=20homework=20scoreboard=20=E6=AC=84?= =?UTF-8?q?=E4=BD=8D=E5=91=BD=E5=90=8D\n-=20=E6=B7=BB=E5=8A=A0=20Course=5F?= =?UTF-8?q?members=20import=20=E4=BF=AE=E5=BE=A9=20NameError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assignments/serializers.py | 8 ++++---- assignments/views.py | 8 ++++---- courses/views/courses.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/assignments/serializers.py b/assignments/serializers.py index a4ada20..a9c0caf 100644 --- a/assignments/serializers.py +++ b/assignments/serializers.py @@ -30,7 +30,7 @@ def to_epoch_from_dt(dt): class HomeworkCreateSerializer(serializers.Serializer): name = serializers.CharField(required=True) - course_id = serializers.UUIDField() + course_id = serializers.IntegerField() markdown = serializers.CharField(required=False, allow_blank=True, default="") start = serializers.IntegerField(required=False, allow_null=True) end = serializers.IntegerField(required=False, allow_null=True) @@ -192,7 +192,7 @@ class HomeworkDeadlineSerializer(serializers.Serializer): id = serializers.IntegerField() name = serializers.CharField() markdown = serializers.CharField(allow_blank=True) - course_id = serializers.UUIDField() + course_id = serializers.IntegerField() start = serializers.DateTimeField(allow_null=True) end = serializers.DateTimeField(allow_null=True) is_overdue = serializers.BooleanField() @@ -218,7 +218,7 @@ class HomeworkProblemStatsSerializer(serializers.Serializer): class HomeworkStatsSerializer(serializers.Serializer): homework_id = serializers.IntegerField() - course_id = serializers.UUIDField() + course_id = serializers.IntegerField() title = serializers.CharField() description = serializers.CharField(allow_blank=True, allow_null=True) @@ -253,7 +253,7 @@ class ScoreboardRowSerializer(serializers.Serializer): class HomeworkScoreboardSerializer(serializers.Serializer): assignment_id = serializers.IntegerField() title = serializers.CharField() - course_id = serializers.UUIDField() + course_id = serializers.IntegerField() items = ScoreboardRowSerializer(many=True) class HomeworkSubmissionListItemSerializer(serializers.ModelSerializer): diff --git a/assignments/views.py b/assignments/views.py index 5fec5e4..ea90fd9 100644 --- a/assignments/views.py +++ b/assignments/views.py @@ -851,8 +851,8 @@ def get(self, request, homework_id: int): # 如果完全沒有統計資料,直接回空排行榜 if not stats_qs.exists(): payload = { - "homework_id": assignment.id, - "homework_title": assignment.title, + "assignment_id": assignment.id, + "title": assignment.title, "course_id": assignment.course_id, "items": [], } @@ -948,8 +948,8 @@ def sort_key(item): # 7. 組成 payload -> serializer -> api_response payload = { - "homework_id": assignment.id, - "homework_title": assignment.title, + "assignment_id": assignment.id, + "title": assignment.title, "course_id": assignment.course_id, "items": items, } diff --git a/courses/views/courses.py b/courses/views/courses.py index 6b01f40..14583f0 100644 --- a/courses/views/courses.py +++ b/courses/views/courses.py @@ -3,7 +3,7 @@ from rest_framework.exceptions import ErrorDetail from ..common.responses import api_response -from ..models import Courses +from ..models import Courses, Course_members from ..serializers import ( CourseCreateSerializer, CourseListSerializer, From dbcab7e114af49275be2fe37edb23c12fae3bd5e Mon Sep 17 00:00:00 2001 From: BlankTsai Date: Mon, 29 Dec 2025 04:52:33 +0800 Subject: [PATCH 113/113] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=E5=90=84?= =?UTF-8?q?=E6=87=89=E7=94=A8=E7=A8=8B=E5=BC=8F=E7=9A=84=20API=20=E6=B8=AC?= =?UTF-8?q?=E8=A9=A6=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- announcements/tests/test_announcements_api.py | 93 +++++++++++++++++++ assignments/tests/test_assignments_api.py | 74 +++++++++++++++ courses/tests/test_courses_api.py | 75 +++++++++++++++ profiles/tests/test_profiles_api.py | 47 ++++++++++ search/tests/test_search_api.py | 65 +++++++++++++ 5 files changed, 354 insertions(+) create mode 100644 announcements/tests/test_announcements_api.py create mode 100644 assignments/tests/test_assignments_api.py create mode 100644 courses/tests/test_courses_api.py create mode 100644 profiles/tests/test_profiles_api.py create mode 100644 search/tests/test_search_api.py diff --git a/announcements/tests/test_announcements_api.py b/announcements/tests/test_announcements_api.py new file mode 100644 index 0000000..0bd3d0f --- /dev/null +++ b/announcements/tests/test_announcements_api.py @@ -0,0 +1,93 @@ + +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework import status +from courses.models import Courses, Announcements, Course_members +from user.models import UserProfile + +User = get_user_model() + +class AnnouncementApiTest(TestCase): + def setUp(self): + self.client = APIClient() + self.teacher = User.objects.create_user(username='teacher', email='teacher@example.com', password='password', identity=User.Identity.TEACHER) + UserProfile.objects.update_or_create(user=self.teacher, defaults={'email_verified': True}) + + self.student = User.objects.create_user(username='student', email='student@example.com', password='password', identity=User.Identity.STUDENT) + UserProfile.objects.update_or_create(user=self.student, defaults={'email_verified': True}) + + self.course = Courses.objects.create(name="Test Course", teacher_id=self.teacher) + + # Add student + Course_members.objects.create(course_id=self.course, user_id=self.student, role=Course_members.Role.STUDENT) + + self.announcement = Announcements.objects.create( + course_id=self.course, + title="Welcome", + content="Hello everyone", + creator_id=self.teacher + ) + + def test_list_announcements(self): + self.client.force_authenticate(user=self.student) + url = reverse('announcements:course', kwargs={'course_id': self.course.id}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(len(response.data['data']) >= 1) + + def test_retrieve_announcement(self): + self.client.force_authenticate(user=self.student) + url = reverse('announcements:announcement', kwargs={'course_id': self.course.id, 'ann_id': self.announcement.id}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['data'][0]['title'], "Welcome") + + def test_create_announcement_teacher(self): + self.client.force_authenticate(user=self.teacher) + url = reverse('announcements:create') + data = { + 'course_id': self.course.id, + 'title': 'New Announcement', + 'content': 'Content here', + 'is_pinned': False + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_create_announcement_student_forbidden(self): + self.client.force_authenticate(user=self.student) + url = reverse('announcements:create') + data = { + 'course_id': self.course.id, + 'title': 'Hacker Announcement', + 'content': 'Content here' + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_announcement(self): + self.client.force_authenticate(user=self.teacher) + url = reverse('announcements:create') # PUT uses same endpoint name logic? No, let's check view. + # View class AnnouncementCreateView handles POST, PUT, DELETE at /ann/ (name='create') + + data = { + 'annId': self.announcement.id, + 'title': 'Updated Title', + 'content': 'Updated Content', + 'is_pinned': True + } + response = self.client.put(url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.announcement.refresh_from_db() + self.assertEqual(self.announcement.title, 'Updated Title') + + def test_delete_announcement(self): + self.client.force_authenticate(user=self.teacher) + url = reverse('announcements:create') # DELETE uses same endpoint + + data = {'annId': self.announcement.id} + response = self.client.delete(url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(Announcements.objects.filter(id=self.announcement.id).exists()) diff --git a/assignments/tests/test_assignments_api.py b/assignments/tests/test_assignments_api.py new file mode 100644 index 0000000..bd948ce --- /dev/null +++ b/assignments/tests/test_assignments_api.py @@ -0,0 +1,74 @@ + +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework import status +from django.utils import timezone +from datetime import timedelta +from courses.models import Courses, Course_members +from assignments.models import Assignments +from user.models import UserProfile + +User = get_user_model() + +class AssignmentApiTest(TestCase): + def setUp(self): + self.client = APIClient() + self.teacher = User.objects.create_user(username='teacher', email='teacher@example.com', password='password', identity=User.Identity.TEACHER) + UserProfile.objects.update_or_create(user=self.teacher, defaults={'email_verified': True}) + + self.course = Courses.objects.create(name="CS101", teacher_id=self.teacher) + + self.assignment = Assignments.objects.create( + title="HW1", + course=self.course, + creator=self.teacher, + status=Assignments.Status.ACTIVE + ) + self.assignment_url_base = reverse('assignments:homework-create') + + def test_create_assignment(self): + self.client.force_authenticate(user=self.teacher) + data = { + 'name': 'New HW', + 'course_id': self.course.id, + 'markdown': 'Desc', + '_start_dt': timezone.now(), + '_end_dt': timezone.now() + timedelta(days=7) + } + # The serializer expects specific fields, checking views.py HWCreateSerializer usage + # views.py: ser = HomeworkCreateSerializer(data=request.data) + # title=ser.validated_data["name"] => input field 'name'. + # start_time=ser.validated_data.get("_start_dt") => input field '_start_dt' ? + # Actually I should verify serializer fields. + # But assuming names from view code: name, markdown, _start_dt, _end_dt, problem_ids. + + response = self.client.post(self.assignment_url_base, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(Assignments.objects.filter(title='New HW').exists()) + + def test_get_assignment_detail(self): + self.client.force_authenticate(user=self.teacher) + url = reverse('assignments:homework-detail', kwargs={'homework_id': self.assignment.id}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # api_response + self.assertEqual(response.data['data']['name'], 'HW1') + + def test_update_assignment_deadline(self): + self.client.force_authenticate(user=self.teacher) + url = reverse('assignments:homework-deadline-update', kwargs={'homework_id': self.assignment.id}) + + new_due = timezone.now() + timedelta(days=7) + data = { + 'end': new_due.strftime('%Y-%m-%dT%H:%M:%SZ') # View expects 'end' + } + response = self.client.put(url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_scoreboard(self): + self.client.force_authenticate(user=self.teacher) + url = reverse('assignments:homework-scoreboard', kwargs={'homework_id': self.assignment.id}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/courses/tests/test_courses_api.py b/courses/tests/test_courses_api.py new file mode 100644 index 0000000..56ff273 --- /dev/null +++ b/courses/tests/test_courses_api.py @@ -0,0 +1,75 @@ + +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework import status +from courses.models import Courses, Course_members +from user.models import UserProfile + +User = get_user_model() + +class CourseApiTest(TestCase): + def setUp(self): + self.client = APIClient() + self.teacher = User.objects.create_user(username='teacher', email='teacher@example.com', password='password', identity=User.Identity.TEACHER) + UserProfile.objects.update_or_create(user=self.teacher, defaults={'email_verified': True}) + + self.student = User.objects.create_user(username='student', email='student@example.com', password='password', identity=User.Identity.STUDENT) + UserProfile.objects.update_or_create(user=self.student, defaults={'email_verified': True}) + + self.course = Courses.objects.create(name="Existing Course", teacher_id=self.teacher) + if not self.course.join_code: + self.course.join_code = "ABC1234" + self.course.save() + + def test_list_courses(self): + self.client.force_authenticate(user=self.teacher) + url = reverse('courses:courses:list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Check if wrapped in api_response or similar + # If response.data is list or dict with data + data = response.data.get('data', response.data) + self.assertTrue(len(data) > 0) + + def test_create_course(self): + self.client.force_authenticate(user=self.teacher) + url = reverse('courses:courses:list') + data = { + 'course': 'New Course', + 'teacher': self.teacher.username, + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(Courses.objects.filter(name='New Course').exists()) + + def test_get_course_detail(self): + self.client.force_authenticate(user=self.teacher) + url = reverse('courses:course_courseid:detail', kwargs={'course_id': self.course.id}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Check wrapper + data = response.data.get('data', response.data) + # Detail view returns object or dict + if 'course' in data: + self.assertEqual(data['course']['course'], 'Existing Course') + else: + self.assertEqual(data['course'], 'Existing Course') + + def test_join_course(self): + self.client.force_authenticate(user=self.student) + # url pattern: courses//join/ + url = reverse('courses:join:join', kwargs={'join_code': self.course.join_code}) + response = self.client.post(url) + + # Check status code. Could be 200 or 201. + self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_201_CREATED]) + + self.assertTrue(Course_members.objects.filter(course_id=self.course, user_id=self.student).exists()) + + def test_join_course_bad_code(self): + self.client.force_authenticate(user=self.student) + url = reverse('courses:join:join', kwargs={'join_code': 'INVALID'}) + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/profiles/tests/test_profiles_api.py b/profiles/tests/test_profiles_api.py new file mode 100644 index 0000000..29587af --- /dev/null +++ b/profiles/tests/test_profiles_api.py @@ -0,0 +1,47 @@ + +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework import status +from user.models import UserProfile + +User = get_user_model() + +class ProfileViewTest(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123' + ) + self.profile, created = UserProfile.objects.get_or_create(user=self.user) + self.profile.email_verified = True + self.profile.save() + + self.url_me = reverse('profile-me') + + def test_get_me_profile_unauthenticated(self): + response = self.client.get(self.url_me) + self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]) + + def test_get_me_profile_authenticated(self): + self.client.force_authenticate(user=self.user) + response = self.client.get(self.url_me) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # response.data = {'data': {...}, 'message': '...', 'status': 'ok'} + self.assertEqual(response.data['data']['username'], self.user.username) + + def test_get_public_profile(self): + self.client.force_authenticate(user=self.user) # Public profile needs auth + url_public = reverse('profile-public', kwargs={'username': self.user.username}) + response = self.client.get(url_public) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['data']['username'], self.user.username) + + def test_get_public_profile_not_found(self): + self.client.force_authenticate(user=self.user) + url_public = reverse('profile-public', kwargs={'username': 'nonexistent'}) + response = self.client.get(url_public) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/search/tests/test_search_api.py b/search/tests/test_search_api.py new file mode 100644 index 0000000..6e92c84 --- /dev/null +++ b/search/tests/test_search_api.py @@ -0,0 +1,65 @@ + +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework import status +from problems.models import Problems +from courses.models import Courses +from django.contrib.auth import get_user_model +from user.models import UserProfile + +User = get_user_model() + +class SearchViewTest(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user(username='teacher', password='password') + UserProfile.objects.update_or_create(user=self.user, defaults={'email_verified': True}) + self.course = Courses.objects.create(name="Test Course", teacher_id=self.user) + + # Create some problems + self.p1 = Problems.objects.create( + title="Hello World Problem", + description="Description 1", + creator_id=self.user, + course_id=self.course, + is_public=Problems.Visibility.PUBLIC + ) + self.p2 = Problems.objects.create( + title="Another Challenge", + description="Description 2", + creator_id=self.user, + course_id=self.course, + is_public=Problems.Visibility.PUBLIC + ) + self.p3 = Problems.objects.create( + title="Hidden Problem", + description="Hidden", + is_public=Problems.Visibility.HIDDEN, + creator_id=self.user, + course_id=self.course + ) + + def test_global_search_problems(self): + self.client.force_authenticate(user=self.user) + url = reverse('global-problem-search') + response = self.client.get(url, {'q': 'Hello'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Check response structure. Assuming standard DRF or custom api_response + data = response.data.get('data', response.data) + self.assertTrue(any(p['title'] == "Hello World Problem" for p in data['items'])) + + def test_problem_search(self): + self.client.force_authenticate(user=self.user) + url = reverse('problem-search') + response = self.client.get(url, {'title': 'Challenge'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data.get('data', response.data) + # response might be paginated or list + if isinstance(data, dict) and 'items' in data: + results = data['items'] + elif isinstance(data, dict) and 'results' in data: + results = data['results'] + else: + results = data + self.assertTrue(len(results) > 0)