Skip to content
Open
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,6 @@ poetry.lock
# Environment variables
.env
.env.local
/.env.development
/.env.development

.cursor
4 changes: 3 additions & 1 deletion core/assignments/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,9 @@ def get_daily_assignments(
DailyAssignment.objects.filter(
study=study,
assigned_date=target_date,
).select_related("problem")
)
.select_related("problem")
.order_by("problem_id")
)

@transaction.atomic
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Generated by Django 5.2.8 on 2026-01-12 03:49

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0008_add_assigned_date_to_solution_note'),
]

operations = [
migrations.AlterField(
model_name='study',
name='template_content',
field=models.TextField(default='## 접근 방법\n - \n - \n\n## 코드\n```\n여기에 코드를 입력하세요\n```\n\n## 회고\n - \n - \n', verbose_name='템플릿 내용'),
),
migrations.CreateModel(
name='ProblemSolvingStatus',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('not_attempted', '미시도'), ('in_progress', '진행중'), ('completed', '완료')], default='not_attempted', max_length=20, verbose_name='풀이 상태')),
('last_updated_at', models.DateTimeField(auto_now=True, verbose_name='마지막 업데이트 시간')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='생성일시')),
('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='solving_statuses', to='core.dailyassignment', verbose_name='일일 과제')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='solving_statuses', to=settings.AUTH_USER_MODEL, verbose_name='사용자')),
],
options={
'verbose_name': '문제 풀이 상태',
'verbose_name_plural': '문제 풀이 상태들',
'db_table': 'problem_solving_statuses',
'indexes': [models.Index(fields=['assignment', 'user'], name='problem_sol_assignm_8f74bd_idx'), models.Index(fields=['assignment'], name='problem_sol_assignm_14e0dc_idx'), models.Index(fields=['user'], name='problem_sol_user_id_525393_idx')],
'unique_together': {('assignment', 'user')},
},
),
]
49 changes: 49 additions & 0 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ class StudyRole(models.TextChoices):
MEMBER = "member", "Member"


class ProblemStatus(models.TextChoices):
"""문제 풀이 상태"""

NOT_ATTEMPTED = "not_attempted", "미시도"
IN_PROGRESS = "in_progress", "진행중"
COMPLETED = "completed", "완료"


class User(AbstractUser):
"""커스텀 User 모델"""

Expand Down Expand Up @@ -206,3 +214,44 @@ class Meta:

def __str__(self):
return f"{self.user.email} - {self.problem.title} ({self.study.name})"


class ProblemSolvingStatus(models.Model):
"""문제 풀이 상태 모델"""

assignment = models.ForeignKey(
"DailyAssignment",
on_delete=models.CASCADE,
related_name="solving_statuses",
verbose_name="일일 과제",
)
user = models.ForeignKey(
"User",
on_delete=models.CASCADE,
related_name="solving_statuses",
verbose_name="사용자",
)
Comment on lines +228 to +233

Choose a reason for hiding this comment

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

medium

User 모델에 대한 ForeignKey를 정의할 때 Django의 권장사항에 따라 settings.AUTH_USER_MODEL을 사용하는 것이 좋습니다. 이렇게 하면 User 모델이 변경되거나 교체될 경우에 더 유연하게 대처할 수 있습니다. 현재 코드도 동작하지만, 일관성과 유지보수성을 위해 변경을 제안합니다.

변경을 위해서는 파일 상단에 from django.conf import settings를 추가해야 합니다.

Suggested change
user = models.ForeignKey(
"User",
on_delete=models.CASCADE,
related_name="solving_statuses",
verbose_name="사용자",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="solving_statuses",
verbose_name="사용자",
)

status = models.CharField(
max_length=20,
choices=ProblemStatus.choices,
default=ProblemStatus.NOT_ATTEMPTED,
verbose_name="풀이 상태",
)
last_updated_at = models.DateTimeField(
auto_now=True, verbose_name="마지막 업데이트 시간"
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="생성일시")

class Meta:
db_table = "problem_solving_statuses"
unique_together = [("assignment", "user")]
indexes = [
models.Index(fields=["assignment", "user"]),
models.Index(fields=["assignment"]),
models.Index(fields=["user"]),
]
verbose_name = "문제 풀이 상태"
verbose_name_plural = "문제 풀이 상태들"

def __str__(self):
return f"{self.user.email} - {self.assignment.problem.title} ({self.assignment.assigned_date}) - {self.get_status_display()}"
Empty file.
221 changes: 221 additions & 0 deletions core/problem_solving_status/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
from rest_framework import serializers


class AssignmentStatusQuerySerializer(serializers.Serializer):
"""문제 풀이 상태 조회 Query Serializer"""

date = serializers.DateField(
required=False, allow_null=True, help_text="날짜 (YYYY-MM-DD, 기본값: 오늘)"
)
view = serializers.ChoiceField(
choices=["me", "group"],
default="me",
required=False,
help_text="조회 방식 (me: 현재 사용자, group: 모든 멤버)",
)


class StatisticsQuerySerializer(serializers.Serializer):
"""통계 조회 Query Serializer"""

view = serializers.ChoiceField(
choices=["me", "group", "member"],
default="me",
required=False,
help_text="조회 방식 (me: 현재 사용자, group: 모든 멤버, member: 특정 멤버)",
)
member_id = serializers.IntegerField(
required=False,
allow_null=True,
help_text="멤버 ID (view=member일 때 필수)",
)
start_date = serializers.DateField(
required=False, allow_null=True, help_text="시작 날짜 (YYYY-MM-DD)"
)
end_date = serializers.DateField(
required=False, allow_null=True, help_text="종료 날짜 (YYYY-MM-DD)"
)

def validate(self, data):
"""view=member일 때 member_id 필수 검증"""
if data.get("view") == "member" and not data.get("member_id"):
raise serializers.ValidationError(
{"member_id": "view='member'일 때 member_id가 필요합니다."}
)
return data


class ProblemStatusSerializer(serializers.Serializer):
"""문제 풀이 상태 Serializer"""

problem_id = serializers.IntegerField()
boj_number = serializers.IntegerField()
title = serializers.CharField()
problem_status = serializers.CharField()
note_status = serializers.CharField()
last_updated_at = serializers.DateTimeField(allow_null=True)


class MemberStatusSerializer(serializers.Serializer):
"""멤버 상태 Serializer"""

member_id = serializers.IntegerField()
member_email = serializers.EmailField()
username = serializers.CharField(allow_null=True)
boj_username = serializers.CharField(allow_null=True)
problem_status = serializers.CharField()
note_status = serializers.CharField()
last_updated_at = serializers.DateTimeField(allow_null=True)


class StatusSummarySerializer(serializers.Serializer):
"""상태 요약 Serializer"""

not_attempted_count = serializers.IntegerField()
in_progress_count = serializers.IntegerField()
completed_count = serializers.IntegerField()


class NoteStatusSummarySerializer(serializers.Serializer):
"""노트 상태 요약 Serializer"""

not_completed_count = serializers.IntegerField()
completed_count = serializers.IntegerField()


class AssignmentStatusResponseSerializer(serializers.Serializer):
"""문제 풀이 상태 조회 응답 Serializer (view=me)"""

date = serializers.DateField()
view = serializers.CharField()
assignments = ProblemStatusSerializer(many=True)
total_count = serializers.IntegerField()
problem_status_summary = StatusSummarySerializer()
note_status_summary = NoteStatusSummarySerializer()
can_update = serializers.BooleanField()
next_available_at = serializers.DateTimeField(allow_null=True)
last_updated_at = serializers.DateTimeField(allow_null=True)


class MemberWithAssignmentsSerializer(serializers.Serializer):
"""멤버와 과제 목록 Serializer"""

member_id = serializers.IntegerField()
member_email = serializers.EmailField()
username = serializers.CharField(allow_null=True)
boj_username = serializers.CharField(allow_null=True)
assignments = ProblemStatusSerializer(many=True)
problem_status_summary = StatusSummarySerializer()
note_status_summary = NoteStatusSummarySerializer()
total_count = serializers.IntegerField()


class OverallSummarySerializer(serializers.Serializer):
"""전체 요약 Serializer"""

problem_status_summary = serializers.DictField()
note_status_summary = serializers.DictField()


class GroupAssignmentStatusResponseSerializer(serializers.Serializer):
"""문제 풀이 상태 조회 응답 Serializer (view=group)"""

date = serializers.DateField()
view = serializers.CharField()
members = MemberWithAssignmentsSerializer(many=True)
total_members = serializers.IntegerField()
overall_summary = OverallSummarySerializer()
can_update = serializers.BooleanField()
next_available_at = serializers.DateTimeField(allow_null=True)
last_updated_at = serializers.DateTimeField(allow_null=True)


class ProblemMembersStatusResponseSerializer(serializers.Serializer):
"""특정 문제의 모든 멤버 풀이 상태 응답 Serializer"""

problem_id = serializers.IntegerField()
boj_number = serializers.IntegerField()
title = serializers.CharField()
members_status = MemberStatusSerializer(many=True)
problem_status_summary = serializers.DictField()
note_status_summary = serializers.DictField()


class DailyStatisticsSerializer(serializers.Serializer):
"""일별 통계 Serializer"""

date = serializers.DateField()
assigned_count = serializers.IntegerField()
problem_status_summary = StatusSummarySerializer()
note_status_summary = NoteStatusSummarySerializer()


class MyStatisticsResponseSerializer(serializers.Serializer):
"""통계 조회 응답 Serializer (view=me)"""

view = serializers.CharField()
member_id = serializers.IntegerField()
member_email = serializers.EmailField()
username = serializers.CharField(allow_null=True)
boj_username = serializers.CharField(allow_null=True)
total_assigned = serializers.IntegerField()
problem_status_summary = StatusSummarySerializer()
note_status_summary = NoteStatusSummarySerializer()
problem_completion_rate = serializers.FloatField()
note_completion_rate = serializers.FloatField()
date_range = serializers.DictField()
daily_statistics = DailyStatisticsSerializer(many=True)


class MemberStatisticsItemSerializer(serializers.Serializer):
"""멤버 통계 항목 Serializer"""

member_id = serializers.IntegerField()
member_email = serializers.EmailField()
username = serializers.CharField(allow_null=True)
boj_username = serializers.CharField(allow_null=True)
total_assigned = serializers.IntegerField()
problem_status_summary = StatusSummarySerializer()
note_status_summary = NoteStatusSummarySerializer()
problem_completion_rate = serializers.FloatField()
note_completion_rate = serializers.FloatField()


class GroupStatisticsResponseSerializer(serializers.Serializer):
"""통계 조회 응답 Serializer (view=group)"""

view = serializers.CharField()
total_members = serializers.IntegerField()
total_assigned = serializers.IntegerField()
problem_status_summary = StatusSummarySerializer()
note_status_summary = NoteStatusSummarySerializer()
average_problem_completion_rate = serializers.FloatField()
average_note_completion_rate = serializers.FloatField()
date_range = serializers.DictField()
members_statistics = MemberStatisticsItemSerializer(many=True)
daily_statistics = DailyStatisticsSerializer(many=True)


class MemberStatisticsResponseSerializer(serializers.Serializer):
"""통계 조회 응답 Serializer (view=member)"""

view = serializers.CharField()
member_id = serializers.IntegerField()
member_email = serializers.EmailField()
username = serializers.CharField(allow_null=True)
boj_username = serializers.CharField(allow_null=True)
total_assigned = serializers.IntegerField()
problem_status_summary = StatusSummarySerializer()
note_status_summary = NoteStatusSummarySerializer()
problem_completion_rate = serializers.FloatField()
note_completion_rate = serializers.FloatField()
date_range = serializers.DictField()
daily_statistics = DailyStatisticsSerializer(many=True)


class UpdateCooldownErrorSerializer(serializers.Serializer):
"""상태 업데이트 쿨다운 에러 Serializer"""

error_code = serializers.CharField()
message = serializers.CharField()
next_available_at = serializers.DateTimeField()
Loading