diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 45d91635..045e14cb 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 + - feat/finish_all_submission_function concurrency: deploy-dev diff --git a/.gitignore b/.gitignore index b4bc589a..731059d4 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/announcements/tests/test_announcements_api.py b/announcements/tests/test_announcements_api.py new file mode 100644 index 00000000..0bd3d0f3 --- /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/announcements/views.py b/announcements/views.py index a17d3e89..b3792d9d 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/api_tokens/views/api_tokens.py b/api_tokens/views/api_tokens.py index b19f0e88..9a259e2d 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/assignments/migrations/0004_assignments_ip_whitelist.py b/assignments/migrations/0004_assignments_ip_whitelist.py new file mode 100644 index 00000000..4b66458e --- /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]), + ), + ] diff --git a/assignments/models.py b/assignments/models.py index e553eafb..9b801259 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,11 @@ class Status(models.TextChoices): ) ip_restriction = models.TextField(blank=True) + 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) diff --git a/assignments/serializers.py b/assignments/serializers.py index 1b87d433..3e201fd5 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) @@ -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 @@ -185,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() @@ -211,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) @@ -232,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() @@ -244,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) @@ -287,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/tests/test_assignments_api.py b/assignments/tests/test_assignments_api.py new file mode 100644 index 00000000..bd948ce8 --- /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/assignments/views.py b/assignments/views.py index 8288519a..ea90fd9b 100644 --- a/assignments/views.py +++ b/assignments/views.py @@ -4,17 +4,18 @@ 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 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 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 @@ -31,6 +32,9 @@ HomeworkDeadlineSerializer, HomeworkStatsSerializer, HomeworkScoreboardSerializer, + AcSubmissionRatioSerializer, + HomeworkProblemStateSerializer, + HomeworkStatsPageSerializer, ) # --------- 權限 & 工具 --------- @@ -68,6 +72,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): @@ -83,8 +93,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 +133,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) @@ -264,11 +270,158 @@ 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 (ValueError, decimal.InvalidOperation): + 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 (ValueError, TypeError): + 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), + "problem_ids": collect_problem_ids(hw), + "markdown": hw.description or "", + "late_penalty": str(hw.late_penalty), + "max_attempts": 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): - permission_classes = [IsAuthenticated] - def get(self, request, course_id): try: course = Courses.objects.get(pk=course_id) # UUID or int 都能吃 @@ -315,7 +468,6 @@ class AddProblemsToHomeworkView(APIView): 404: "homework not exists" 400: 參數錯誤(如題目皆不存在) """ - permission_classes = [permissions.IsAuthenticated] def post(self, request, homework_id: int): # 1) 找作業 @@ -424,7 +576,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 +648,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( @@ -539,51 +688,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" - } - ] - } - """ - - permission_classes = [IsAuthenticated] - def get(self, request, homework_id: int): # 1) 找作業 try: @@ -595,7 +699,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, @@ -603,181 +707,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[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 --------- @@ -786,7 +809,6 @@ class HomeworkScoreboardView(APIView): GET /homework//scoreboard/ 取得某份作業的排行榜 """ - permission_classes = [IsAuthenticated] def get_assignment(self, homework_id: int) -> Assignments: return get_object_or_404( @@ -829,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": [], } @@ -926,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, } @@ -952,8 +974,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( diff --git a/auths/serializers/password.py b/auths/serializers/password.py index 1db88cdc..61b9898e 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(required=True) class ResetPasswordSerializer(serializers.Serializer): diff --git a/auths/serializers/signup.py b/auths/serializers/signup.py index 27c566df..d28e320e 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, Courses User = get_user_model() @@ -37,9 +38,20 @@ 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): + 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/auths/views/activity.py b/auths/views/activity.py index 2cca9214..6b4a56d6 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 6349b54a..f5f0b9a1 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/auths/views/password.py b/auths/views/password.py index 79ba2546..eec31302 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, diff --git a/copycat/tests/test_services.py b/copycat/tests/test_services.py index a824707e..8dadc95f 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) diff --git a/copycat/tests/test_views.py b/copycat/tests/test_views.py index 2dc3942a..b25e4f26 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 190f4d5f..6a288dbd 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,47 @@ 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] # 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: + 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 +71,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. 權限檢查:必須是該題所屬課程的老師或助教 + # (此方法內部會檢查題目是否存在,避免重複查詢) + 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) # 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) - # 只有當沒有 pending 任務時,才建立新的 report, created = CopycatReport.objects.get_or_create( problem_id=problem_id, status='pending', @@ -54,11 +98,10 @@ 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. 啟動背景執行緒 @@ -66,7 +109,7 @@ def post(self, request): target=run_moss_check, args=(report.id, problem_id, language) ) - thread.daemon = True # (Copilot 建議:設為守護執行緒) + thread.daemon = True thread.start() return api_response( @@ -77,12 +120,25 @@ 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: + if "不存在" in error_msg: + return api_response(None, error_msg, status_code=404) + return api_response(None, error_msg, status_code=403) + # 只抓最新一筆 report = CopycatReport.objects.filter(problem_id=problem_id).order_by('-created_at').first() diff --git a/courses/tests/test_courses_api.py b/courses/tests/test_courses_api.py new file mode 100644 index 00000000..56ff273f --- /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/courses/views/assign_ta.py b/courses/views/assign_ta.py index e0ebd405..b6d378d1 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 e715a1b0..ff078b5e 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 eada459c..14583f0a 100644 --- a/courses/views/courses.py +++ b/courses/views/courses.py @@ -1,9 +1,9 @@ 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 -from ..models import Courses +from ..models import Courses, Course_members from ..serializers import ( CourseCreateSerializer, CourseListSerializer, @@ -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 @@ -75,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.", diff --git a/courses/views/grade.py b/courses/views/grade.py index 58cfdaca..7261a476 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 c589bedc..b97862b1 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 e5bcf6e9..5965cebc 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 30919a99..bcd3be5f 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 cc611023..630389dd 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 ae9e0114..51555276 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 33684246..1312a4d0 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": diff --git a/docs/assignments.MD b/docs/assignments.MD index 3ed115f3..fd22ad2e 100644 --- a/docs/assignments.MD +++ b/docs/assignments.MD @@ -47,52 +47,165 @@ 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, + "problemIds": [201, 202, 203], + "markdown": "更新後的作業說明", + "latePenalty": "10.00", + "maxAttempts": -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) | +| problemIds | array | 此作業題目 ID 清單(依 order_index 排序) | +| markdown | string | 作業說明 | +| latePenalty | string | 逾期扣分百分比(Decimal 字串,0~100) | +| maxAttempts | 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}" +``` --- @@ -474,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, @@ -508,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 | 各題目的統計資訊 | --- @@ -537,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 的使用者數 | --- @@ -571,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) | --- @@ -594,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", @@ -658,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/ diff --git a/docs/editorials.MD b/docs/editorials.MD index b89897ae..6444d91d 100644 --- a/docs/editorials.MD +++ b/docs/editorials.MD @@ -29,37 +29,32 @@ Authorization: Bearer #### 請求體 ```json { - "title": "題解標題", - "content": "題解內容", - "difficulty_rating": 3.5, - "is_official": true + "content": "題解內容" } ``` #### 請求體字段說明 -- `title` (必填): 題解標題,最大255字元 -- `content` (必填): 題解內容,不能為空 -- `difficulty_rating` (可選): 難度評級 -- `is_official` (可選): 是否為官方題解,默認false +- `content` (必填): 題解內容,不能為空,最多 10000 字元 #### 成功響應 (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, + "content": "題解內容", + "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", + "author": "550e8400-e29b-41d4-a716-446655440001" + }, + "message": "獲取題解列表成功", + "status": "ok" } ``` @@ -81,25 +76,26 @@ 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, + "content": "這是題解內容...", + "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", + "author": "550e8400-e29b-41d4-a716-446655440001" + } + ], + "message": "獲取題解列表成功", + "status": "ok" +} ``` #### 錯誤響應 @@ -108,9 +104,8 @@ Authorization: Bearer #### 排序規則 題解按以下順序排列: -1. 官方題解優先 (`is_official=true`) -2. 按讚數量降序 -3. 創建時間降序 +1. 按讚數量降序 +2. 創建時間降序 --- @@ -126,31 +121,29 @@ Authorization: Bearer #### 請求體 ```json { - "title": "更新後的題解標題", - "content": "更新後的題解內容", - "difficulty_rating": 4.0, - "is_official": false + "content": "更新後的題解內容" } ``` #### 成功響應 (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, + "content": "更新後的題解內容", + "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", + "author": "550e8400-e29b-41d4-a716-446655440001" + }, + "message": "獲取題解列表成功", + "status": "ok" } ``` @@ -206,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", @@ -241,16 +231,20 @@ Authorization: Bearer ### 常見錯誤響應格式 ```json { - "error": "錯誤描述信息", - "detail": "詳細錯誤信息" + "data": null, + "message": "錯誤描述信息", + "status": "error" } ``` 或字段驗證錯誤: ```json { - "title": ["標題長度不能超過 255 字元"], - "content": ["內容至少需要 10 個字元"] + "data": { + "content": ["內容至少需要 10 個字元"] + }, + "message": "驗證失敗", + "status": "error" } ``` @@ -276,9 +270,8 @@ Authorization: Bearer ## 注意事項 1. **UUID格式**: `solutionId` 必須是有效的UUID格式 -2. **內容長度**: 題解內容不能為空 -3. **標題長度**: 標題最多255個字元 -4. **問題關聯**: 題解必須關聯到存在的問題 -5. **課程權限**: 問題必須屬於某個課程,且操作者必須是該課程的教學人員 -6. **狀態管理**: 創建的題解自動設為"已發布"狀態 -7. **統計更新**: 查看題解時會自動增加瀏覽次數 \ No newline at end of file +2. **內容長度**: 題解內容不能為空,最多 10000 字元 +3. **問題關聯**: 題解必須關聯到存在的問題 +4. **課程權限**: 問題必須屬於某個課程,且操作者必須是該課程的教學人員 +5. **狀態管理**: 創建的題解自動設為“已發布”狀態 +6. **統計更新**: 查看題解時會自動增加瀏覽次數 \ No newline at end of file diff --git a/docs/problems.MD b/docs/problems.MD index 2e379a89..19d28a5d 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** @@ -142,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": "EXAMPLE_SHA256_CHECKSUM" }, "message": "OK", "status": "200" } @@ -167,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` @@ -176,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": [ @@ -230,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' @@ -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) +```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 規則時,必須至少指定一個禁止使用的函數。"]} +``` + --- ## 刪除題目(管理) diff --git a/docs/submissions.MD b/docs/submissions.MD index fcc5eff1..8e9e9e31 100644 --- a/docs/submissions.MD +++ b/docs/submissions.MD @@ -236,8 +236,23 @@ 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) +- `course_id` (可選): 篩選特定課程的所有提交 +- `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 範圍 + +**權限說明**: +- 學生:只能查看自己的提交 +- 教師/助教:可以查看自己所教課程的所有學生提交 +- 管理員:可以查看所有提交 **成功響應**: HTTP 200 ```json @@ -285,8 +300,36 @@ 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" + +# 查詢特定 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" ``` @@ -634,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 判題系統 @@ -806,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 格式標準 - 實作角色權限系統 - 支援重新判題功能 diff --git a/docs/user.MD b/docs/user.MD index 7359e32b..42efcc62 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。 --- @@ -1235,7 +1248,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 +1266,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 +1315,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,僅能使用一次。 --- 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 00000000..008f2879 --- /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/problems/migrations/0004_problems_allowed_network.py b/problems/migrations/0004_problems_allowed_network.py new file mode 100644 index 00000000..ad8e2cef --- /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/migrations/0004_problems_forbidden_functions_and_more.py b/problems/migrations/0004_problems_forbidden_functions_and_more.py new file mode 100644 index 00000000..f11f2177 --- /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/migrations/0005_add_testcase_hash.py b/problems/migrations/0005_add_testcase_hash.py new file mode 100644 index 00000000..fda7b12a --- /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), + ), + ] diff --git a/problems/migrations/0006_merge_20251228_1914.py b/problems/migrations/0006_merge_20251228_1914.py new file mode 100644 index 00000000..62224b95 --- /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/problems/models.py b/problems/models.py index 98a68aeb..60e1c22d 100644 --- a/problems/models.py +++ b/problems/models.py @@ -15,6 +15,44 @@ 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 + + # 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 + 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 + +def default_static_analysis_rules(): + """預設的靜態分析規則(空列表表示不使用)""" + return [] + + +def default_forbidden_functions(): + """預設的禁止函數列表(空列表)""" + return [] + class Tags(models.Model): id = models.AutoField(primary_key=True) @@ -30,15 +68,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. @@ -66,9 +112,41 @@ 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.") + # --- Custom checker settings --- + 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. '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." + ) + # --- 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) @@ -111,7 +189,59 @@ 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 的純網域)'}) + + # 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) diff --git a/problems/serializers/core.py b/problems/serializers/core.py index 4f51b486..9477a794 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 @@ -32,7 +33,6 @@ class Meta: "status", ] - class SubtaskSerializer(serializers.ModelSerializer): """完整 subtask 資訊(管理員用)""" test_cases = TestCaseSerializer(many=True, read_only=True) @@ -74,6 +74,30 @@ 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' 時必填" + ) + 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 @@ -83,12 +107,16 @@ 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 + "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", + "tags", "tag_ids", "submit_count", ] read_only_fields = [ "acceptance_rate", "like_count", "view_count", @@ -96,6 +124,35 @@ class Meta: "creator_id", "created_at", "updated_at", ] + def validate(self, attrs): + """ + 透過 Problems.clean() 執行驗證,避免在序列化器中重複商業邏輯。 + """ + # 建立或重用 Problems 實例以反映驗證後的狀態 + if self.instance is not None: + instance = self.instance + for field, value in attrs.items(): + setattr(instance, field, value) + else: + # 建立臨時實例用於驗證 + # 注意:我們只使用 clean() 驗證靜態分析欄位,不會實際存取外鍵 + temp_attrs = attrs.copy() + # 提供必填外鍵的臨時值(僅用於建立物件,不會實際查詢資料庫) + 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(),避免重複邏輯 + 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 + def create(self, validated_data): # Extract tag_ids (write-only field) tag_ids = validated_data.pop('tag_ids', None) @@ -182,6 +239,13 @@ 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 + ) + + # 靜態分析設定(唯讀,管理員視角顯示用) + static_analysis_config = serializers.SerializerMethodField() + use_static_analysis = serializers.SerializerMethodField() class Meta: model = Problems @@ -191,7 +255,12 @@ 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", + # 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", @@ -202,6 +271,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): """學生版題目詳情(隱藏敏感資訊 + 加上個人化資訊)""" @@ -216,6 +293,13 @@ 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 + ) + course_name = serializers.CharField(source="course_id.name", read_only=True) + + # 靜態分析設定(學生只需知道是否啟用) + use_static_analysis = serializers.SerializerMethodField() class Meta: model = Problems @@ -225,17 +309,27 @@ class Meta: "total_quota", "description", "input_description", "output_description", "sample_input", "sample_output", "hint", - "subtask_description", "supported_languages", - "course_id", + "subtask_description", "supported_languages", "allowed_network", + # custom checker settings (read-only for students) + "use_custom_checker", "checker_name", + "course_id", "course_name", + # static analysis (read-only for students) + "static_analysis_rules", "use_static_analysis", "created_at", "tags", "tag_ids", "subtasks", "submit_count", "high_score", "is_liked_by_user", ] 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/tests/test_api.py b/problems/tests/test_api.py index 9cb03abf..876b38d8 100644 --- a/problems/tests/test_api.py +++ b/problems/tests/test_api.py @@ -7,8 +7,10 @@ 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 @pytest.fixture @@ -23,19 +25,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 @@ -168,13 +181,26 @@ 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) + + # 計算並儲存 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'}) @@ -184,16 +210,100 @@ 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['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() + 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.""" @@ -261,3 +371,327 @@ 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' + + +@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 # DRF returns 422 for validation errors in this endpoint + + # 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 # DRF returns 422 for validation errors in this endpoint + 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 # DRF returns 422 for validation errors in this endpoint + + +@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"] diff --git a/problems/urls.py b/problems/urls.py index 8f73d25a..3aa2d248 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"), diff --git a/problems/views/api.py b/problems/views/api.py index b04fa5f4..b5aca9b2 100644 --- a/problems/views/api.py +++ b/problems/views/api.py @@ -50,7 +50,67 @@ 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 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: + 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'] + ) + if not is_staff_like: + from courses.models import Course_members + is_member = 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) @@ -62,12 +122,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 +212,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 +244,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 +337,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 +367,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 +397,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 +429,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 +492,6 @@ def stem(n): class ProblemTestCaseDownloadView(APIView): - permission_classes = [IsAuthenticated] def get(self, request, pk: int): problem = get_object_or_404(Problems, pk=pk) @@ -445,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 (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 + 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 @@ -458,7 +550,6 @@ class ProblemTestCaseZipUploadView(APIView): - 將 zip 存到 `MEDIA_ROOT/testcases/p/problem.zip`。 - 回傳保存路徑。 """ - permission_classes = [IsAuthenticated] parser_classes = [MultiPartParser, FormParser] def post(self, request, pk: int): @@ -524,8 +615,10 @@ def build_meta_entry(ss_idx: int): max_ss = max(case_counts.keys()) else: max_ss = -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": _build_testcases_list(pairs_map, max_ss) } # 重打包 zip:複製原檔案並加入 meta.json @@ -566,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 + 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']) + + 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 驗證,不用一般身份驗證 @@ -584,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") - # 計算 MD5 - import hashlib - with _storage.open(rel, 'rb') as fh: - md5 = hashlib.md5(fh.read()).hexdigest() - return api_response({"checksum": md5}, "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): @@ -613,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() - md5 = hashlib.md5(data).hexdigest() from io import BytesIO buffer = BytesIO(data) # 優先讀取 zip 內的 meta.json;若不存在則回退為檔名掃描 @@ -663,7 +758,11 @@ 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)]} + + meta = { + "tasks": [build_meta_entry(ss) for ss in range(max_ss + 1)], + "testcases": _build_testcases_list(pairs_map, max_ss) + } except zipfile.BadZipFile: return api_response(None, "Corrupted test case archive", status_code=500) # 回傳 fallback 生成的 meta 結構 @@ -676,7 +775,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 +821,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) @@ -803,10 +900,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 @@ -831,10 +944,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 @@ -861,9 +1000,10 @@ class ProblemDetailView(APIView): submitCount: 個人提交次數(若 submissions table 缺失或未登入則 null) highScore: 個人最高分(同上) - 權限:需求書標示需登入;故此處強制 IsAuthenticated。針對非公開題目沿用既有權限檢查。 + 權限:允許匿名存取公開題目;非公開題目與個人相關欄位(如 submitCount、highScore)於內部依題目可見性與使用者身分檢查。 """ - permission_classes = [IsAuthenticated] + + permission_classes = [] # 允許匿名,實際權限在內部依題目可見性檢查 LANGUAGE_BIT_MAP = { 'c': 1, @@ -927,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: @@ -999,6 +1140,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, @@ -1008,6 +1150,15 @@ 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'), + # 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) @@ -1021,7 +1172,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. @@ -1069,11 +1219,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], @@ -1094,7 +1266,6 @@ class ProblemHighScoreView(APIView): GET /api/problem//high-score — 取得使用者在該題目的最高分數 需要登入;回傳該使用者在此題的最高分 """ - permission_classes = [IsAuthenticated] def get(self, request, pk): try: @@ -1121,7 +1292,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 +1360,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 +1494,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,14 +1544,13 @@ def problem_likes_count(request, pk): class UserLikedProblemsView(generics.ListAPIView): """GET /user/likes - 列出已按讚的題目(需登入)""" - permission_classes = [IsAuthenticated] serializer_class = ProblemStudentSerializer pagination_class = ProblemPagination 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()) @@ -1402,7 +1568,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,檢查修改/刪除權限。 diff --git a/profiles/tests/test_profiles_api.py b/profiles/tests/test_profiles_api.py new file mode 100644 index 00000000..29587af2 --- /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 00000000..6e92c842 --- /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) diff --git a/submissions/admin.py b/submissions/admin.py index 836d58e6..a5d88473 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 00000000..9d4008a6 --- /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', + ), + ] 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 00000000..d6c3d10b --- /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/migrations/0005_alter_submissionresult_options_and_more.py b/submissions/migrations/0005_alter_submissionresult_options_and_more.py new file mode 100644 index 00000000..5689dd5b --- /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 5371e84d..0475068c 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'), @@ -119,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() - test_case_index = models.IntegerField() + test_case_id = models.IntegerField(null=True, blank=True) # CE 時可能為 None # Status and scoring status = models.CharField(max_length=30, choices=STATUS_CHOICES) @@ -149,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): @@ -397,14 +400,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 +408,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 +416,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 +423,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 +454,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/sandbox_client.py b/submissions/sandbox_client.py index 9496f50c..f6bd9243 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 進行判題 @@ -80,31 +140,43 @@ 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, 'time_limit': time_limit, 'memory_limit': memory_limit, - 'use_checker': False, # TODO: 從 problem 設定取得 - 'checker_name': 'diff', - 'use_static_analysis': False, # TODO: 從 assignment 設定取得 + # 只有在啟用自訂 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': problem.use_static_analysis, 'priority': 0, # 一般優先級 - 'callback_url': f'{settings.BACKEND_BASE_URL}/submissions/callback/', # Sandbox 判題完成後回傳結果的 URL + 'callback_url': settings.BACKEND_BASE_URL.rstrip('/'), # Sandbox 判題完成後回傳結果的 URL(注意:是 submission 不是 submissions) } - # 5. 準備檔案 + # 6. 靜態分析設定(從 problem.static_analysis_rules 和 forbidden_functions 組合) + static_analysis_config = build_static_analysis_config(problem) + data.update(static_analysis_config) + + # 7. 網路設定(從 problem.allowed_network) + network_config = build_network_config(problem) + data.update(network_config) + + # 8. 準備檔案 filename = f'solution.{get_file_extension(language)}' file_content = submission.source_code.encode('utf-8') files = { 'file': (filename, BytesIO(file_content), 'text/plain') } - # 6. 發送請求 + # 9. 發送請求 url = f'{SANDBOX_API_URL}/api/v1/submissions' logger.info(f'Submitting to Sandbox: submission_id={submission.id}, problem_id={submission.problem_id}') @@ -112,6 +184,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, @@ -121,7 +200,10 @@ 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}') response.raise_for_status() result = response.json() @@ -157,8 +239,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()}" @@ -182,11 +272,19 @@ 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': 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} + + # 靜態分析設定(從 problem.static_analysis_rules 和 forbidden_functions 組合) + static_analysis_config = build_static_analysis_config(problem) + data.update(static_analysis_config) + + # 網路設定(從 problem.allowed_network) + network_config = build_network_config(problem) + data.update(network_config) + # 準備檔案 filename = f'solution.{get_file_extension(language)}' files = { diff --git a/submissions/serializers.py b/submissions/serializers.py index 4deee399..8d44b1c5 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): @@ -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 @@ -368,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/get_test_token.py b/submissions/test_file/get_test_token.py index dcc1a2f0..e63017c6 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/test_file/test_callback_apis.py b/submissions/test_file/test_callback_apis.py index 6f972d55..d0b09888 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 00000000..369785fd --- /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/test_file/test_custom_test.py b/submissions/test_file/test_custom_test.py index 9659956c..289b2d7e 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_problem1_submission.py b/submissions/test_file/test_problem1_submission.py new file mode 100644 index 00000000..7802b198 --- /dev/null +++ b/submissions/test_file/test_problem1_submission.py @@ -0,0 +1,440 @@ +#!/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', '') +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}") + 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': settings.BACKEND_BASE_URL.rstrip('/'), + } + + 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 連線 ║ +╚══════════════════════════════════════════════════════════════════╝ + """) + + # 顯示環境變數設定 + print_env_variables() + + # 步驟 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 diff --git a/submissions/test_file/test_sandbox_integration.py b/submissions/test_file/test_sandbox_integration.py index 885f2967..30829f6b 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): @@ -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: diff --git a/submissions/test_file/test_submission_views_api.py b/submissions/test_file/test_submission_views_api.py index a2e0a8c0..b5187d6e 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 ( @@ -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) @@ -1094,6 +1126,282 @@ 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:submission-list-create') + + 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 threading import Thread + + 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) + + # 配額應該被正確執行(只允許 1 次提交) + self.assertEqual(success_count, 1) + self.assertEqual(forbidden_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 777734ec..aa414b4f 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 @@ -11,6 +11,8 @@ from problems.models import Problems, Problem_subtasks, Test_cases from user.models import User import uuid +from datetime import datetime +import logging # 統一的 API 響應格式 def api_response(data=None, message="OK", status_code=200): @@ -41,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 = 'never_tried' + else: + stats.solve_status = 'attempted' + + 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 - 提供通用權限檢查方法""" @@ -161,7 +355,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'] @@ -175,7 +368,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': @@ -203,10 +396,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'] @@ -221,7 +428,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'] @@ -267,6 +473,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'] @@ -274,7 +516,6 @@ def perform_update(self, serializer): @api_view(['POST', 'DELETE']) -@permission_classes([permissions.IsAuthenticated]) def editorial_like_toggle(request, problem_id, solution_id): """題解按讚/取消按讚""" @@ -400,9 +641,10 @@ class SubmissionListCreateView(BasePermissionMixin, generics.ListCreateAPIView): """ GET /submission/ - 獲取提交列表 POST /submission/ - 創建新提交 + POST /submission/ - 還沒實作黑名單 + """ - permission_classes = [permissions.IsAuthenticated] def get_serializer_class(self): if self.request.method == 'POST': @@ -411,8 +653,116 @@ 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') # Unix 時間戳記 (秒) + after = self.request.query_params.get('after') # Unix 時間戳記 (秒) + ip_prefix = self.request.query_params.get('ip_prefix') # IP 網段前綴 + + # 套用篩選條件 + 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 (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: + try: + queryset = queryset.filter(language_type=int(language_type)) + except ValueError: + pass # 忽略無效的語言類型 + + if before: + try: + # 將 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: + # 將 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 # 忽略無效的時間格式 + + # IP 網段前綴篩選 + if ip_prefix: + try: + # 支援 CIDR 格式 (例如 192.168.1.0/24) 或簡單前綴 (例如 192.168.) + if '/' in ip_prefix: + # CIDR 格式:使用 ipaddress 模組進行正確的 IP 範圍過濾 + import ipaddress + from django.db.models import Q + import struct + import socket + + network = ipaddress.ip_network(ip_prefix, strict=False) + + # 使用 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: + 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): + """獲取客戶端 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 兼容版本)""" @@ -442,19 +792,171 @@ 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) - # 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 + # 確保用戶已通過身份驗證(防止 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 + 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 problems.models import Problems + from .models import UserProblemQuota + 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, Course_members + course = problem.course_id + # 若題目未關聯任何課程,則不應允許提交 + if course is None: + 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, + message="Unexisted problem id.", + status_code=status.HTTP_404_NOT_FOUND + ) - submission = serializer.save() + # 4. Quota 檢查 - 檢查用戶是否還有提交配額 + # 先檢查題目層級的 total_quota 設定 + problem_quota = problem.total_quota # -1 = 無限制, >= 0 = 有限制 + + # 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 # Global quota (no assignment) + ) + 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 IntegrityError: + # 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 與題目設定不同,可能是題目設定更新了 + # 這裡我們不自動更新,保持原有配額(避免用戶突然獲得更多或更少配額) + + if quota.remaining_attempts == 0: + return api_response( + data=None, + message="you have used all your quotas", + status_code=status.HTTP_403_FORBIDDEN + ) + + # 減少配額 + # 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: + quota = UserProblemQuota.objects.select_for_update().get( + user=user, + problem_id=problem_id, + assignment_id=None # Global quota (no assignment) + ) + if quota.remaining_attempts == 0: + return api_response( + data=None, + 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 + + # Save submission within the same transaction to ensure atomicity + submission = serializer.save() # NOJ 格式響應 return api_response( @@ -468,11 +970,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 兼容版本)""" @@ -510,7 +1015,6 @@ class SubmissionRetrieveUpdateView(BasePermissionMixin, generics.RetrieveUpdateA PUT /submission/{id} - 上傳程式碼 """ - permission_classes = [permissions.IsAuthenticated] lookup_field = 'id' def get_serializer_class(self): @@ -614,7 +1118,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} @@ -641,22 +1144,58 @@ 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: 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}') + # 即使找不到 test_case,也繼續用 test_case_index 查詢 # 5. 找結果 + # 重要:查詢需要 subtask_id + test_case_index(因為 test_case_index 只是相對於 subtask 的編號) try: - result = SubmissionResult.objects.get( + result = SubmissionResult.objects.filter( submission_id=submission.id, - test_case_id=test_case.id + subtask_id=subtask.id, + test_case_index=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"subtask_id={r.subtask_id}, case_index={r.test_case_index}") + + error_detail = ( + f"找不到測資結果。" + 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) subtask_id 不匹配 3) case_no 超出範圍" + ) + + return api_response( + None, + error_detail, + 404 + ) + except Exception as e: + logger.error(f'Error fetching submission result: {str(e)}') + return api_response( + None, + f"查詢測資結果時發生錯誤:{str(e)}", + 404 ) - except SubmissionResult.DoesNotExist: - return api_response(None, "output not found", 404) # 6. 回傳 payload = { @@ -681,7 +1220,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') @@ -719,7 +1257,6 @@ class SubmissionDetailView(BasePermissionMixin, generics.RetrieveAPIView): """GET /submission/{id} - 獲取提交詳情""" serializer_class = SubmissionDetailSerializer - permission_classes = [permissions.IsAuthenticated] lookup_field = 'id' def get_queryset(self): @@ -750,7 +1287,6 @@ class SubmissionCodeView(BasePermissionMixin, generics.RetrieveAPIView): """GET /submission/{id}/code - 獲取提交程式碼""" serializer_class = SubmissionCodeSerializer - permission_classes = [permissions.IsAuthenticated] lookup_field = 'id' def get_queryset(self): @@ -785,7 +1321,6 @@ class SubmissionStdoutView(BasePermissionMixin, generics.RetrieveAPIView): """GET /submission/{id}/stdout - 獲取提交標準輸出""" serializer_class = SubmissionStdoutSerializer - permission_classes = [permissions.IsAuthenticated] lookup_field = 'id' def get_queryset(self): @@ -813,7 +1348,6 @@ def retrieve(self, request, *args, **kwargs): @api_view(['GET']) -@permission_classes([permissions.IsAuthenticated]) def submission_rejudge(request, id): """GET /submission/{id}/rejudge - 重新判題""" @@ -870,7 +1404,6 @@ def submission_rejudge(request, id): @api_view(['GET']) -@permission_classes([permissions.IsAuthenticated]) def ranking_view(request): """GET /ranking - 獲取排行榜""" @@ -899,6 +1432,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 +1446,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 }, @@ -926,7 +1467,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} - 使用者統計 @@ -1050,7 +1590,6 @@ def get_diff_count(name): @api_view(['POST']) -@permission_classes([permissions.IsAuthenticated]) def submit_custom_test(request, problem_id): """ 提交自定義測試(不存資料庫) @@ -1238,7 +1777,6 @@ def submit_custom_test(request, problem_id): @api_view(['GET']) -@permission_classes([permissions.IsAuthenticated]) def get_custom_test_result(request, custom_test_id): """ 查詢自定義測試結果 @@ -1383,7 +1921,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 認證 @@ -1440,7 +1978,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( @@ -1478,28 +2017,155 @@ def post(self, request): logger.info(f'Updated submission {submission_id}: status={submission.status}, score={total_score}') # 4. 建立 SubmissionResult 記錄 - for test_result in test_results: - # 轉換 status 為字串格式(SubmissionResult 使用字串) - result_status = test_result.get('status', 'runtime_error') - - SubmissionResult.objects.create( - submission=submission, # 使用 submission 而非 submission_id - 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 是字串類型 - 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'), - ) + from problems.models import Problem_subtasks, Test_cases - logger.info(f'Created {len(test_results)} test results for submission {submission_id}') + # 特殊處理:如果是 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 或使用預設值) + 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) + + # 為每個測資創建或更新錯誤記錄 + 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, # 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}') + 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: + 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: + # 正常情況:處理每個測資結果 + 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') + + # 重要:Sandbox 文件中的欄位映射 + # - 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) # 測資編號 + + logger.info(f'Processing test_result: subtask_no={subtask_no} (type={type(subtask_no)}), test_case_index={test_case_index}, status={result_status}') + + 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( + problem_id=submission.problem_id, + 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: + # 容錯:如果找不到,嘗試 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( + submission=submission, + subtask_id=subtask_id, + test_case_index=test_case_index, + 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'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}') - # 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', 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 00000000..b338f9c4 --- /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 00000000..f768ae06 --- /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 00000000..555d2880 --- /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 00000000..77774705 --- /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()}") diff --git a/user/management/__init__.py b/user/management/__init__.py new file mode 100644 index 00000000..a94f2a33 --- /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 00000000..2c1c7c15 --- /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 00000000..c19e07e2 --- /dev/null +++ b/user/management/commands/seed_data.py @@ -0,0 +1,844 @@ +""" +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.""" + 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() + 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 + 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', 45), # Accepted - 45% + ('1', 25), # Wrong Answer - 25% + ('3', 10), # TLE - 10% + ('5', 10), # Runtime Error - 10% + ('2', 7), # Compilation Error - 7% + ('-1', 3), # Pending - 3% (少數情況) + ] + + 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), + ) + + # 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': + problem.accepted_submissions += 1 + problem.recompute_acceptance_rate(save=True) + + submissions.append(submission) + + 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 + '2': 'wrong_answer', # CE - 標記為 wrong_answer (編譯失敗) + '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' + error_msg = None + elif overall_status == '2': + # 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.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, + submission=submission, + subtask_id=test_case.subtask_id.id, # 必填:subtask 的資料庫 ID + test_case_id=test_case.id, + 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), + score=tc_score, + max_score=score_per_case, + output_preview=self._generate_output_preview(tc_status) if overall_status != '2' else None, + error_message=error_msg, + 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 = [] + 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), + } + )