Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
260ad58
feat(contest): refresh student dashboard
quan0715 May 5, 2026
6945c5d
fix(contest-admin): remove duplicate answer progress rail
quan0715 May 5, 2026
9c77abd
docs(contest): add dashboard typography & layout consolidation design
quan0715 May 5, 2026
c285428
docs(contest): add dashboard primitives implementation plan
quan0715 May 5, 2026
20bee77
feat(dashboard): add typography primitives
quan0715 May 5, 2026
4adee50
feat(dashboard): add page/container/block/header primitives
quan0715 May 5, 2026
cc40909
feat(dashboard): add tabs and toolbar primitives
quan0715 May 5, 2026
9347dea
refactor(contest): migrate student dashboard to dashboard primitives
quan0715 May 5, 2026
86a5317
refactor(contest-admin): migrate overview header and exam status panel
quan0715 May 5, 2026
8bea7d8
refactor(contest): use MetricBlock for hero start/end time pairs
quan0715 May 5, 2026
6123d98
feat(dashboard): add split proportions for main+aside layout
quan0715 May 5, 2026
dc3a6e5
fix(contest): announcements use BlockHeader; DashboardPage fills view…
quan0715 May 5, 2026
aa19bc6
refactor(contest-admin): migrate panel headers and titles to dashboar…
quan0715 May 5, 2026
39748e6
docs(spec): contest announcement & Q&A UX design
quan0715 May 5, 2026
dac941a
refactor(contest): migrate header timer and insight rail to dashboard…
quan0715 May 5, 2026
39e7fdf
fix(contest-admin): tidy participant list header and insight card spa…
quan0715 May 5, 2026
50d05a3
docs(spec): rework contest comm design — Conversation + Message + nav…
quan0715 May 5, 2026
48d11e7
docs(spec): drop conversation status & initiated_by fields
quan0715 May 5, 2026
ff12c57
docs(spec): drop problem field, unique (contest, student), simpler da…
quan0715 May 5, 2026
04a025f
refactor(contest): unify countdown via CountdownProgress; route right…
quan0715 May 5, 2026
5e95577
docs(plan): contest conversation implementation plan
quan0715 May 5, 2026
0c8be90
refactor(contest-admin): migrate command center tab+toolbar to dashbo…
quan0715 May 5, 2026
9a66cee
feat(contest): shimmer running countdown progress bar
quan0715 May 5, 2026
517305d
chore(dashboard): add typography token guard script
quan0715 May 5, 2026
21b1472
chore: ignore extra db backup formats
quan0715 May 5, 2026
a387165
feat(contest): allow participants to view dashboard summary after res…
quan0715 May 5, 2026
7632cf8
feat(contest): refresh paper exam answering UX
quan0715 May 5, 2026
615f83a
fix(dashboard): extract tabs story render into named component
quan0715 May 5, 2026
f46665d
feat(dashboard): add KPIBlock primitive for single-data emphasis
quan0715 May 5, 2026
3a891c8
fix(dashboard): even out spacing in KPIBlock body and participant panel
quan0715 May 5, 2026
a409500
docs(spec): unify contest layout under MainLayout/WorkspaceShell
quan0715 May 5, 2026
f25ddb0
chore(contest): collapse remaining bespoke headers and dead SCSS
quan0715 May 5, 2026
cff6b90
docs(plan): contest layout unification implementation plan
quan0715 May 5, 2026
d0d14e2
refactor(contest): student dashboard inner tabs use DashboardTabs pri…
quan0715 May 5, 2026
f51e7e3
feat(dashboard): add padding prop to DashboardTabBar (defaults to inset)
quan0715 May 5, 2026
1e947ea
feat(contest): URL-based useContestRuntimeMode hook
quan0715 May 5, 2026
25300ed
refactor(contest-admin): violation events log tabs use DashboardTabs
quan0715 May 5, 2026
4223556
chore(contest): export useContestRuntimeMode from hooks barrel
quan0715 May 5, 2026
d1b1376
fix(contest): drop outer dashboard frame on student page
quan0715 May 5, 2026
10a6f3b
feat(contest): RuntimeRouteWrapper + ContestRuntimeContext
quan0715 May 5, 2026
7715528
fix(contest-admin): rename participant distribution KPI to student pr…
quan0715 May 5, 2026
c9495d1
feat(contest): expose openMonitor through ContestRuntimeContext
quan0715 May 5, 2026
2ac7804
feat(top-nav): runtime lock + ExamStatusBadge in workspace top nav
quan0715 May 5, 2026
d153023
refactor(top-nav): extract RuntimeNavExtras to avoid running contest …
quan0715 May 5, 2026
1b8935e
feat(user-menu): auto settingsOnly during contest runtime
quan0715 May 5, 2026
5552bb0
feat(side-menu): contest idle section with back-to-classroom
quan0715 May 5, 2026
9a88023
feat(side-menu): contest runtime section with tabs and problem list
quan0715 May 5, 2026
287e7f9
feat(side-menu): switch to contest idle/runtime sections by URL
quan0715 May 5, 2026
d06776e
refactor(side-menu): inject problems via prop to avoid cross-tree Con…
quan0715 May 5, 2026
9b0e9bd
refactor(contest): merge runtime+detail routes into one branch
quan0715 May 5, 2026
1b42126
refactor(app): mount contest routes under MainLayout
quan0715 May 5, 2026
fa2cd1e
chore(contest): remove obsolete ContestLayout
quan0715 May 5, 2026
44e10f4
chore(contest): hide student announcement block behind a flag
quan0715 May 5, 2026
d67258a
fix: localize contest admin dashboard
quan0715 May 5, 2026
75b7ce4
fix(side-menu): align idle section style with AppSideMenu via shared …
quan0715 May 5, 2026
77882d4
fix(contest): wait for contest load before mounting ExamModeWrapper t…
quan0715 May 5, 2026
8809342
fix(paper-exam): remove duplicate ExamStatusBadge (now provided by gl…
quan0715 May 5, 2026
ae95881
refactor(contest-admin): preparation entries use DashboardContainer grid
quan0715 May 5, 2026
46b3d79
feat(contest): unify runtime workspace shell
quan0715 May 5, 2026
982f09a
fix(ci): remove stale contest anonymous fields
quan0715 May 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions backend/apps/contests/exporters/data_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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,
)

Expand Down
2 changes: 1 addition & 1 deletion backend/apps/contests/exporters/dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
),
]
Comment on lines +1 to +19
Comment on lines +10 to +19
16 changes: 0 additions & 16 deletions backend/apps/contests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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='更新時間')

Expand Down Expand Up @@ -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,
Expand Down
71 changes: 11 additions & 60 deletions backend/apps/contests/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -139,7 +138,6 @@ class Meta:
'created_at',
'updated_at',
# Computed fields
'my_nickname',
'current_user_role',
'permissions',
'has_joined',
Expand All @@ -159,7 +157,6 @@ class Meta:
'max_cheat_warnings',
'allow_auto_unlock',
'auto_unlock_minutes',
'anonymous_mode_enabled',
'participant_count',
'admins',
'is_classroom_bound',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -414,7 +406,6 @@ class Meta:
'allow_auto_unlock',
'auto_unlock_minutes',
'status',
'anonymous_mode_enabled',
'results_published',
]
read_only_fields = ['id']
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand All @@ -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',
]

Expand Down Expand Up @@ -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."""
Expand All @@ -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
# ============================================================================
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)."""
Expand Down
4 changes: 2 additions & 2 deletions backend/apps/contests/services/exam_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
3 changes: 2 additions & 1 deletion backend/apps/contests/services/export_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ''
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fallback paper-export display names to usernames

When a user has no profile.display_name (the model default is empty), this export now writes an empty 顯示名稱 cell for that participant, because it falls back to '' instead of a stable identifier. This regresses the CSV readability for classes where students never set display names, even though usernames are available in the same row. Please fall back to p.user.username so exported records remain identifiable.

Useful? React with 👍 / 👎.

Comment on lines 165 to +167
status_label = p.get_exam_status_display()
p_answers = answer_lookup.get(p.id, {})
graded_count = sum(
Expand Down
4 changes: 1 addition & 3 deletions backend/apps/contests/services/participant_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", ""),
Expand Down
11 changes: 2 additions & 9 deletions backend/apps/contests/services/scoreboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
32 changes: 32 additions & 0 deletions backend/apps/contests/tests/exam/test_exam_answers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions backend/apps/contests/tests/exam/test_exam_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading