diff --git a/.gitignore b/.gitignore index f176aac0..3a3ad837 100644 --- a/.gitignore +++ b/.gitignore @@ -87,8 +87,12 @@ celerybeat.pid # Database *.sql +*.sql.gz +*.dump +*.backup *.sqlite *.db +backups/ artifacts/db_backups/ # Migrations (optional - uncomment if you want to ignore migrations) diff --git a/backend/apps/contests/exporters/data_service.py b/backend/apps/contests/exporters/data_service.py index afa05b45..666c3c35 100644 --- a/backend/apps/contests/exporters/data_service.py +++ b/backend/apps/contests/exporters/data_service.py @@ -105,11 +105,12 @@ def get_participants(self) -> List[ParticipantDTO]: result = [] for p in participants: + profile = getattr(p.user, 'profile', None) result.append(ParticipantDTO( user_id=p.user.id, username=p.user.username, email=getattr(p.user, 'email', ''), - nickname=getattr(p, 'nickname', '') or '', + display_name=getattr(profile, 'display_name', '') or '', exam_status=getattr(p, 'exam_status', ''), started_at=getattr(p, 'started_at', None), left_at=getattr(p, 'left_at', None), @@ -178,7 +179,7 @@ def calculate_standings(self, user_id: Optional[int] = None) -> StandingsDTO: stats[p.user_id] = UserStandingDTO( user_id=p.user_id, username=p.username, - display_name=p.nickname or p.username, + display_name=p.display_name or p.username, problems=problem_stats, ) diff --git a/backend/apps/contests/exporters/dto.py b/backend/apps/contests/exporters/dto.py index a9596022..db3d8a9b 100644 --- a/backend/apps/contests/exporters/dto.py +++ b/backend/apps/contests/exporters/dto.py @@ -74,7 +74,7 @@ class ParticipantDTO: user_id: int username: str email: str = '' - nickname: str = '' + display_name: str = '' exam_status: str = '' started_at: Optional[datetime] = None left_at: Optional[datetime] = None diff --git a/backend/apps/contests/migrations/0077_remove_contest_anonymous_mode.py b/backend/apps/contests/migrations/0077_remove_contest_anonymous_mode.py new file mode 100644 index 00000000..8dd96514 --- /dev/null +++ b/backend/apps/contests/migrations/0077_remove_contest_anonymous_mode.py @@ -0,0 +1,19 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("contests", "0076_unique_exam_question_order"), + ] + + operations = [ + migrations.RemoveField( + model_name="contest", + name="anonymous_mode_enabled", + ), + migrations.RemoveField( + model_name="contestparticipant", + name="nickname", + ), + ] diff --git a/backend/apps/contests/models.py b/backend/apps/contests/models.py index 6c78b552..c5d2cd12 100644 --- a/backend/apps/contests/models.py +++ b/backend/apps/contests/models.py @@ -215,13 +215,6 @@ class QuestionEditLockTrigger(models.TextChoices): help_text='TA 完成批改後手動設為 True,學生才能查看成績' ) - # Anonymous mode settings - anonymous_mode_enabled = models.BooleanField( - default=False, - verbose_name='啟用匿名模式', - help_text='啟用後學生可使用暱稱參與競賽,排行榜和提交列表顯示暱稱' - ) - created_at = models.DateTimeField(auto_now_add=True, verbose_name='建立時間') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新時間') @@ -483,15 +476,6 @@ class ContestParticipant(models.Model): help_text='手動或系統自動交卷原因' ) - # Anonymous mode nickname - nickname = models.CharField( - max_length=50, - blank=True, - default='', - verbose_name='暱稱', - help_text='匿名模式下顯示的名稱,預設為用戶名' - ) - # Explicit exam state (primary state field for UI) exam_status = models.CharField( max_length=20, diff --git a/backend/apps/contests/serializers.py b/backend/apps/contests/serializers.py index e56b4c83..cfd03bb7 100644 --- a/backend/apps/contests/serializers.py +++ b/backend/apps/contests/serializers.py @@ -95,7 +95,6 @@ class ContestDetailSerializer(serializers.ModelSerializer): accepted_at = serializers.SerializerMethodField() submitted_at = serializers.SerializerMethodField() auto_unlock_at = serializers.SerializerMethodField() - my_nickname = serializers.SerializerMethodField() problems = serializers.SerializerMethodField() participant_count = serializers.SerializerMethodField() admins = serializers.SerializerMethodField() @@ -139,7 +138,6 @@ class Meta: 'created_at', 'updated_at', # Computed fields - 'my_nickname', 'current_user_role', 'permissions', 'has_joined', @@ -159,7 +157,6 @@ class Meta: 'max_cheat_warnings', 'allow_auto_unlock', 'auto_unlock_minutes', - 'anonymous_mode_enabled', 'participant_count', 'admins', 'is_classroom_bound', @@ -193,11 +190,6 @@ def _get_current_registration(self, obj): cache[cache_key] = obj.registrations.filter(user=user).first() return cache[cache_key] - def get_my_nickname(self, obj): - """Get nickname for current user.""" - registration = self._get_current_registration(obj) - return registration.nickname if registration else None - def get_lock_reason(self, obj): """Get lock reason for current user.""" registration = self._get_current_registration(obj) @@ -414,7 +406,6 @@ class Meta: 'allow_auto_unlock', 'auto_unlock_minutes', 'status', - 'anonymous_mode_enabled', 'results_published', ] read_only_fields = ['id'] @@ -864,25 +855,8 @@ class Meta: read_only_fields = ['author', 'status', 'answered_at', 'author_username', 'author_display_name', 'problem_title'] def get_author_display_name(self, obj): - """根據匿名模式返回適當的作者名稱""" - request = self.context.get('request') - contest = obj.contest - - if not contest.anonymous_mode_enabled: - return obj.author.username - - # 管理者可見真實名稱 - if request and request.user.is_authenticated: - from .permissions import get_user_role_in_contest - role = get_user_role_in_contest(request.user, contest) - if role in ['admin', 'owner', 'teacher']: - return obj.author.username - - # 查找該用戶的暱稱 - participant = ContestParticipant.objects.filter( - contest=contest, user=obj.author - ).first() - return participant.nickname if participant else obj.author.username + profile = getattr(obj.author, 'profile', None) + return getattr(profile, 'display_name', '') or obj.author.username class ClarificationCreateSerializer(serializers.ModelSerializer): @@ -1070,9 +1044,7 @@ class ContestParticipantSerializer(serializers.ModelSerializer): user = UserSerializer(read_only=True) user_id = serializers.IntegerField(source='user.id', read_only=True) username = serializers.CharField(source='user.username', read_only=True) - nickname = serializers.CharField(read_only=True) display_name = serializers.SerializerMethodField() - user_display_name = serializers.SerializerMethodField() account_role = serializers.CharField(source='user.role', read_only=True) auth_provider = serializers.CharField(source='user.auth_provider', read_only=True) total_score = serializers.SerializerMethodField() @@ -1090,7 +1062,7 @@ class Meta: 'user_id', 'username', 'user', 'score', 'total_score', 'rank', 'joined_at', 'exam_status', 'lock_reason', 'violation_count', 'submit_reason', 'auto_unlock_at', 'remaining_unlock_seconds', - 'nickname', 'display_name', 'user_display_name', 'account_role', 'auth_provider', + 'display_name', 'account_role', 'auth_provider', 'connection_status', 'last_heartbeat_at', 'live_monitoring_online', 'live_monitoring_sources', ] @@ -1180,23 +1152,8 @@ def get_total_score(self, obj): return total def get_display_name(self, obj): - """根據權限返回適當的顯示名稱""" - request = self.context.get('request') - contest = obj.contest - - # 非匿名模式,直接返回真實用戶名 - if not contest.anonymous_mode_enabled: - return obj.user.username - - # 管理者可見真實名稱 - if request and request.user.is_authenticated: - from .permissions import get_user_role_in_contest - role = get_user_role_in_contest(request.user, contest) - if role in ['admin', 'owner', 'teacher']: - return obj.user.username - - # 其他用戶看暱稱 (nickname 預設為 username) - return obj.nickname or obj.user.username + profile = getattr(obj.user, 'profile', None) + return getattr(profile, 'display_name', '') or "" def get_auto_unlock_at(self, obj): """Calculate auto unlock time if applicable.""" @@ -1218,13 +1175,6 @@ def get_remaining_unlock_seconds(self, obj): return int((unlock_at - now).total_seconds()) - def get_user_display_name(self, obj): - profile = getattr(obj.user, 'profile', None) - if not profile: - return "" - return profile.display_name or "" - - # ============================================================================ # Exam Answer Serializers # ============================================================================ @@ -1258,7 +1208,7 @@ class ExamAnswerDetailSerializer(serializers.ModelSerializer): ) participant_user_id = serializers.SerializerMethodField() participant_username = serializers.SerializerMethodField() - participant_nickname = serializers.SerializerMethodField() + participant_display_name = serializers.SerializerMethodField() class Meta: model = ExamAnswer @@ -1268,7 +1218,7 @@ class Meta: 'answer', 'is_correct', 'score', 'feedback', 'question_snapshot', 'graded_by_username', 'graded_at', - 'participant_user_id', 'participant_username', 'participant_nickname', + 'participant_user_id', 'participant_username', 'participant_display_name', 'created_at', 'updated_at', ] read_only_fields = fields @@ -1304,15 +1254,16 @@ def get_participant_user_id(self, obj): def get_participant_username(self, obj): return obj.participant.user.username - def get_participant_nickname(self, obj): - return obj.participant.nickname or obj.participant.user.username + def get_participant_display_name(self, obj): + profile = getattr(obj.participant.user, 'profile', None) + return getattr(profile, 'display_name', '') or "" class ExamAnswerGradingSerializer(serializers.ModelSerializer): """Slim serializer for grading screens. Drops redundant per-row duplicates (question_prompt/type/options/explanation/ - max_score/question_snapshot and participant_username/nickname). Consumers + max_score/question_snapshot and participant_username/display_name). Consumers should join question info via GET /exam-questions/ and participant info via contest participants list — both are O(题数) / O(学生数) rather than O(answers).""" diff --git a/backend/apps/contests/services/exam_validation.py b/backend/apps/contests/services/exam_validation.py index 8fa76c62..77aad9e4 100644 --- a/backend/apps/contests/services/exam_validation.py +++ b/backend/apps/contests/services/exam_validation.py @@ -27,10 +27,10 @@ def validate_exam_operation(contest, user, require_in_progress=False, allow_admi if allow_admin_bypass and can_manage_contest(user, contest): try: participant = ContestParticipant.objects.get(contest=contest, user=user) - return participant, None + return participant except ContestParticipant.DoesNotExist: # Managers don't need to be registered - return None, None + return None # Layer 1: Contest status if contest.status != 'published': diff --git a/backend/apps/contests/services/export_service.py b/backend/apps/contests/services/export_service.py index 035dba57..9b3e216e 100644 --- a/backend/apps/contests/services/export_service.py +++ b/backend/apps/contests/services/export_service.py @@ -163,7 +163,8 @@ def build_paper_exam_results_csv_response(contest): participant_count = len(students) for p in students: - display_name = p.nickname or p.user.username + profile = getattr(p.user, 'profile', None) + display_name = getattr(profile, 'display_name', '') or '' status_label = p.get_exam_status_display() p_answers = answer_lookup.get(p.id, {}) graded_count = sum( diff --git a/backend/apps/contests/services/participant_dashboard.py b/backend/apps/contests/services/participant_dashboard.py index aa7a2ab9..871e6bfb 100644 --- a/backend/apps/contests/services/participant_dashboard.py +++ b/backend/apps/contests/services/participant_dashboard.py @@ -79,9 +79,7 @@ def _serialize_participant(participant: ContestParticipant) -> dict[str, Any]: return { "user_id": participant.user_id, "username": participant.user.username, - "nickname": participant.nickname or participant.user.username, - "display_name": participant.nickname or participant.user.username, - "user_display_name": getattr(profile, "display_name", "") or "", + "display_name": getattr(profile, "display_name", "") or "", "account_role": getattr(participant.user, "role", "student"), "auth_provider": getattr(participant.user, "auth_provider", "email"), "email": getattr(participant.user, "email", ""), diff --git a/backend/apps/contests/services/scoreboard.py b/backend/apps/contests/services/scoreboard.py index 427e90ed..dc477ba4 100644 --- a/backend/apps/contests/services/scoreboard.py +++ b/backend/apps/contests/services/scoreboard.py @@ -88,7 +88,6 @@ def calculate(contest: Contest, user_scope: ScoreboardScope) -> ScoreboardResult stats[participant.user.id] = { "user": UserSerializer(participant.user).data, "display_name": display_name, - "nickname": participant.nickname, "solved": 0, "rank": participant.rank, "score": participant.score, @@ -177,11 +176,5 @@ def _build_display_name( is_privileged: bool, use_export_display: bool, ) -> str: - if use_export_display: - return participant.nickname or participant.user.username - - if not contest.anonymous_mode_enabled: - return participant.user.username - if is_privileged: - return participant.user.username - return participant.nickname or participant.user.username + profile = getattr(participant.user, "profile", None) + return getattr(profile, "display_name", "") or participant.user.username diff --git a/backend/apps/contests/tests/exam/test_exam_answers.py b/backend/apps/contests/tests/exam/test_exam_answers.py index 6ab1ac42..7d000f96 100644 --- a/backend/apps/contests/tests/exam/test_exam_answers.py +++ b/backend/apps/contests/tests/exam/test_exam_answers.py @@ -441,6 +441,38 @@ def test_teacher_view_dashboard_summary(self): self.assertEqual(essay_summary['subjective_stats']['graded_count'], 1) self.assertEqual(essay_summary['subjective_stats']['pending_count'], 1) + def test_participant_can_view_dashboard_summary_after_results_published(self): + self.contest.results_published = True + self.contest.save(update_fields=['results_published']) + ExamAnswer.objects.create( + participant=self.participant, + question=self.q_single, + answer={'selected': 'B'}, + is_correct=True, + score=5, + ) + + self.client.force_authenticate(user=self.student) + url = reverse( + 'contests:contest-exam-answers-dashboard-summary', + kwargs={'contest_pk': self.contest.id}, + ) + resp = self.client.get(url) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data['contest']['participant_count'], 1) + self.assertEqual(resp.data['score_distribution'][-1]['range_label'], '90-100%') + + def test_participant_cannot_view_dashboard_summary_before_results_published(self): + self.client.force_authenticate(user=self.student) + url = reverse( + 'contests:contest-exam-answers-dashboard-summary', + kwargs={'contest_pk': self.contest.id}, + ) + resp = self.client.get(url) + + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + def test_dashboard_summary_cache_invalidates_on_submission(self): self.client.force_authenticate(user=self.teacher) summary_url = reverse( diff --git a/backend/apps/contests/tests/exam/test_exam_state.py b/backend/apps/contests/tests/exam/test_exam_state.py index 2a3c9641..524c5984 100644 --- a/backend/apps/contests/tests/exam/test_exam_state.py +++ b/backend/apps/contests/tests/exam/test_exam_state.py @@ -38,6 +38,17 @@ def test_start_exam(self): p = ContestParticipant.objects.get(user=self.user, contest=self.contest) self.assertEqual(p.exam_status, ExamStatus.IN_PROGRESS) + + def test_owner_participant_can_start_exam(self): + ContestParticipant.objects.create(contest=self.contest, user=self.admin) + self.client.force_authenticate(user=self.admin) + + url = reverse('contests:contest-exam-start-exam', args=[self.contest.id]) + response = self.client.post(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + p = ContestParticipant.objects.get(user=self.admin, contest=self.contest) + self.assertEqual(p.exam_status, ExamStatus.IN_PROGRESS) def test_end_exam(self): # Start first diff --git a/backend/apps/contests/tests/management/test_contest_viewset_actions.py b/backend/apps/contests/tests/management/test_contest_viewset_actions.py index fd64de60..3b630736 100644 --- a/backend/apps/contests/tests/management/test_contest_viewset_actions.py +++ b/backend/apps/contests/tests/management/test_contest_viewset_actions.py @@ -276,7 +276,7 @@ def test_owner_can_list_participants( assert response.status_code == status.HTTP_200_OK assert len(response.data) == 1 assert response.data[0]["user"]["id"] == student.id - assert response.data[0]["user_display_name"] == "Student Display" + assert response.data[0]["display_name"] == "Student Display" assert response.data[0]["account_role"] == student.role assert response.data[0]["auth_provider"] == student.auth_provider @@ -455,37 +455,6 @@ def test_register_rejects_non_published_contest( assert response.data["message"] == "Contest is not published" -@pytest.mark.django_db -def test_update_nickname_handles_not_registered_and_blank_nickname( - api_client: APIClient, - contest: Contest, - student: User, -) -> None: - contest.anonymous_mode_enabled = True - contest.save(update_fields=["anonymous_mode_enabled"]) - api_client.force_authenticate(user=student) - - not_registered = api_client.post( - f"/api/v1/contests/{contest.id}/update_nickname/", - {"nickname": "newname"}, - format="json", - ) - assert not_registered.status_code == status.HTTP_400_BAD_REQUEST - assert not_registered.data["error"] == "Not registered for this contest" - - participant = ContestParticipant.objects.create(contest=contest, user=student, nickname="old") - response = api_client.post( - f"/api/v1/contests/{contest.id}/update_nickname/", - {"nickname": " "}, - format="json", - ) - - assert response.status_code == status.HTTP_200_OK - participant.refresh_from_db() - assert participant.nickname == student.username - assert response.data["nickname"] == student.username - - @pytest.mark.django_db def test_enter_privileged_user_and_leave_without_registration( api_client: APIClient, diff --git a/backend/apps/contests/tests/participation/test_participation.py b/backend/apps/contests/tests/participation/test_participation.py index b735c5a9..4fb18049 100644 --- a/backend/apps/contests/tests/participation/test_participation.py +++ b/backend/apps/contests/tests/participation/test_participation.py @@ -252,30 +252,3 @@ def test_enter_and_leave_contest(self): # Try to enter again response = self.client.post(url_enter) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_update_nickname_requires_anonymous_mode(self): - ContestParticipant.objects.create(contest=self.public_contest, user=self.user) - url = reverse('contests:contest-update-nickname', args=[self.public_contest.id]) - response = self.client.post(url, {'nickname': 'alias'}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data.get('error'), 'Anonymous mode is not enabled for this contest') - - def test_update_nickname_rejects_long_value(self): - contest = Contest.objects.create( - name='Anonymous Contest', - start_time=timezone.now(), - end_time=timezone.now() + timedelta(hours=2), - owner=self.admin, - visibility='public', - status='published', - anonymous_mode_enabled=True, - ) - ContestParticipant.objects.create(contest=contest, user=self.user) - long_nickname = 'n' * 51 - url = reverse('contests:contest-update-nickname', args=[contest.id]) - response = self.client.post(url, {'nickname': long_nickname}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data.get('error'), - 'Nickname is too long (max 50 characters)', - ) diff --git a/backend/apps/contests/tests/standings/test_contest_legacy_flows.py b/backend/apps/contests/tests/standings/test_contest_legacy_flows.py index a1a27795..4269b04a 100644 --- a/backend/apps/contests/tests/standings/test_contest_legacy_flows.py +++ b/backend/apps/contests/tests/standings/test_contest_legacy_flows.py @@ -62,7 +62,6 @@ class Meta: contest = factory.SubFactory(ContestFactory) user = factory.SubFactory(UserFactory) exam_status = ExamStatus.IN_PROGRESS - nickname = "" class ContestProblemBindingFactory(factory.django.DjangoModelFactory): @@ -223,53 +222,6 @@ def test_standings_shows_problem_titles_for_admins( assert problem.get("title") is not None -@pytest.mark.django_db -def test_anonymous_mode_uses_nickname_for_students( - api_client: APIClient, -) -> None: - teacher = UserFactory(role="teacher") - viewer = UserFactory() - other = UserFactory() - contest, _ = create_contest_with_problem( - owner=teacher, - scoreboard_visible_during_contest=True, - anonymous_mode_enabled=True, - ) - ContestParticipantFactory(contest=contest, user=viewer, nickname="alpha") - ContestParticipantFactory(contest=contest, user=other, nickname="beta") - - api_client.force_authenticate(user=viewer) - response = api_client.get(f"/api/v1/contests/{contest.id}/standings/") - - assert response.status_code == 200 - display_names = {entry["display_name"] for entry in response.data.get("standings", [])} - assert "alpha" in display_names - assert "beta" in display_names - - -@pytest.mark.django_db -def test_anonymous_mode_admin_sees_real_names( - api_client: APIClient, -) -> None: - teacher = UserFactory(role="teacher") - admin = UserFactory(role="admin", is_staff=True) - participant = UserFactory() - contest, _ = create_contest_with_problem( - owner=teacher, - scoreboard_visible_during_contest=True, - anonymous_mode_enabled=True, - ) - ContestParticipantFactory(contest=contest, user=participant, nickname="alias") - - api_client.force_authenticate(user=admin) - response = api_client.get(f"/api/v1/contests/{contest.id}/standings/") - - assert response.status_code == 200 - display_names = {entry["display_name"] for entry in response.data.get("standings", [])} - assert participant.username in display_names - assert "alias" not in display_names - - @pytest.mark.django_db def test_standings_excludes_test_submissions_from_score( api_client: APIClient, diff --git a/backend/apps/contests/tests/test_exam_service_boundaries.py b/backend/apps/contests/tests/test_exam_service_boundaries.py index aa28be50..0edda7c1 100644 --- a/backend/apps/contests/tests/test_exam_service_boundaries.py +++ b/backend/apps/contests/tests/test_exam_service_boundaries.py @@ -92,6 +92,45 @@ def test_validate_exam_operation_returns_participant_on_success_and_raises_valid ) == participant +@pytest.mark.django_db +def test_validate_exam_operation_admin_bypass_returns_participant_not_tuple( + teacher: User, + published_contest: Contest, +): + participant = ContestParticipant.objects.create( + contest=published_contest, + user=teacher, + exam_status=ExamStatus.NOT_STARTED, + ) + + assert validate_exam_operation(published_contest, teacher) == participant + + adapted_participant, response = validate_exam_operation_for_view( + published_contest, + teacher, + ) + + assert response is None + assert adapted_participant == participant + assert not isinstance(adapted_participant, tuple) + + +@pytest.mark.django_db +def test_validate_exam_operation_admin_bypass_without_participant_returns_none( + teacher: User, + published_contest: Contest, +): + assert validate_exam_operation(published_contest, teacher) is None + + adapted_participant, response = validate_exam_operation_for_view( + published_contest, + teacher, + ) + + assert adapted_participant is None + assert response is None + + @pytest.mark.django_db def test_validate_exam_operation_view_adapter_preserves_legacy_error_response( teacher: User, diff --git a/backend/apps/contests/tests/test_participant_dashboard_api.py b/backend/apps/contests/tests/test_participant_dashboard_api.py index 6898b2df..f51cbbb3 100644 --- a/backend/apps/contests/tests/test_participant_dashboard_api.py +++ b/backend/apps/contests/tests/test_participant_dashboard_api.py @@ -63,7 +63,6 @@ def _create_participant(self, contest: Contest) -> ContestParticipant: user=self.student, exam_status=ExamStatus.IN_PROGRESS, started_at=timezone.now() - timedelta(minutes=20), - nickname="Stu Dashboard", score=12, violation_count=2, ) diff --git a/backend/apps/contests/views/contest.py b/backend/apps/contests/views/contest.py index 17b16314..c80a9c43 100644 --- a/backend/apps/contests/views/contest.py +++ b/backend/apps/contests/views/contest.py @@ -212,7 +212,6 @@ def _ensure_classroom_bound_participant(self, contest: Contest, user): if contest.delivery_mode == "practice" else AssignmentState.ACCEPTED ), - "nickname": user.username, }, ) return participant, created, None @@ -855,9 +854,6 @@ def register(self, request, pk=None): ) if error_response is not None: return error_response - if participant.nickname != user.username and not participant.nickname: - participant.nickname = user.username - participant.save(update_fields=["nickname"]) if not created: raise DRFValidationError('Already registered') ContestActivityViewSet.log_activity( @@ -871,46 +867,6 @@ def register(self, request, pk=None): status=status.HTTP_201_CREATED, ) - @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated], url_path='update_nickname') - def update_nickname(self, request, pk=None): - """ - Allow user to update their nickname in an anonymous contest. - """ - contest = self.get_object() - user = request.user - - if not contest.anonymous_mode_enabled: - return Response( - {'error': 'Anonymous mode is not enabled for this contest'}, - status=status.HTTP_400_BAD_REQUEST - ) - - try: - participant = ContestParticipant.objects.get(contest=contest, user=user) - except ContestParticipant.DoesNotExist: - return Response( - {'error': 'Not registered for this contest'}, - status=status.HTTP_400_BAD_REQUEST - ) - - nickname = request.data.get('nickname', '').strip() - if not nickname: - nickname = user.username - - if len(nickname) > 50: - return Response( - {'error': 'Nickname is too long (max 50 characters)'}, - status=status.HTTP_400_BAD_REQUEST - ) - - participant.nickname = nickname - participant.save() - - return Response({ - 'status': 'updated', - 'nickname': nickname - }) - @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated]) def enter(self, request, pk=None): """ diff --git a/backend/apps/contests/views/exam_answer.py b/backend/apps/contests/views/exam_answer.py index 86f4ca5f..392a7997 100644 --- a/backend/apps/contests/views/exam_answer.py +++ b/backend/apps/contests/views/exam_answer.py @@ -64,6 +64,21 @@ def _student_participants_qs(self, contest): user__role='student', ).select_related('user') + def _can_view_dashboard_summary(self, user, contest): + if can_manage_contest(user, contest): + return True + if not contest.results_published: + return False + return ContestParticipant.objects.filter( + contest=contest, + user=user, + ).exists() + + @staticmethod + def _participant_display_name(participant): + profile = getattr(participant.user, 'profile', None) + return getattr(profile, 'display_name', '') or '' + @staticmethod def _build_score_distribution(scores, max_score): buckets = [ @@ -151,8 +166,7 @@ def _build_option_distribution(cls, question, answers, participants_by_id): participants.append({ 'participant_id': participant.id, 'username': participant.user.username, - 'nickname': participant.nickname or None, - 'display_name': participant.nickname or participant.user.username, + 'display_name': cls._participant_display_name(participant), }) if isinstance(correct_answer, list): @@ -355,16 +369,17 @@ def _parse_dashboard_kind(cls, raw): @action(detail=False, methods=['get'], url_path='dashboard-summary') def dashboard_summary(self, request, contest_pk=None): - """Contest result dashboard summary (teacher/admin only). + """Contest result dashboard summary. Supports ``?kind=`` to filter the per-question ``questions`` array (e.g. ``?kind=subjective``). Contest-level summary (average, median, score distribution) is always computed over all - questions so numbers stay consistent across callers. + questions so numbers stay consistent across callers. Contest staff can + view it anytime; participants can view it after results are published. """ contest = self._get_contest(contest_pk) - if not can_manage_contest(request.user, contest): - raise PermissionDenied('Only contest staff can view dashboard summary.') + if not self._can_view_dashboard_summary(request.user, contest): + raise PermissionDenied('Results dashboard summary is not available.') kind_filter = self._parse_dashboard_kind(request.query_params.get('kind')) @@ -544,8 +559,7 @@ def question_detail(self, request, contest_pk=None): { 'participant_id': participant.id, 'username': participant.user.username, - 'nickname': participant.nickname or None, - 'display_name': participant.nickname or participant.user.username, + 'display_name': self._participant_display_name(participant), } for participant in participants if participant.id not in answered_participant_ids @@ -560,10 +574,8 @@ def question_detail(self, request, contest_pk=None): 'exam_answer_id': answer['id'], 'participant_id': answer['participant_id'], 'username': participants_by_id[answer['participant_id']].user.username, - 'nickname': participants_by_id[answer['participant_id']].nickname or None, - 'display_name': ( - participants_by_id[answer['participant_id']].nickname - or participants_by_id[answer['participant_id']].user.username + 'display_name': self._participant_display_name( + participants_by_id[answer['participant_id']], ), 'score': float(answer['score']) if answer['score'] is not None else None, 'graded_at': answer['graded_at'], diff --git a/backend/apps/core/management/commands/seed_loadtest_data.py b/backend/apps/core/management/commands/seed_loadtest_data.py index c87614e1..dbfc132d 100644 --- a/backend/apps/core/management/commands/seed_loadtest_data.py +++ b/backend/apps/core/management/commands/seed_loadtest_data.py @@ -292,7 +292,7 @@ def _register_participants(self, contest): for student in students: if student.id not in existing: new_participants.append( - ContestParticipant(contest=contest, user=student, nickname=student.username) + ContestParticipant(contest=contest, user=student) ) if new_participants: ContestParticipant.objects.bulk_create(new_participants, ignore_conflicts=True) diff --git a/backend/apps/submissions/managers.py b/backend/apps/submissions/managers.py index e5ac4946..9a4045b4 100644 --- a/backend/apps/submissions/managers.py +++ b/backend/apps/submissions/managers.py @@ -5,11 +5,8 @@ from django.contrib.auth import get_user_model from django.db import models -from django.db.models import OuterRef, Subquery from django.utils import timezone -from apps.contests.models import ContestParticipant - User = get_user_model() @@ -35,7 +32,6 @@ def optimized_for_list(self) -> "SubmissionQuerySet": "problem__id", "problem__question_asset__title", "contest__id", - "contest__anonymous_mode_enabled", ).select_related("user", "problem", "problem__question_asset", "contest") def visible_to( @@ -55,13 +51,6 @@ def visible_to( and (user.is_staff or getattr(user, "role", "") in ["admin", "teacher"]) ) - if contest_id: - nickname_subquery = ContestParticipant.objects.filter( - contest_id=contest_id, - user_id=OuterRef("user_id"), - ).values("nickname")[:1] - queryset = queryset.annotate(_contest_nickname=Subquery(nickname_subquery)) - if not include_all: if created_after: queryset = queryset.filter(created_at__gte=created_after) diff --git a/backend/apps/submissions/serializers.py b/backend/apps/submissions/serializers.py index 680235ed..8ca89301 100644 --- a/backend/apps/submissions/serializers.py +++ b/backend/apps/submissions/serializers.py @@ -99,27 +99,7 @@ def get_problem_title(self, obj): contest_id = serializers.UUIDField(source='contest.id', read_only=True, allow_null=True) def get_username(self, obj): - """Handle anonymous mode for contests.""" - # If no contest or anonymous mode not enabled, return real username - if not obj.contest or not obj.contest.anonymous_mode_enabled: - return obj.user.username - - request = self.context.get('request') - viewer = request.user if request else None - - # Privileged users or owners see real username - is_privileged = viewer and (viewer.is_staff or getattr(viewer, 'role', '') in ['teacher', 'admin']) - is_owner = viewer and viewer == obj.user - - if is_privileged or is_owner: - return obj.user.username - - # For others, return nickname if available - # Use annotated field from queryset (added via Subquery in ViewSet) - if hasattr(obj, '_contest_nickname') and obj._contest_nickname: - return obj._contest_nickname - - # Fallback to real username + """Return submitter username.""" return obj.user.username class Meta: diff --git a/backend/apps/submissions/tests/test_submission_legacy_flows.py b/backend/apps/submissions/tests/test_submission_legacy_flows.py index 0f67c4aa..31ee0425 100644 --- a/backend/apps/submissions/tests/test_submission_legacy_flows.py +++ b/backend/apps/submissions/tests/test_submission_legacy_flows.py @@ -65,7 +65,6 @@ class Meta: contest = factory.SubFactory(ContestFactory) user = factory.SubFactory(UserFactory) exam_status = ExamStatus.NOT_STARTED - nickname = "" @pytest.fixture diff --git a/backend/apps/submissions/tests/test_submission_service.py b/backend/apps/submissions/tests/test_submission_service.py index c4f01b85..ecb638e8 100644 --- a/backend/apps/submissions/tests/test_submission_service.py +++ b/backend/apps/submissions/tests/test_submission_service.py @@ -73,7 +73,6 @@ class Meta: contest = factory.SubFactory(ContestFactory) user = factory.SubFactory(UserFactory) exam_status = ExamStatus.IN_PROGRESS - nickname = "" # --------------------------------------------------------------------------- diff --git a/docs/superpowers/plans/2026-05-05-contest-conversation-implementation.md b/docs/superpowers/plans/2026-05-05-contest-conversation-implementation.md new file mode 100644 index 00000000..3b8b750a --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-contest-conversation-implementation.md @@ -0,0 +1,2603 @@ +# 競賽公告與對話功能 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 取代既有 Clarification 模型為雙向 Conversation thread,新增 navbar 通知鈴鐺,並讓教師於 proctoring panel 對特定學生發訊息。 + +**Architecture:** +- Backend:新增 `ContestConversation` + `ContestMessage`,`(contest, student)` unique;同 migration 刪除舊 `Clarification` 表 +- Frontend:拆掉 `ContestClarifications.tsx` 巨檔,新增 `discussion/` 與 `notification/` 兩個元件夾、兩個 hook(`useContestConversations`、`useContestUnread`),鈴鐺掛在 `ContestLayout` 的 `HeaderGlobalBar` +- 已讀狀態用 localStorage(無後端 read receipt) + +**Tech Stack:** Django 5 + DRF(後端)、React 18 + Carbon Design System + react-router-dom + react-i18next(前端)、pytest + RTL/Vitest(測試) + +**參考文件:** `docs/superpowers/specs/2026-05-05-contest-announcement-qna-design.md` + +--- + +## Phase 1:Backend 模型 + Migration + +### Task 1: 新增 Conversation/Message model 並移除 Clarification + +**Files:** +- Modify: `backend/apps/contests/models.py` +- Modify: `backend/apps/contests/admin.py` +- Create: `backend/apps/contests/migrations/0078_drop_clarification_add_conversation.py` + +- [ ] **Step 1:在 `models.py` 中刪除 `Clarification` class** + +定位 `backend/apps/contests/models.py:553-610`,整段 `class Clarification(models.Model)` 連同其 Meta 與 `__str__` 全部刪除。 + +- [ ] **Step 2:在 `models.py` 末尾、`ContestActivity` class 之前新增兩個新 model** + +```python +class ContestConversation(models.Model): + """ + Single conversation thread between a student and the teaching team. + Unique per (contest, student) - one ongoing conversation per pair. + """ + contest = models.ForeignKey( + Contest, + on_delete=models.CASCADE, + related_name='conversations', + verbose_name='競賽', + ) + student = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='contest_conversations', + verbose_name='學生', + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name='建立時間') + last_message_at = models.DateTimeField(verbose_name='最後一則訊息時間') + + class Meta: + db_table = 'contest_conversations' + verbose_name = '競賽對話' + verbose_name_plural = '競賽對話' + unique_together = [('contest', 'student')] + ordering = ['-last_message_at'] + + def __str__(self): + return f"Conversation #{self.id} ({self.student.username} in contest {self.contest_id})" + + +class ContestMessage(models.Model): + """ + A single message inside a ContestConversation thread. + """ + SENDER_ROLE_CHOICES = [ + ('student', 'Student'), + ('teacher', 'Teacher'), + ] + conversation = models.ForeignKey( + ContestConversation, + on_delete=models.CASCADE, + related_name='messages', + verbose_name='對話', + ) + sender = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name='sent_contest_messages', + verbose_name='發送者', + ) + sender_role = models.CharField( + max_length=10, + choices=SENDER_ROLE_CHOICES, + verbose_name='發送者角色', + ) + content = models.TextField(verbose_name='內容') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='建立時間') + + class Meta: + db_table = 'contest_messages' + verbose_name = '競賽訊息' + verbose_name_plural = '競賽訊息' + ordering = ['created_at'] + + def __str__(self): + return f"Message #{self.id} from {self.sender_role} in conv {self.conversation_id}" +``` + +- [ ] **Step 3:在 `admin.py` 中刪除 Clarification 註冊、新增兩個 model** + +開啟 `backend/apps/contests/admin.py`,搜尋 `Clarification` 並把所有相關 import / `@admin.register(Clarification)` / class 整段刪除,於檔案結尾追加: + +```python +from .models import ContestConversation, ContestMessage + + +@admin.register(ContestConversation) +class ContestConversationAdmin(admin.ModelAdmin): + list_display = ('id', 'contest', 'student', 'last_message_at', 'created_at') + list_filter = ('contest',) + search_fields = ('student__username',) + readonly_fields = ('created_at', 'last_message_at') + + +@admin.register(ContestMessage) +class ContestMessageAdmin(admin.ModelAdmin): + list_display = ('id', 'conversation', 'sender_role', 'sender', 'created_at') + list_filter = ('sender_role',) + readonly_fields = ('created_at',) +``` + +- [ ] **Step 4:產生 migration** + +Run: +```bash +bash .codex/skills/qjudge-env-compose-owner/scripts/qjudge-dc.sh dev exec -T backend python manage.py makemigrations contests --name drop_clarification_add_conversation +``` + +Expected:產生 `backend/apps/contests/migrations/0078_drop_clarification_add_conversation.py`,內含 `DeleteModel(Clarification)` + `CreateModel(ContestConversation)` + `CreateModel(ContestMessage)`。 + +- [ ] **Step 5:套用 migration(dev DB 不保留 Clarification 資料)** + +```bash +bash .codex/skills/qjudge-env-compose-owner/scripts/qjudge-dc.sh dev exec -T backend python manage.py migrate contests +``` + +Expected:output 含 `0078_drop_clarification_add_conversation... OK`。 + +- [ ] **Step 6:Commit** + +```bash +git add backend/apps/contests/models.py backend/apps/contests/admin.py backend/apps/contests/migrations/0078_drop_clarification_add_conversation.py +git commit -m "feat(contests): replace Clarification with Conversation+Message models" +``` + +--- + +### Task 2: 移除舊 Clarification 引用(serializers / urls / views) + +**Files:** +- Modify: `backend/apps/contests/serializers.py` +- Modify: `backend/apps/contests/urls.py` +- Modify: `backend/apps/contests/views/__init__.py` +- Delete: `backend/apps/contests/views/clarification.py` + +- [ ] **Step 1:刪除 `views/clarification.py`** + +```bash +rm backend/apps/contests/views/clarification.py +``` + +- [ ] **Step 2:在 `views/__init__.py` 中移除 `ClarificationViewSet` 的 export** + +開啟 `backend/apps/contests/views/__init__.py`,刪除 `from .clarification import ClarificationViewSet` 與相應的 `__all__` 條目。 + +- [ ] **Step 3:在 `urls.py` 中移除 clarification import + register** + +定位 `backend/apps/contests/urls.py:7` 的 `ClarificationViewSet` import 整行刪除;同時刪除 `backend/apps/contests/urls.py:24` 的: +```python +contest_router.register(r'clarifications', ClarificationViewSet, basename='contest-clarifications') +``` + +- [ ] **Step 4:在 `serializers.py` 中刪除三個 Clarification serializer** + +`grep -n "Clarification" backend/apps/contests/serializers.py` 找出 `ClarificationSerializer`、`ClarificationCreateSerializer`、`ClarificationReplySerializer` 三個 class,整段刪除(含相關 imports 中的 `Clarification` 引用)。 + +- [ ] **Step 5:驗證沒有殘餘引用** + +```bash +grep -rn "Clarification" backend/apps/contests/ --include="*.py" +``` +Expected:只剩下 migrations 歷史檔(不可動)內提及 Clarification 的舊紀錄;views/serializers/admin/urls 都不再 import 或使用該名稱。 + +- [ ] **Step 6:Commit** + +```bash +git add backend/apps/contests/serializers.py backend/apps/contests/urls.py backend/apps/contests/views/__init__.py backend/apps/contests/views/clarification.py +git commit -m "refactor(contests): drop ClarificationViewSet and serializers" +``` + +--- + +### Task 3: 新增 Conversation/Message serializer + +**Files:** +- Modify: `backend/apps/contests/serializers.py` + +- [ ] **Step 1:在 `serializers.py` 末尾追加三個 serializer** + +```python +class ContestMessageSerializer(serializers.ModelSerializer): + sender_username = serializers.CharField(source='sender.username', read_only=True, default=None) + + class Meta: + model = ContestMessage + fields = ['id', 'sender', 'sender_username', 'sender_role', 'content', 'created_at'] + read_only_fields = ['id', 'sender', 'sender_username', 'sender_role', 'created_at'] + + +class ContestConversationListSerializer(serializers.ModelSerializer): + student_username = serializers.CharField(source='student.username', read_only=True) + last_message = serializers.SerializerMethodField() + first_sender_role = serializers.SerializerMethodField() + + class Meta: + model = ContestConversation + fields = [ + 'id', 'student', 'student_username', + 'created_at', 'last_message_at', + 'last_message', 'first_sender_role', + ] + + def get_last_message(self, obj): + msg = obj.messages.order_by('-created_at').first() + if not msg: + return None + return ContestMessageSerializer(msg).data + + def get_first_sender_role(self, obj): + msg = obj.messages.order_by('created_at').first() + return msg.sender_role if msg else None + + +class ContestConversationDetailSerializer(ContestConversationListSerializer): + messages = ContestMessageSerializer(many=True, read_only=True) + + class Meta(ContestConversationListSerializer.Meta): + fields = ContestConversationListSerializer.Meta.fields + ['messages'] + + +class ContestConversationCreateSerializer(serializers.Serializer): + initial_content = serializers.CharField() + student_id = serializers.UUIDField(required=False, allow_null=True) + + +class SendMessageToStudentSerializer(serializers.Serializer): + student_id = serializers.UUIDField() + content = serializers.CharField() + + +class AppendMessageSerializer(serializers.Serializer): + content = serializers.CharField() +``` + +確保上方 `from .models import` 區塊新增 `ContestConversation, ContestMessage`。 + +- [ ] **Step 2:Commit** + +```bash +git add backend/apps/contests/serializers.py +git commit -m "feat(contests): add Conversation/Message serializers" +``` + +--- + +### Task 4: 新增 ContestConversationViewSet + +**Files:** +- Create: `backend/apps/contests/views/conversation.py` +- Modify: `backend/apps/contests/views/__init__.py` +- Modify: `backend/apps/contests/urls.py` + +- [ ] **Step 1:建立 `views/conversation.py`** + +```python +"""ContestConversationViewSet.""" +from django.db import IntegrityError, transaction +from django.db.models import OuterRef, Subquery +from django.utils import timezone +from rest_framework import viewsets, permissions, status +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied, NotFound, ValidationError +from rest_framework.response import Response +from django.shortcuts import get_object_or_404 + +from ..models import Contest, ContestConversation, ContestMessage, User +from ..serializers import ( + ContestConversationListSerializer, + ContestConversationDetailSerializer, + ContestConversationCreateSerializer, + SendMessageToStudentSerializer, + AppendMessageSerializer, + ContestMessageSerializer, +) +from ..permissions import can_manage_contest + + +def _is_contest_active_for_student(contest): + now = timezone.now() + if contest.end_time and now > contest.end_time: + return False + return True + + +def _annotate_last_message_role(qs): + last_msg_role = ContestMessage.objects.filter( + conversation=OuterRef('pk') + ).order_by('-created_at').values('sender_role')[:1] + return qs.annotate(_last_role=Subquery(last_msg_role)) + + +class ContestConversationViewSet(viewsets.GenericViewSet): + """ + GET /contests/{contest_pk}/conversations/ 教師 list + GET /contests/{contest_pk}/conversations/me/ 學生取自己的(404 if none) + GET /contests/{contest_pk}/conversations/{pk}/ retrieve(含 messages) + POST /contests/{contest_pk}/conversations/ 學生 create(idempotent) + POST /contests/{contest_pk}/conversations/{pk}/messages/ append message + POST /contests/{contest_pk}/conversations/messages-to-student/ 教師 ensure-and-append + """ + permission_classes = [permissions.IsAuthenticated] + + def _get_contest(self): + contest_id = self.kwargs.get('contest_pk') + return get_object_or_404(Contest, id=contest_id) + + def _is_manager(self, contest): + return can_manage_contest(self.request.user, contest) + + def _serialize_detail(self, conversation): + return ContestConversationDetailSerializer(conversation).data + + # ── list (teacher only) ──────────────────────────────────────────── + def list(self, request, contest_pk=None): + contest = self._get_contest() + if not self._is_manager(request.user): + raise PermissionDenied("Only teaching staff can list conversations.") + + qs = ContestConversation.objects.filter(contest=contest).select_related('student') + qs = _annotate_last_message_role(qs) + + awaiting = request.query_params.get('awaiting') + if awaiting == 'teacher': + qs = qs.filter(_last_role='student') + elif awaiting == 'student': + qs = qs.filter(_last_role='teacher') + + data = ContestConversationListSerializer(qs, many=True).data + return Response(data) + + # ── retrieve ─────────────────────────────────────────────────────── + def retrieve(self, request, pk=None, contest_pk=None): + contest = self._get_contest() + conv = get_object_or_404(ContestConversation, pk=pk, contest=contest) + if not self._is_manager(request.user) and conv.student_id != request.user.id: + raise PermissionDenied("You can only view your own conversation.") + return Response(self._serialize_detail(conv)) + + # ── student: my conversation ────────────────────────────────────── + @action(detail=False, methods=['get'], url_path='me') + def me(self, request, contest_pk=None): + contest = self._get_contest() + try: + conv = ContestConversation.objects.get(contest=contest, student=request.user) + except ContestConversation.DoesNotExist: + raise NotFound("No conversation yet.") + return Response(self._serialize_detail(conv)) + + # ── create (student-initiated) ──────────────────────────────────── + def create(self, request, contest_pk=None): + contest = self._get_contest() + serializer = ContestConversationCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + if self._is_manager(request.user): + raise PermissionDenied( + "Teachers should use POST /messages-to-student/ to send messages." + ) + + if not _is_contest_active_for_student(contest): + raise PermissionDenied("Contest has ended; cannot start a new message.") + + with transaction.atomic(): + conv, created = ContestConversation.objects.get_or_create( + contest=contest, + student=request.user, + defaults={'last_message_at': timezone.now()}, + ) + self._append_message( + conv, sender=request.user, sender_role='student', + content=serializer.validated_data['initial_content'], + ) + return Response( + self._serialize_detail(conv), + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + # ── append message ──────────────────────────────────────────────── + @action(detail=True, methods=['post'], url_path='messages') + def append_message(self, request, pk=None, contest_pk=None): + contest = self._get_contest() + conv = get_object_or_404(ContestConversation, pk=pk, contest=contest) + serializer = AppendMessageSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + if self._is_manager(request.user): + sender_role = 'teacher' + else: + if conv.student_id != request.user.id: + raise PermissionDenied("You can only post in your own conversation.") + if not _is_contest_active_for_student(contest): + raise PermissionDenied("Contest has ended; cannot post message.") + sender_role = 'student' + + msg = self._append_message( + conv, sender=request.user, sender_role=sender_role, + content=serializer.validated_data['content'], + ) + return Response(ContestMessageSerializer(msg).data, status=status.HTTP_201_CREATED) + + # ── teacher: ensure-and-append to a specific student ────────────── + @action(detail=False, methods=['post'], url_path='messages-to-student') + def messages_to_student(self, request, contest_pk=None): + contest = self._get_contest() + if not self._is_manager(request.user): + raise PermissionDenied("Only teaching staff can use this endpoint.") + serializer = SendMessageToStudentSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + student_id = serializer.validated_data['student_id'] + try: + student = User.objects.get(id=student_id) + except User.DoesNotExist: + raise ValidationError({'student_id': 'Student not found.'}) + + with transaction.atomic(): + conv, _created = ContestConversation.objects.get_or_create( + contest=contest, student=student, + defaults={'last_message_at': timezone.now()}, + ) + self._append_message( + conv, sender=request.user, sender_role='teacher', + content=serializer.validated_data['content'], + ) + return Response(self._serialize_detail(conv), status=status.HTTP_201_CREATED) + + # ── helper ──────────────────────────────────────────────────────── + def _append_message(self, conversation, sender, sender_role, content): + msg = ContestMessage.objects.create( + conversation=conversation, + sender=sender, + sender_role=sender_role, + content=content, + ) + conversation.last_message_at = msg.created_at + conversation.save(update_fields=['last_message_at']) + return msg +``` + +- [ ] **Step 2:在 `views/__init__.py` 加上 export** + +```python +from .conversation import ContestConversationViewSet +``` + +並加進 `__all__`(若有)。 + +- [ ] **Step 3:在 `urls.py` 註冊新 router** + +`backend/apps/contests/urls.py` 內找 `contest_router.register(r'announcements', ...)` 區塊,下方追加: + +```python +from .views import ContestConversationViewSet +contest_router.register(r'conversations', ContestConversationViewSet, basename='contest-conversations') +``` + +(若 `from .views import ...` 已存在則直接把 `ContestConversationViewSet` 加進去) + +- [ ] **Step 4:用 Django shell 確認 URL 可解析** + +```bash +bash .codex/skills/qjudge-env-compose-owner/scripts/qjudge-dc.sh dev exec -T backend python manage.py show_urls | grep conversation +``` + +Expected:列出 `/api/contests//conversations/` 等 endpoints。 + +- [ ] **Step 5:Commit** + +```bash +git add backend/apps/contests/views/conversation.py backend/apps/contests/views/__init__.py backend/apps/contests/urls.py +git commit -m "feat(contests): add ContestConversationViewSet" +``` + +--- + +### Task 5: 後端測試(list / create / append / messages-to-student / 權限) + +**Files:** +- Create: `backend/apps/contests/tests/conversation/__init__.py` +- Create: `backend/apps/contests/tests/conversation/test_conversation_viewset.py` + +- [ ] **Step 1:建立測試骨架** + +```python +# backend/apps/contests/tests/conversation/__init__.py +``` +(空檔案) + +- [ ] **Step 2:撰寫測試** + +```python +# backend/apps/contests/tests/conversation/test_conversation_viewset.py +import pytest +from django.urls import reverse +from rest_framework.test import APIClient +from django.contrib.auth import get_user_model + +from apps.contests.models import ( + Contest, ContestParticipant, ContestConversation, ContestMessage +) + +User = get_user_model() + + +@pytest.fixture +def teacher(db): + return User.objects.create_user(username='teacher1', password='x', role='teacher') + + +@pytest.fixture +def student(db): + return User.objects.create_user(username='student1', password='x', role='student') + + +@pytest.fixture +def other_student(db): + return User.objects.create_user(username='student2', password='x', role='student') + + +@pytest.fixture +def contest(db, teacher, student, other_student): + from django.utils import timezone + from datetime import timedelta + c = Contest.objects.create( + name='Test', + owner=teacher, + start_time=timezone.now() - timedelta(hours=1), + end_time=timezone.now() + timedelta(hours=1), + ) + ContestParticipant.objects.create(contest=c, user=student) + ContestParticipant.objects.create(contest=c, user=other_student) + return c + + +def _client(user): + c = APIClient() + c.force_authenticate(user=user) + return c + + +def test_student_create_conversation_idempotent(contest, student): + url = f'/api/contests/{contest.id}/conversations/' + client = _client(student) + r1 = client.post(url, {'initial_content': 'hello'}, format='json') + assert r1.status_code == 201 + conv_id = r1.data['id'] + + r2 = client.post(url, {'initial_content': 'hello again'}, format='json') + assert r2.status_code == 200 + assert r2.data['id'] == conv_id + assert ContestConversation.objects.filter(contest=contest, student=student).count() == 1 + assert ContestMessage.objects.filter(conversation_id=conv_id).count() == 2 + + +def test_student_get_me_returns_404_when_no_conversation(contest, student): + url = f'/api/contests/{contest.id}/conversations/me/' + r = _client(student).get(url) + assert r.status_code == 404 + + +def test_student_cannot_view_other_students_conversation(contest, student, other_student): + conv = ContestConversation.objects.create( + contest=contest, student=other_student, last_message_at='2024-01-01T00:00:00Z' + ) + url = f'/api/contests/{contest.id}/conversations/{conv.id}/' + r = _client(student).get(url) + assert r.status_code == 403 + + +def test_teacher_list_filters_awaiting_teacher(contest, teacher, student, other_student): + conv1 = ContestConversation.objects.create( + contest=contest, student=student, last_message_at='2024-01-01T00:00:00Z' + ) + ContestMessage.objects.create(conversation=conv1, sender=student, sender_role='student', content='ask') + conv2 = ContestConversation.objects.create( + contest=contest, student=other_student, last_message_at='2024-01-01T00:00:00Z' + ) + ContestMessage.objects.create(conversation=conv2, sender=other_student, sender_role='student', content='q') + ContestMessage.objects.create(conversation=conv2, sender=teacher, sender_role='teacher', content='a') + + url = f'/api/contests/{contest.id}/conversations/?awaiting=teacher' + r = _client(teacher).get(url) + assert r.status_code == 200 + assert {c['id'] for c in r.data} == {conv1.id} + + +def test_student_create_blocked_after_contest_ended(contest, student): + from django.utils import timezone + from datetime import timedelta + contest.end_time = timezone.now() - timedelta(minutes=1) + contest.save() + + url = f'/api/contests/{contest.id}/conversations/' + r = _client(student).post(url, {'initial_content': 'late'}, format='json') + assert r.status_code == 403 + + +def test_teacher_messages_to_student_creates_conversation(contest, teacher, student): + url = f'/api/contests/{contest.id}/conversations/messages-to-student/' + r = _client(teacher).post(url, {'student_id': str(student.id), 'content': 'pay attention'}, format='json') + assert r.status_code == 201 + conv = ContestConversation.objects.get(contest=contest, student=student) + msgs = list(conv.messages.all()) + assert len(msgs) == 1 + assert msgs[0].sender_role == 'teacher' + + +def test_teacher_messages_to_student_appends_to_existing(contest, teacher, student): + conv = ContestConversation.objects.create( + contest=contest, student=student, last_message_at='2024-01-01T00:00:00Z' + ) + ContestMessage.objects.create(conversation=conv, sender=student, sender_role='student', content='q') + + url = f'/api/contests/{contest.id}/conversations/messages-to-student/' + r = _client(teacher).post(url, {'student_id': str(student.id), 'content': 'a'}, format='json') + assert r.status_code == 201 + assert conv.messages.count() == 2 + + +def test_student_append_message_blocked_after_contest_ended(contest, student): + conv = ContestConversation.objects.create( + contest=contest, student=student, last_message_at='2024-01-01T00:00:00Z' + ) + from django.utils import timezone + from datetime import timedelta + contest.end_time = timezone.now() - timedelta(minutes=1) + contest.save() + + url = f'/api/contests/{contest.id}/conversations/{conv.id}/messages/' + r = _client(student).post(url, {'content': 'late'}, format='json') + assert r.status_code == 403 + + +def test_student_cannot_use_messages_to_student_endpoint(contest, student, other_student): + url = f'/api/contests/{contest.id}/conversations/messages-to-student/' + r = _client(student).post(url, {'student_id': str(other_student.id), 'content': 'x'}, format='json') + assert r.status_code == 403 +``` + +- [ ] **Step 3:執行測試** + +```bash +bash .codex/skills/qjudge-env-compose-owner/scripts/qjudge-dc.sh dev exec -T -e PYTEST_ADDOPTS='--no-cov' backend pytest apps/contests/tests/conversation/ -v +``` + +Expected:所有測試 pass。如有 fail,回頭 debug View / Serializer。 + +- [ ] **Step 4:Commit** + +```bash +git add backend/apps/contests/tests/conversation/ +git commit -m "test(contests): add Conversation viewset coverage" +``` + +--- + +## Phase 2:Frontend Infrastructure + +### Task 6: Entity types + API repository + +**Files:** +- Modify: `frontend/src/core/entities/contest.entity.ts` +- Create: `frontend/src/infrastructure/api/repositories/contestConversation.ts` +- Modify: `frontend/src/infrastructure/api/repositories/index.ts` +- Modify: `frontend/src/infrastructure/mappers/contest.mapper.ts` + +- [ ] **Step 1:在 `contest.entity.ts` 中新增三個 type,移除 Clarification** + +開啟 `frontend/src/core/entities/contest.entity.ts`,刪除 `Clarification` 相關 type(搜尋 `Clarification` 的所有 export type / interface),於檔案末尾新增: + +```typescript +export type ContestMessageSenderRole = 'student' | 'teacher'; + +export interface ContestMessage { + id: string; + sender: string | null; + senderUsername: string | null; + senderRole: ContestMessageSenderRole; + content: string; + createdAt: string; +} + +export interface ContestConversation { + id: string; + student: string; + studentUsername: string; + createdAt: string; + lastMessageAt: string; + lastMessage: ContestMessage | null; + firstSenderRole: ContestMessageSenderRole | null; + messages?: ContestMessage[]; +} +``` + +- [ ] **Step 2:在 `contest.mapper.ts` 中新增兩個 mapper,移除 Clarification mapper** + +刪除既有 `mapClarificationDto`(搜尋並整段刪除),新增: + +```typescript +import type { ContestConversation, ContestMessage } from '@/core/entities/contest.entity'; + +export const mapContestMessageDto = (raw: any): ContestMessage => ({ + id: String(raw.id), + sender: raw.sender ? String(raw.sender) : null, + senderUsername: raw.sender_username ?? null, + senderRole: raw.sender_role, + content: raw.content, + createdAt: raw.created_at, +}); + +export const mapContestConversationDto = (raw: any): ContestConversation => ({ + id: String(raw.id), + student: String(raw.student), + studentUsername: raw.student_username ?? '', + createdAt: raw.created_at, + lastMessageAt: raw.last_message_at, + lastMessage: raw.last_message ? mapContestMessageDto(raw.last_message) : null, + firstSenderRole: raw.first_sender_role ?? null, + messages: Array.isArray(raw.messages) ? raw.messages.map(mapContestMessageDto) : undefined, +}); +``` + +- [ ] **Step 3:建立 repository 檔** + +```typescript +// frontend/src/infrastructure/api/repositories/contestConversation.ts +import apiClient from '@/infrastructure/api/client'; +import { + mapContestConversationDto, + mapContestMessageDto, +} from '@/infrastructure/mappers/contest.mapper'; +import type { + ContestConversation, + ContestMessage, +} from '@/core/entities/contest.entity'; + +const base = (contestId: string) => `/api/contests/${contestId}/conversations`; + +export const listConversations = async ( + contestId: string, + awaiting?: 'teacher' | 'student', +): Promise => { + const params = awaiting ? { awaiting } : {}; + const { data } = await apiClient.get(`${base(contestId)}/`, { params }); + return (data as unknown[]).map(mapContestConversationDto); +}; + +export const getMyConversation = async ( + contestId: string, +): Promise => { + try { + const { data } = await apiClient.get(`${base(contestId)}/me/`); + return mapContestConversationDto(data); + } catch (err: any) { + if (err?.response?.status === 404) return null; + throw err; + } +}; + +export const getConversation = async ( + contestId: string, + conversationId: string, +): Promise => { + const { data } = await apiClient.get(`${base(contestId)}/${conversationId}/`); + return mapContestConversationDto(data); +}; + +export const createStudentConversation = async ( + contestId: string, + initialContent: string, +): Promise => { + const { data } = await apiClient.post(`${base(contestId)}/`, { + initial_content: initialContent, + }); + return mapContestConversationDto(data); +}; + +export const appendMessage = async ( + contestId: string, + conversationId: string, + content: string, +): Promise => { + const { data } = await apiClient.post( + `${base(contestId)}/${conversationId}/messages/`, + { content }, + ); + return mapContestMessageDto(data); +}; + +export const sendMessageToStudent = async ( + contestId: string, + studentId: string, + content: string, +): Promise => { + const { data } = await apiClient.post( + `${base(contestId)}/messages-to-student/`, + { student_id: studentId, content }, + ); + return mapContestConversationDto(data); +}; +``` + +- [ ] **Step 4:在 `repositories/index.ts` 補 export,並把所有 `createClarification / replyClarification / deleteClarification / getClarifications` 整段刪除** + +`grep -n "Clarification" frontend/src/infrastructure/api/repositories/index.ts` 找出後刪除,於底部新增: + +```typescript +export { + listConversations, + getMyConversation, + getConversation, + createStudentConversation, + appendMessage, + sendMessageToStudent, +} from './contestConversation'; +``` + +- [ ] **Step 5:型別檢查** + +```bash +cd frontend && npm run -s typecheck +``` + +Expected:通過。預期會有「`useClarifications` 等檔尚未刪除而引用刪除的 type」錯誤 —— 這些檔案會在後續任務中刪除,先把錯誤檔列入下一階段(Phase 6)的清單,**此階段允許這幾個錯誤暫時存在**。 + +> 若 typecheck 是 CI gate 又無法 pass,可在此 task 提前刪除 `useClarifications.ts`(檔案內容暫時換成 `export {}`),等 Phase 6 真正清理。實作上以「先讓 type 通過為止」為原則。 + +- [ ] **Step 6:Commit** + +```bash +git add frontend/src/core/entities/contest.entity.ts frontend/src/infrastructure/api/repositories/contestConversation.ts frontend/src/infrastructure/api/repositories/index.ts frontend/src/infrastructure/mappers/contest.mapper.ts +git commit -m "feat(api): conversation entity + repository" +``` + +--- + +### Task 7: localStorage 已讀工具 + +**Files:** +- Create: `frontend/src/features/contest/hooks/contestReadStorage.ts` +- Create: `frontend/src/features/contest/hooks/contestReadStorage.test.ts` + +- [ ] **Step 1:寫失敗測試** + +```typescript +// frontend/src/features/contest/hooks/contestReadStorage.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { + loadReadIds, + saveReadIds, + markRead, + isRead, + STORAGE_KEY_PREFIX, +} from './contestReadStorage'; + +describe('contestReadStorage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('returns empty Set when no entry exists', () => { + expect(loadReadIds('c1').size).toBe(0); + }); + + it('persists ids per contest', () => { + saveReadIds('c1', new Set(['ann:1', 'msg:5'])); + expect(loadReadIds('c1')).toEqual(new Set(['ann:1', 'msg:5'])); + expect(loadReadIds('c2').size).toBe(0); + }); + + it('markRead adds ids without dropping existing ones', () => { + saveReadIds('c1', new Set(['ann:1'])); + markRead('c1', ['ann:2', 'msg:9']); + expect(loadReadIds('c1')).toEqual(new Set(['ann:1', 'ann:2', 'msg:9'])); + }); + + it('isRead reflects storage', () => { + saveReadIds('c1', new Set(['ann:1'])); + expect(isRead('c1', 'ann:1')).toBe(true); + expect(isRead('c1', 'ann:2')).toBe(false); + }); + + it('uses the documented key prefix', () => { + saveReadIds('c1', new Set(['ann:1'])); + expect(localStorage.getItem(`${STORAGE_KEY_PREFIX}c1.readIds`)).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2:執行確認 fail** + +```bash +cd frontend && npm test -- contestReadStorage +``` + +Expected:fail(檔案尚不存在)。 + +- [ ] **Step 3:實作** + +```typescript +// frontend/src/features/contest/hooks/contestReadStorage.ts +export const STORAGE_KEY_PREFIX = 'qjudge.contest.'; + +const keyFor = (contestId: string) => `${STORAGE_KEY_PREFIX}${contestId}.readIds`; + +export const loadReadIds = (contestId: string): Set => { + try { + const raw = localStorage.getItem(keyFor(contestId)); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? new Set(parsed) : new Set(); + } catch { + return new Set(); + } +}; + +export const saveReadIds = (contestId: string, ids: Set): void => { + try { + localStorage.setItem(keyFor(contestId), JSON.stringify([...ids])); + } catch (err) { + console.warn('[contestReadStorage] failed to save', err); + } +}; + +export const markRead = (contestId: string, ids: string[]): void => { + if (ids.length === 0) return; + const current = loadReadIds(contestId); + ids.forEach((id) => current.add(id)); + saveReadIds(contestId, current); +}; + +export const isRead = (contestId: string, id: string): boolean => + loadReadIds(contestId).has(id); +``` + +- [ ] **Step 4:執行測試 pass** + +```bash +cd frontend && npm test -- contestReadStorage +``` + +Expected:5 tests pass。 + +- [ ] **Step 5:Commit** + +```bash +git add frontend/src/features/contest/hooks/contestReadStorage.ts frontend/src/features/contest/hooks/contestReadStorage.test.ts +git commit -m "feat(contest): localStorage read-tracking utility" +``` + +--- + +### Task 8: useContestConversations hook(取代 useClarifications) + +**Files:** +- Create: `frontend/src/features/contest/hooks/useContestConversations.ts` + +- [ ] **Step 1:實作** + +```typescript +// frontend/src/features/contest/hooks/useContestConversations.ts +import { useCallback, useEffect, useState } from 'react'; +import { + listConversations, + getMyConversation, + getContestAnnouncements, +} from '@/infrastructure/api/repositories'; +import { mapContestAnnouncementDto } from '@/infrastructure/mappers/contest.mapper'; +import { useInterval } from '@/shared/hooks/useInterval'; +import type { + ContestAnnouncement, + ContestConversation, +} from '@/core/entities/contest.entity'; + +interface Options { + pollIntervalMs?: number | null; + role: 'student' | 'teacher'; + awaiting?: 'teacher' | 'student'; +} + +export const useContestConversations = ( + contestId: string, + options: Options, +) => { + const [announcements, setAnnouncements] = useState([]); + const [conversations, setConversations] = useState([]); + const [myConversation, setMyConversation] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const pollIntervalMs = options.pollIntervalMs ?? null; + const role = options.role; + const awaiting = options.awaiting; + + const fetchAll = useCallback( + async (showLoading = false) => { + if (!contestId) return; + if (showLoading) setLoading(true); + try { + const [annData, convPart] = await Promise.all([ + getContestAnnouncements(contestId), + role === 'teacher' + ? listConversations(contestId, awaiting) + : getMyConversation(contestId).then((c) => (c ? [c] : [])), + ]); + setAnnouncements( + (Array.isArray(annData) ? annData : []).map((it: any) => + mapContestAnnouncementDto(it), + ), + ); + if (role === 'teacher') { + setConversations(convPart); + setMyConversation(null); + } else { + setConversations([]); + setMyConversation(convPart[0] ?? null); + } + setError(null); + } catch (err) { + setError(err as Error); + } finally { + if (showLoading) setLoading(false); + } + }, + [contestId, role, awaiting], + ); + + useEffect(() => { + fetchAll(true); + }, [fetchAll]); + + useInterval( + () => fetchAll(false), + contestId && pollIntervalMs && pollIntervalMs > 0 ? pollIntervalMs : null, + ); + + return { + announcements, + conversations, + myConversation, + loading, + error, + refresh: () => fetchAll(false), + }; +}; +``` + +- [ ] **Step 2:型別檢查** + +```bash +cd frontend && npm run -s typecheck +``` + +Expected:通過(與既有 announcement repository / interval hook 已存在)。 + +- [ ] **Step 3:Commit** + +```bash +git add frontend/src/features/contest/hooks/useContestConversations.ts +git commit -m "feat(contest): useContestConversations hook" +``` + +--- + +### Task 9: useContestUnread hook + +**Files:** +- Create: `frontend/src/features/contest/hooks/useContestUnread.ts` +- Create: `frontend/src/features/contest/hooks/useContestUnread.test.ts` + +- [ ] **Step 1:寫失敗測試** + +```typescript +// frontend/src/features/contest/hooks/useContestUnread.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { computeUnread } from './useContestUnread'; +import type { + ContestAnnouncement, + ContestConversation, +} from '@/core/entities/contest.entity'; + +const ann = (id: string): ContestAnnouncement => + ({ id, title: 't', content: 'c', createdAt: 'x' } as any); + +const msg = (id: string, role: 'student' | 'teacher') => + ({ id, senderRole: role, content: 'm', createdAt: 'x', sender: null, senderUsername: null }); + +const conv = ( + id: string, + messages: ReturnType[], +): ContestConversation => + ({ + id, student: 's', studentUsername: 's', + createdAt: 'x', lastMessageAt: 'x', + lastMessage: messages[messages.length - 1] ?? null, + firstSenderRole: messages[0]?.senderRole ?? null, + messages, + } as any); + +describe('computeUnread', () => { + beforeEach(() => localStorage.clear()); + + it('student counts unread announcements + teacher messages', () => { + const result = computeUnread({ + contestId: 'c1', + role: 'student', + announcements: [ann('1'), ann('2')], + conversations: [], + myConversation: conv('cv1', [ + msg('m1', 'student'), + msg('m2', 'teacher'), + msg('m3', 'teacher'), + ]), + readIds: new Set(), + }); + expect(result.unreadAnnouncementIds).toEqual(['1', '2']); + expect(result.unreadMessageIds).toEqual(['m2', 'm3']); + expect(result.unreadCount).toBe(4); + }); + + it('student excludes own messages from unread', () => { + const result = computeUnread({ + contestId: 'c1', + role: 'student', + announcements: [], + conversations: [], + myConversation: conv('cv1', [msg('m1', 'student')]), + readIds: new Set(), + }); + expect(result.unreadMessageIds).toEqual([]); + }); + + it('teacher unread = number of conversations awaiting teacher reply', () => { + const result = computeUnread({ + contestId: 'c1', + role: 'teacher', + announcements: [ann('1')], + conversations: [ + conv('cv1', [msg('m1', 'student')]), + conv('cv2', [msg('m2', 'student'), msg('m3', 'teacher')]), + conv('cv3', [msg('m4', 'student')]), + ], + myConversation: null, + readIds: new Set(), + }); + expect(result.pendingConversationCount).toBe(2); + expect(result.unreadCount).toBe(2); + expect(result.unreadAnnouncementIds).toEqual([]); + }); + + it('respects already-read ids in localStorage', () => { + const result = computeUnread({ + contestId: 'c1', + role: 'student', + announcements: [ann('1'), ann('2')], + conversations: [], + myConversation: conv('cv1', [msg('m1', 'teacher'), msg('m2', 'teacher')]), + readIds: new Set(['ann:1', 'msg:m1']), + }); + expect(result.unreadAnnouncementIds).toEqual(['2']); + expect(result.unreadMessageIds).toEqual(['m2']); + expect(result.unreadCount).toBe(2); + }); +}); +``` + +- [ ] **Step 2:執行測試確認 fail** + +```bash +cd frontend && npm test -- useContestUnread +``` + +Expected:fail(檔案尚不存在)。 + +- [ ] **Step 3:實作** + +```typescript +// frontend/src/features/contest/hooks/useContestUnread.ts +import { useCallback, useMemo, useState } from 'react'; +import type { + ContestAnnouncement, + ContestConversation, +} from '@/core/entities/contest.entity'; +import { + loadReadIds, + markRead as markReadStorage, +} from './contestReadStorage'; + +export interface ComputeUnreadInput { + contestId: string; + role: 'student' | 'teacher'; + announcements: ContestAnnouncement[]; + conversations: ContestConversation[]; + myConversation: ContestConversation | null; + readIds: Set; +} + +export interface UnreadSummary { + unreadAnnouncementIds: string[]; + unreadMessageIds: string[]; + pendingConversationCount: number; + unreadCount: number; +} + +export const computeUnread = (input: ComputeUnreadInput): UnreadSummary => { + const { role, announcements, conversations, myConversation, readIds } = input; + + const unreadAnnouncementIds = announcements + .map((a) => a.id) + .filter((id) => !readIds.has(`ann:${id}`)); + + if (role === 'teacher') { + const pending = conversations.filter( + (c) => c.lastMessage?.senderRole === 'student', + ).length; + return { + unreadAnnouncementIds: [], + unreadMessageIds: [], + pendingConversationCount: pending, + unreadCount: pending, + }; + } + + const teacherMsgs = (myConversation?.messages ?? []).filter( + (m) => m.senderRole === 'teacher', + ); + const unreadMessageIds = teacherMsgs + .map((m) => m.id) + .filter((id) => !readIds.has(`msg:${id}`)); + + return { + unreadAnnouncementIds, + unreadMessageIds, + pendingConversationCount: 0, + unreadCount: unreadAnnouncementIds.length + unreadMessageIds.length, + }; +}; + +export const useContestUnread = (input: Omit) => { + const [readVersion, setReadVersion] = useState(0); + + const readIds = useMemo( + () => loadReadIds(input.contestId), + [input.contestId, readVersion], + ); + + const summary = useMemo( + () => computeUnread({ ...input, readIds }), + [input, readIds], + ); + + const markRead = useCallback( + (ids: string[]) => { + if (!ids.length) return; + markReadStorage(input.contestId, ids); + setReadVersion((v) => v + 1); + }, + [input.contestId], + ); + + const markAllRead = useCallback(() => { + const annKeys = summary.unreadAnnouncementIds.map((id) => `ann:${id}`); + const msgKeys = summary.unreadMessageIds.map((id) => `msg:${id}`); + markRead([...annKeys, ...msgKeys]); + }, [summary, markRead]); + + return { ...summary, markRead, markAllRead }; +}; +``` + +- [ ] **Step 4:執行測試 pass** + +```bash +cd frontend && npm test -- useContestUnread +``` + +Expected:4 tests pass。 + +- [ ] **Step 5:Commit** + +```bash +git add frontend/src/features/contest/hooks/useContestUnread.ts frontend/src/features/contest/hooks/useContestUnread.test.ts +git commit -m "feat(contest): useContestUnread hook with read tracking" +``` + +--- + +## Phase 3:Frontend Components — Discussion + +### Task 10: ConversationThreadView + +**Files:** +- Create: `frontend/src/features/contest/components/discussion/ConversationThreadView.tsx` +- Create: `frontend/src/features/contest/components/discussion/ConversationThreadView.module.scss` + +- [ ] **Step 1:實作** + +```tsx +// frontend/src/features/contest/components/discussion/ConversationThreadView.tsx +import { useEffect, useState } from 'react'; +import { Button, TextArea, InlineNotification, Tag } from '@carbon/react'; +import { Send } from '@carbon/icons-react'; +import type { ContestConversation, ContestMessage } from '@/core/entities/contest.entity'; +import styles from './ConversationThreadView.module.scss'; + +interface ConversationThreadViewProps { + conversation: ContestConversation | null; + /** 'student' or 'teacher' (current viewer role) */ + viewerRole: 'student' | 'teacher'; + /** if false, input is disabled (e.g., contest ended for student) */ + canPost: boolean; + /** label for the disabled hint, shown above input when canPost=false */ + disabledHint?: string; + /** called when sending message; promise resolves when done */ + onSend: (content: string) => Promise; + /** optional empty-state message when conversation is null */ + emptyHint?: string; +} + +export const ConversationThreadView = ({ + conversation, + viewerRole, + canPost, + disabledHint, + onSend, + emptyHint, +}: ConversationThreadViewProps) => { + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + + useEffect(() => { + setErrorMsg(null); + }, [conversation?.id]); + + const handleSend = async () => { + if (!draft.trim() || sending) return; + setSending(true); + setErrorMsg(null); + try { + await onSend(draft.trim()); + setDraft(''); + } catch (err: any) { + setErrorMsg(err?.response?.data?.detail ?? '訊息送出失敗,請稍後再試'); + } finally { + setSending(false); + } + }; + + const messages: ContestMessage[] = conversation?.messages ?? []; + + return ( +
+
+ {messages.length === 0 && ( +

{emptyHint ?? '目前沒有訊息'}

+ )} + {messages.map((m) => { + const isMine = + (m.senderRole === 'student' && viewerRole === 'student') || + (m.senderRole === 'teacher' && viewerRole === 'teacher'); + return ( +
+
+ + {m.senderRole === 'teacher' ? '老師' : '學生'} + + {new Date(m.createdAt).toLocaleString()} +
+
{m.content}
+
+ ); + })} +
+ +
+ {!canPost && disabledHint && ( + + )} + {errorMsg && ( + setErrorMsg(null)} + /> + )} +