diff --git a/.gitignore b/.gitignore index 2e18f63..bfebf56 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,6 @@ poetry.lock # Environment variables .env .env.local -/.env.development \ No newline at end of file +/.env.development + +.cursor \ No newline at end of file diff --git a/core/assignments/services.py b/core/assignments/services.py index c9bfc44..0976ac0 100644 --- a/core/assignments/services.py +++ b/core/assignments/services.py @@ -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 diff --git a/core/migrations/0009_alter_study_template_content_problemsolvingstatus.py b/core/migrations/0009_alter_study_template_content_problemsolvingstatus.py new file mode 100644 index 0000000..17d429f --- /dev/null +++ b/core/migrations/0009_alter_study_template_content_problemsolvingstatus.py @@ -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')}, + }, + ), + ] diff --git a/core/models.py b/core/models.py index 743bcfd..0abaf33 100644 --- a/core/models.py +++ b/core/models.py @@ -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 모델""" @@ -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="사용자", + ) + 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()}" diff --git a/core/problem_solving_status/__init__.py b/core/problem_solving_status/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/problem_solving_status/serializers.py b/core/problem_solving_status/serializers.py new file mode 100644 index 0000000..f0af73d --- /dev/null +++ b/core/problem_solving_status/serializers.py @@ -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() diff --git a/core/problem_solving_status/services.py b/core/problem_solving_status/services.py new file mode 100644 index 0000000..f4423f6 --- /dev/null +++ b/core/problem_solving_status/services.py @@ -0,0 +1,1113 @@ +from datetime import date, datetime, timedelta +from typing import List, Optional + +from django.db import transaction +from django.utils import timezone + +from core.models import ( + DailyAssignment, + ProblemSolvingStatus, + ProblemStatus, + SolutionNote, + Study, + StudyMember, +) +from core.utils.solvedac import SolvedAC + +PROBLEM_STATUS_UPDATE_COOLDOWN = 300 # 5분 + + +class UpdateCooldownError(Exception): + """상태 업데이트 쿨다운 에러""" + + def __init__(self, next_available_at: datetime): + self.next_available_at = next_available_at + super().__init__(f"5분 후에 다시 시도해주세요.") + + +class ProblemSolvingStatusService: + """문제 풀이 상태 관리 서비스""" + + def __init__(self): + self.solvedac_service = SolvedAC() + + def can_update_status( + self, user, study: Study, target_date: Optional[date] = None + ) -> tuple[bool, Optional[datetime]]: + """상태 업데이트 가능 여부 확인 (5분 쿨다운, 스터디 멤버 전체 공유) + + Args: + user: 사용자 (쿨다운 체크에는 사용되지 않음, 스터디 전체 공유) + study: 스터디 + target_date: 대상 날짜 (기본값: 오늘) + + Returns: + tuple: (업데이트 가능 여부, 다음 업데이트 가능 시간) + - 업데이트 가능하면 (True, None) + - 업데이트 불가능하면 (False, 다음 업데이트 가능 시간) + """ + if target_date is None: + target_date = date.today() + + # 해당 날짜의 DailyAssignment 중 가장 최근 업데이트 시간 확인 + assignments = DailyAssignment.objects.filter( + study=study, assigned_date=target_date + ) + if not assignments.exists(): + return True, None + + # 해당 날짜의 문제들에 대한 스터디의 모든 멤버 중 가장 최근 업데이트 시간 확인 + # 쿨다운은 스터디 멤버 전체가 공유함 + latest_status = ( + ProblemSolvingStatus.objects.filter( + assignment__study=study, + assignment__assigned_date=target_date, + ) + .order_by("-last_updated_at") + .first() + ) + + if latest_status is None: + return True, None + + next_available_at = latest_status.last_updated_at + timedelta( + seconds=PROBLEM_STATUS_UPDATE_COOLDOWN + ) + now = timezone.now() + + if now >= next_available_at: + return True, None + return False, next_available_at + + @transaction.atomic + def update_solving_status( + self, + user, + study_id: int, + target_date: Optional[date] = None, + ) -> List[ProblemSolvingStatus]: + """오늘의 추천 문제 풀이 상태 일괄 업데이트 (백준 API로 자동 확인) + + Args: + user: 사용자 + study_id: 스터디 ID + target_date: 대상 날짜 (기본값: 오늘) + + Returns: + List[ProblemSolvingStatus]: 업데이트된 상태 목록 + + Raises: + UpdateCooldownError: 5분 쿨다운이 지나지 않은 경우 + ValueError: 사용자의 백준 사용자명이 없는 경우 + """ + if target_date is None: + target_date = date.today() + + # 스터디 조회 + study = Study.objects.get(id=study_id) + + # 쿨다운 체크 + can_update, next_available_at = self.can_update_status(user, study, target_date) + if not can_update: + raise UpdateCooldownError(next_available_at) + + # 사용자의 백준 사용자명 확인 + if not user.boj_username: + raise ValueError("백준 사용자명이 설정되지 않았습니다.") + + # 해당 날짜의 모든 DailyAssignment 조회 + assignments = DailyAssignment.objects.filter( + study=study, assigned_date=target_date + ).select_related("problem") + + if not assignments.exists(): + return [] + + # 문제 ID 리스트 수집 + problem_ids = [assignment.problem.boj_number for assignment in assignments] + + # 백준 API로 여러 문제를 한 번에 확인 + solved_problems = self.solvedac_service.check_user_solved_problems( + user.boj_username, problem_ids + ) + + # 각 assignment에 대해 풀이 상태 확인 후 업데이트 + updated_statuses = [] + for assignment in assignments: + problem_id = assignment.problem.boj_number + is_solved = solved_problems.get(problem_id, False) + + # 풀이 여부에 따라 상태 설정 + # TODO: in_progress 상태 자동 판단 로직 추가 필요 + # 현재는 solved.ac API로 풀었는지만 확인 가능하므로, + # "제출했지만 틀린 상태"를 판단하려면 백준 제출 이력 API 추가 확인 필요 + if is_solved: + status = ProblemStatus.COMPLETED + else: + status = ProblemStatus.NOT_ATTEMPTED + + status_obj, created = ProblemSolvingStatus.objects.update_or_create( + assignment=assignment, + user=user, + defaults={"status": status}, + ) + updated_statuses.append(status_obj) + + return updated_statuses + + def get_solving_statuses( + self, + user, + study_id: int, + target_date: Optional[date] = None, + view: str = "me", + ): + """문제 풀이 상태 조회 + + Args: + user: 조회하는 사용자 + study_id: 스터디 ID + target_date: 대상 날짜 (기본값: 오늘) + view: 조회 방식 ('me' 또는 'group', 기본값: 'me') + + Returns: + dict: 조회 결과 (view에 따라 구조가 다름) + """ + if target_date is None: + target_date = date.today() + + # 스터디 조회 + study = Study.objects.get(id=study_id) + + # 해당 날짜의 모든 DailyAssignment 조회 + assignments = ( + DailyAssignment.objects.filter(study=study, assigned_date=target_date) + .select_related("problem") + .order_by("problem_id") + ) + + if view == "me": + return self._get_my_solving_statuses(user, study, assignments, target_date) + elif view == "group": + return self._get_group_solving_statuses(study, assignments, target_date) + else: + raise ValueError(f"잘못된 view 값입니다: {view}") + + def _get_my_solving_statuses(self, user, study, assignments, target_date) -> dict: + """현재 사용자의 문제 풀이 상태 조회""" + if not assignments.exists(): + return { + "date": target_date.isoformat(), + "view": "me", + "assignments": [], + "total_count": 0, + "problem_status_summary": { + "not_attempted_count": 0, + "in_progress_count": 0, + "completed_count": 0, + }, + "note_status_summary": { + "not_completed_count": 0, + "completed_count": 0, + }, + "can_update": True, + "next_available_at": None, + "last_updated_at": timezone.make_aware( + datetime.combine(target_date, datetime.min.time()) + ).isoformat(), + } + + # 사용자의 ProblemSolvingStatus 조회 + statuses = ProblemSolvingStatus.objects.filter( + assignment__in=assignments, user=user + ).select_related("assignment", "assignment__problem") + + # status를 assignment_id로 매핑 + status_dict = {status.assignment_id: status for status in statuses} + + # 사용자의 SolutionNote 조회 (note_status 확인용) + solution_notes = SolutionNote.objects.filter( + study=study, user=user, problem__in=[a.problem for a in assignments] + ).select_related("problem") + + # note를 problem_id로 매핑 + note_dict = {note.problem_id: note for note in solution_notes} + + # 쿨다운 체크 + can_update, next_available_at = self.can_update_status(user, study, target_date) + + # assignments를 순회하며 상태 정보 구성 + assignment_list = [] + problem_status_counts = { + "not_attempted_count": 0, + "in_progress_count": 0, + "completed_count": 0, + } + note_status_counts = {"not_completed_count": 0, "completed_count": 0} + latest_updated_at = None + + for assignment in assignments: + status = status_dict.get(assignment.id) + note = note_dict.get(assignment.problem_id) + + problem_status = status.status if status else ProblemStatus.NOT_ATTEMPTED + note_status = "completed" if note else "not_completed" + + assignment_list.append( + { + "problem_id": assignment.problem_id, + "boj_number": assignment.problem.boj_number, + "title": assignment.problem.title, + "problem_status": problem_status, + "note_status": note_status, + "last_updated_at": ( + status.last_updated_at.isoformat() if status else None + ), + } + ) + + # 카운트 증가 + if problem_status == ProblemStatus.NOT_ATTEMPTED: + problem_status_counts["not_attempted_count"] += 1 + elif problem_status == ProblemStatus.IN_PROGRESS: + problem_status_counts["in_progress_count"] += 1 + elif problem_status == ProblemStatus.COMPLETED: + problem_status_counts["completed_count"] += 1 + + if note_status == "not_completed": + note_status_counts["not_completed_count"] += 1 + else: + note_status_counts["completed_count"] += 1 + + # 가장 최근 업데이트 시간 추적 + if status and status.last_updated_at: + if ( + latest_updated_at is None + or status.last_updated_at > latest_updated_at + ): + latest_updated_at = status.last_updated_at + + return { + "date": target_date.isoformat(), + "view": "me", + "assignments": assignment_list, + "total_count": len(assignment_list), + "problem_status_summary": problem_status_counts, + "note_status_summary": note_status_counts, + "can_update": can_update, + "next_available_at": ( + next_available_at.isoformat() if next_available_at else None + ), + "last_updated_at": ( + latest_updated_at.isoformat() + if latest_updated_at + else timezone.make_aware( + datetime.combine(target_date, datetime.min.time()) + ).isoformat() + ), + } + + def _get_group_solving_statuses(self, study, assignments, target_date) -> dict: + """모든 멤버의 문제 풀이 상태 조회 (그룹 조회)""" + # 쿨다운 체크 (스터디 전체 공유) + can_update, next_available_at = self.can_update_status(None, study, target_date) + + if not assignments.exists(): + return { + "date": target_date.isoformat(), + "view": "group", + "members": [], + "total_members": 0, + "overall_summary": { + "problem_status_summary": { + "not_attempted_count": 0, + "in_progress_count": 0, + "completed_count": 0, + "total": 0, + }, + "note_status_summary": { + "not_completed_count": 0, + "completed_count": 0, + "total": 0, + }, + }, + "can_update": can_update, + "next_available_at": ( + next_available_at.isoformat() if next_available_at else None + ), + "last_updated_at": timezone.make_aware( + datetime.combine(target_date, datetime.min.time()) + ).isoformat(), + } + + # 스터디 멤버 조회 + members = StudyMember.objects.filter(study=study).select_related("user") + + # 모든 멤버의 ProblemSolvingStatus 조회 + statuses = ProblemSolvingStatus.objects.filter( + assignment__in=assignments + ).select_related("assignment", "assignment__problem", "user") + + # status를 (assignment_id, user_id)로 매핑 + status_dict = { + (status.assignment_id, status.user_id): status for status in statuses + } + + # 모든 멤버의 SolutionNote 조회 + solution_notes = SolutionNote.objects.filter( + study=study, problem__in=[a.problem for a in assignments] + ).select_related("problem", "user") + + # note를 (problem_id, user_id)로 매핑 + note_dict = {(note.problem_id, note.user_id): note for note in solution_notes} + + # 멤버별로 상태 정보 구성 + members_list = [] + overall_problem_counts = { + "not_attempted_count": 0, + "in_progress_count": 0, + "completed_count": 0, + "total": 0, + } + overall_note_counts = { + "not_completed_count": 0, + "completed_count": 0, + "total": 0, + } + latest_updated_at = None + + for member in members: + member_assignments = [] + member_problem_counts = { + "not_attempted_count": 0, + "in_progress_count": 0, + "completed_count": 0, + } + member_note_counts = {"not_completed_count": 0, "completed_count": 0} + + for assignment in assignments: + status = status_dict.get((assignment.id, member.user_id)) + note = note_dict.get((assignment.problem_id, member.user_id)) + + problem_status = ( + status.status if status else ProblemStatus.NOT_ATTEMPTED + ) + note_status = "completed" if note else "not_completed" + + member_assignments.append( + { + "problem_id": assignment.problem_id, + "boj_number": assignment.problem.boj_number, + "title": assignment.problem.title, + "problem_status": problem_status, + "note_status": note_status, + "last_updated_at": ( + status.last_updated_at.isoformat() if status else None + ), + } + ) + + # 멤버별 카운트 + if problem_status == ProblemStatus.NOT_ATTEMPTED: + member_problem_counts["not_attempted_count"] += 1 + elif problem_status == ProblemStatus.IN_PROGRESS: + member_problem_counts["in_progress_count"] += 1 + elif problem_status == ProblemStatus.COMPLETED: + member_problem_counts["completed_count"] += 1 + + if note_status == "not_completed": + member_note_counts["not_completed_count"] += 1 + else: + member_note_counts["completed_count"] += 1 + + # 전체 카운트 + overall_problem_counts["total"] += 1 + overall_note_counts["total"] += 1 + if problem_status == ProblemStatus.NOT_ATTEMPTED: + overall_problem_counts["not_attempted_count"] += 1 + elif problem_status == ProblemStatus.IN_PROGRESS: + overall_problem_counts["in_progress_count"] += 1 + elif problem_status == ProblemStatus.COMPLETED: + overall_problem_counts["completed_count"] += 1 + + if note_status == "not_completed": + overall_note_counts["not_completed_count"] += 1 + else: + overall_note_counts["completed_count"] += 1 + + # 가장 최근 업데이트 시간 추적 + if status and status.last_updated_at: + if ( + latest_updated_at is None + or status.last_updated_at > latest_updated_at + ): + latest_updated_at = status.last_updated_at + + members_list.append( + { + "member_id": member.user_id, + "member_email": member.user.email, + "username": member.user.username, + "boj_username": member.user.boj_username, + "assignments": member_assignments, + "problem_status_summary": member_problem_counts, + "note_status_summary": member_note_counts, + "total_count": len(member_assignments), + } + ) + + return { + "date": target_date.isoformat(), + "view": "group", + "members": members_list, + "total_members": len(members_list), + "overall_summary": { + "problem_status_summary": overall_problem_counts, + "note_status_summary": overall_note_counts, + }, + "can_update": can_update, + "next_available_at": ( + next_available_at.isoformat() if next_available_at else None + ), + "last_updated_at": ( + latest_updated_at.isoformat() + if latest_updated_at + else timezone.make_aware( + datetime.combine(target_date, datetime.min.time()) + ).isoformat() + ), + } + + def get_problem_members_status(self, study_id: int, problem_id: int) -> dict: + """특정 문제의 모든 멤버 풀이 상태 조회 + + Args: + study_id: 스터디 ID + problem_id: 문제 ID (Problem 모델의 ID) + + Returns: + dict: 문제 정보 및 모든 멤버의 풀이 상태 + """ + # 스터디 및 문제 조회 + study = Study.objects.get(id=study_id) + from core.models import Problem + + problem = Problem.objects.get(id=problem_id) + + # 스터디 멤버 조회 + members = StudyMember.objects.filter(study=study).select_related("user") + + # 해당 문제가 포함된 모든 DailyAssignment 조회 (날짜 무관) + assignments = DailyAssignment.objects.filter( + study=study, problem=problem + ).select_related("problem") + + if not assignments.exists(): + return { + "problem_id": problem_id, + "boj_number": problem.boj_number, + "title": problem.title, + "members_status": [], + "problem_status_summary": { + "not_attempted_count": 0, + "in_progress_count": 0, + "completed_count": 0, + "total_members": len(members), + }, + "note_status_summary": { + "not_completed_count": 0, + "completed_count": 0, + "total_members": len(members), + }, + } + + # 모든 assignment에 대한 ProblemSolvingStatus 조회 + # 가장 최근 assignment의 상태를 사용 (또는 모든 assignment의 상태를 집계) + # 일단 가장 최근 assignment만 사용 + latest_assignment = assignments.order_by("-assigned_date").first() + + statuses = ProblemSolvingStatus.objects.filter( + assignment=latest_assignment + ).select_related("user") + + # status를 user_id로 매핑 + status_dict = {status.user_id: status for status in statuses} + + # 모든 멤버의 SolutionNote 조회 + solution_notes = SolutionNote.objects.filter( + study=study, problem=problem + ).select_related("user") + + # note를 user_id로 매핑 + note_dict = {note.user_id: note for note in solution_notes} + + # 멤버별 상태 정보 구성 + members_status_list = [] + problem_status_counts = { + "not_attempted_count": 0, + "in_progress_count": 0, + "completed_count": 0, + "total_members": len(members), + } + note_status_counts = { + "not_completed_count": 0, + "completed_count": 0, + "total_members": len(members), + } + + for member in members: + status = status_dict.get(member.user_id) + note = note_dict.get(member.user_id) + + problem_status = status.status if status else ProblemStatus.NOT_ATTEMPTED + note_status = "completed" if note else "not_completed" + + members_status_list.append( + { + "member_id": member.user_id, + "member_email": member.user.email, + "username": member.user.username, + "boj_username": member.user.boj_username, + "problem_status": problem_status, + "note_status": note_status, + "last_updated_at": ( + status.last_updated_at.isoformat() if status else None + ), + } + ) + + # 카운트 증가 + if problem_status == ProblemStatus.NOT_ATTEMPTED: + problem_status_counts["not_attempted_count"] += 1 + elif problem_status == ProblemStatus.IN_PROGRESS: + problem_status_counts["in_progress_count"] += 1 + elif problem_status == ProblemStatus.COMPLETED: + problem_status_counts["completed_count"] += 1 + + if note_status == "not_completed": + note_status_counts["not_completed_count"] += 1 + else: + note_status_counts["completed_count"] += 1 + + return { + "problem_id": problem_id, + "boj_number": problem.boj_number, + "title": problem.title, + "members_status": members_status_list, + "problem_status_summary": problem_status_counts, + "note_status_summary": note_status_counts, + } + + def get_statistics( + self, + user, + study_id: int, + view: str = "me", + member_id: Optional[int] = None, + start_date: Optional[date] = None, + end_date: Optional[date] = None, + ) -> dict: + """문제 풀이 통계 조회 + + Args: + user: 조회하는 사용자 + study_id: 스터디 ID + view: 조회 방식 ('me', 'group', 'member', 기본값: 'me') + member_id: 멤버 ID (view='member'일 때 필수) + start_date: 시작 날짜 (선택) + end_date: 종료 날짜 (선택) + + Returns: + dict: 통계 정보 (view에 따라 구조가 다름) + """ + # 스터디 조회 + study = Study.objects.get(id=study_id) + + if view == "me": + return self._get_my_statistics(user, study, start_date, end_date) + elif view == "group": + return self._get_group_statistics(study, start_date, end_date) + elif view == "member": + if member_id is None: + raise ValueError("view='member'일 때 member_id가 필요합니다.") + return self._get_member_statistics(study, member_id, start_date, end_date) + else: + raise ValueError(f"잘못된 view 값입니다: {view}") + + def _get_my_statistics( + self, user, study, start_date: Optional[date], end_date: Optional[date] + ) -> dict: + """현재 사용자의 통계 조회""" + # 날짜 범위 필터링 + assignments_query = DailyAssignment.objects.filter(study=study) + if start_date: + assignments_query = assignments_query.filter(assigned_date__gte=start_date) + if end_date: + assignments_query = assignments_query.filter(assigned_date__lte=end_date) + + assignments = assignments_query.select_related("problem") + + # 사용자의 ProblemSolvingStatus 조회 + statuses = ProblemSolvingStatus.objects.filter( + assignment__in=assignments, user=user + ).select_related("assignment", "assignment__problem") + + # 사용자의 SolutionNote 조회 + problems = [a.problem for a in assignments] + solution_notes = SolutionNote.objects.filter( + study=study, user=user, problem__in=problems + ).select_related("problem") + + # status와 note를 매핑 + status_dict = {status.assignment_id: status for status in statuses} + note_dict = {note.problem_id: note for note in solution_notes} + + # 전체 통계 계산 + total_assigned = len(assignments) + problem_status_counts = { + "not_attempted_count": 0, + "in_progress_count": 0, + "completed_count": 0, + } + note_status_counts = {"not_completed_count": 0, "completed_count": 0} + + for assignment in assignments: + status = status_dict.get(assignment.id) + note = note_dict.get(assignment.problem_id) + + problem_status = status.status if status else ProblemStatus.NOT_ATTEMPTED + note_status = "completed" if note else "not_completed" + + if problem_status == ProblemStatus.NOT_ATTEMPTED: + problem_status_counts["not_attempted_count"] += 1 + elif problem_status == ProblemStatus.IN_PROGRESS: + problem_status_counts["in_progress_count"] += 1 + elif problem_status == ProblemStatus.COMPLETED: + problem_status_counts["completed_count"] += 1 + + if note_status == "not_completed": + note_status_counts["not_completed_count"] += 1 + else: + note_status_counts["completed_count"] += 1 + + # 완료율 계산 + completed_count = problem_status_counts["completed_count"] + problem_completion_rate = ( + completed_count / total_assigned if total_assigned > 0 else 0.0 + ) + note_completed_count = note_status_counts["completed_count"] + note_completion_rate = ( + note_completed_count / total_assigned if total_assigned > 0 else 0.0 + ) + + # 일별 통계 계산 + daily_statistics = self._calculate_daily_statistics( + user, study, assignments, status_dict, note_dict, start_date, end_date + ) + + return { + "view": "me", + "member_id": user.id, + "member_email": user.email, + "username": user.username, + "boj_username": user.boj_username, + "total_assigned": total_assigned, + "problem_status_summary": problem_status_counts, + "note_status_summary": note_status_counts, + "problem_completion_rate": problem_completion_rate, + "note_completion_rate": note_completion_rate, + "date_range": { + "start_date": start_date.isoformat() if start_date else None, + "end_date": end_date.isoformat() if end_date else None, + }, + "daily_statistics": daily_statistics, + } + + def _get_group_statistics( + self, study, start_date: Optional[date], end_date: Optional[date] + ) -> dict: + """모든 멤버의 통계 조회 (그룹 통계)""" + # 날짜 범위 필터링 + assignments_query = DailyAssignment.objects.filter(study=study) + if start_date: + assignments_query = assignments_query.filter(assigned_date__gte=start_date) + if end_date: + assignments_query = assignments_query.filter(assigned_date__lte=end_date) + + assignments = assignments_query.select_related("problem") + + # 스터디 멤버 조회 + members = StudyMember.objects.filter(study=study).select_related("user") + + # 모든 멤버의 ProblemSolvingStatus 조회 + statuses = ProblemSolvingStatus.objects.filter( + assignment__in=assignments + ).select_related("assignment", "assignment__problem", "user") + + # 모든 멤버의 SolutionNote 조회 + problems = [a.problem for a in assignments] + solution_notes = SolutionNote.objects.filter( + study=study, problem__in=problems + ).select_related("problem", "user") + + # status와 note를 매핑 + status_dict = { + (status.assignment_id, status.user_id): status for status in statuses + } + note_dict = {(note.problem_id, note.user_id): note for note in solution_notes} + + # 전체 통계 계산 + total_assigned = len(assignments) * len(members) + overall_problem_counts = { + "not_attempted_count": 0, + "in_progress_count": 0, + "completed_count": 0, + } + overall_note_counts = {"not_completed_count": 0, "completed_count": 0} + + # 멤버별 통계 계산 + members_statistics = [] + completion_rates = [] + + for member in members: + member_problem_counts = { + "not_attempted_count": 0, + "in_progress_count": 0, + "completed_count": 0, + } + member_note_counts = {"not_completed_count": 0, "completed_count": 0} + + for assignment in assignments: + status = status_dict.get((assignment.id, member.user_id)) + note = note_dict.get((assignment.problem_id, member.user_id)) + + problem_status = ( + status.status if status else ProblemStatus.NOT_ATTEMPTED + ) + note_status = "completed" if note else "not_completed" + + # 멤버별 카운트 + if problem_status == ProblemStatus.NOT_ATTEMPTED: + member_problem_counts["not_attempted_count"] += 1 + elif problem_status == ProblemStatus.IN_PROGRESS: + member_problem_counts["in_progress_count"] += 1 + elif problem_status == ProblemStatus.COMPLETED: + member_problem_counts["completed_count"] += 1 + + if note_status == "not_completed": + member_note_counts["not_completed_count"] += 1 + else: + member_note_counts["completed_count"] += 1 + + # 전체 카운트 + if problem_status == ProblemStatus.NOT_ATTEMPTED: + overall_problem_counts["not_attempted_count"] += 1 + elif problem_status == ProblemStatus.IN_PROGRESS: + overall_problem_counts["in_progress_count"] += 1 + elif problem_status == ProblemStatus.COMPLETED: + overall_problem_counts["completed_count"] += 1 + + if note_status == "not_completed": + overall_note_counts["not_completed_count"] += 1 + else: + overall_note_counts["completed_count"] += 1 + + # 멤버별 완료율 계산 + member_total = len(assignments) + member_problem_rate = ( + member_problem_counts["completed_count"] / member_total + if member_total > 0 + else 0.0 + ) + member_note_rate = ( + member_note_counts["completed_count"] / member_total + if member_total > 0 + else 0.0 + ) + completion_rates.append(member_problem_rate) + + members_statistics.append( + { + "member_id": member.user_id, + "member_email": member.user.email, + "username": member.user.username, + "boj_username": member.user.boj_username, + "total_assigned": member_total, + "problem_status_summary": member_problem_counts, + "note_status_summary": member_note_counts, + "problem_completion_rate": member_problem_rate, + "note_completion_rate": member_note_rate, + } + ) + + # 평균 완료율 계산 + average_problem_rate = ( + sum(completion_rates) / len(completion_rates) if completion_rates else 0.0 + ) + average_note_rate = ( + sum( + [ + m["note_completion_rate"] + for m in members_statistics + if m["total_assigned"] > 0 + ] + ) + / len([m for m in members_statistics if m["total_assigned"] > 0]) + if members_statistics + else 0.0 + ) + + # 일별 통계 계산 + daily_statistics = self._calculate_group_daily_statistics( + study, members, assignments, status_dict, note_dict, start_date, end_date + ) + + return { + "view": "group", + "total_members": len(members), + "total_assigned": total_assigned, + "problem_status_summary": overall_problem_counts, + "note_status_summary": overall_note_counts, + "average_problem_completion_rate": average_problem_rate, + "average_note_completion_rate": average_note_rate, + "date_range": { + "start_date": start_date.isoformat() if start_date else None, + "end_date": end_date.isoformat() if end_date else None, + }, + "members_statistics": members_statistics, + "daily_statistics": daily_statistics, + } + + def _get_member_statistics( + self, + study, + member_id: int, + start_date: Optional[date], + end_date: Optional[date], + ) -> dict: + """특정 멤버의 통계 조회""" + from core.models import User + + member = User.objects.get(id=member_id) + + # 날짜 범위 필터링 + assignments_query = DailyAssignment.objects.filter(study=study) + if start_date: + assignments_query = assignments_query.filter(assigned_date__gte=start_date) + if end_date: + assignments_query = assignments_query.filter(assigned_date__lte=end_date) + + assignments = assignments_query.select_related("problem") + + # 멤버의 ProblemSolvingStatus 조회 + statuses = ProblemSolvingStatus.objects.filter( + assignment__in=assignments, user=member + ).select_related("assignment", "assignment__problem") + + # 멤버의 SolutionNote 조회 + problems = [a.problem for a in assignments] + solution_notes = SolutionNote.objects.filter( + study=study, user=member, problem__in=problems + ).select_related("problem") + + # status와 note를 매핑 + status_dict = {status.assignment_id: status for status in statuses} + note_dict = {note.problem_id: note for note in solution_notes} + + # 전체 통계 계산 + total_assigned = len(assignments) + problem_status_counts = { + "not_attempted_count": 0, + "in_progress_count": 0, + "completed_count": 0, + } + note_status_counts = {"not_completed_count": 0, "completed_count": 0} + + for assignment in assignments: + status = status_dict.get(assignment.id) + note = note_dict.get(assignment.problem_id) + + problem_status = status.status if status else ProblemStatus.NOT_ATTEMPTED + note_status = "completed" if note else "not_completed" + + if problem_status == ProblemStatus.NOT_ATTEMPTED: + problem_status_counts["not_attempted_count"] += 1 + elif problem_status == ProblemStatus.IN_PROGRESS: + problem_status_counts["in_progress_count"] += 1 + elif problem_status == ProblemStatus.COMPLETED: + problem_status_counts["completed_count"] += 1 + + if note_status == "not_completed": + note_status_counts["not_completed_count"] += 1 + else: + note_status_counts["completed_count"] += 1 + + # 완료율 계산 + completed_count = problem_status_counts["completed_count"] + problem_completion_rate = ( + completed_count / total_assigned if total_assigned > 0 else 0.0 + ) + note_completed_count = note_status_counts["completed_count"] + note_completion_rate = ( + note_completed_count / total_assigned if total_assigned > 0 else 0.0 + ) + + # 일별 통계 계산 + daily_statistics = self._calculate_daily_statistics( + member, study, assignments, status_dict, note_dict, start_date, end_date + ) + + return { + "view": "member", + "member_id": member.id, + "member_email": member.email, + "username": member.username, + "boj_username": member.boj_username, + "total_assigned": total_assigned, + "problem_status_summary": problem_status_counts, + "note_status_summary": note_status_counts, + "problem_completion_rate": problem_completion_rate, + "note_completion_rate": note_completion_rate, + "date_range": { + "start_date": start_date.isoformat() if start_date else None, + "end_date": end_date.isoformat() if end_date else None, + }, + "daily_statistics": daily_statistics, + } + + def _calculate_daily_statistics( + self, + user, + study, + assignments, + status_dict, + note_dict, + start_date: Optional[date], + end_date: Optional[date], + ) -> List[dict]: + """일별 통계 계산 (개인용)""" + from collections import defaultdict + + # 날짜별로 그룹화 + daily_data = defaultdict( + lambda: { + "assignments": [], + "problem_counts": { + "not_attempted_count": 0, + "in_progress_count": 0, + "completed_count": 0, + }, + "note_counts": {"not_completed_count": 0, "completed_count": 0}, + } + ) + + for assignment in assignments: + assigned_date = assignment.assigned_date + daily_data[assigned_date]["assignments"].append(assignment) + + # 각 날짜별 통계 계산 + daily_statistics = [] + for assigned_date in sorted(daily_data.keys()): + date_assignments = daily_data[assigned_date]["assignments"] + problem_counts = { + "not_attempted_count": 0, + "in_progress_count": 0, + "completed_count": 0, + } + note_counts = {"not_completed_count": 0, "completed_count": 0} + + for assignment in date_assignments: + status = status_dict.get(assignment.id) + note = note_dict.get(assignment.problem_id) + + problem_status = ( + status.status if status else ProblemStatus.NOT_ATTEMPTED + ) + note_status = "completed" if note else "not_completed" + + if problem_status == ProblemStatus.NOT_ATTEMPTED: + problem_counts["not_attempted_count"] += 1 + elif problem_status == ProblemStatus.IN_PROGRESS: + problem_counts["in_progress_count"] += 1 + elif problem_status == ProblemStatus.COMPLETED: + problem_counts["completed_count"] += 1 + + if note_status == "not_completed": + note_counts["not_completed_count"] += 1 + else: + note_counts["completed_count"] += 1 + + daily_statistics.append( + { + "date": assigned_date.isoformat(), + "assigned_count": len(date_assignments), + "problem_status_summary": problem_counts, + "note_status_summary": note_counts, + } + ) + + return daily_statistics + + def _calculate_group_daily_statistics( + self, + study, + members, + assignments, + status_dict, + note_dict, + start_date: Optional[date], + end_date: Optional[date], + ) -> List[dict]: + """일별 통계 계산 (그룹용)""" + from collections import defaultdict + + # 날짜별로 그룹화 + daily_data = defaultdict(lambda: {"assignments": []}) + + for assignment in assignments: + assigned_date = assignment.assigned_date + daily_data[assigned_date]["assignments"].append(assignment) + + # 각 날짜별 통계 계산 + daily_statistics = [] + for assigned_date in sorted(daily_data.keys()): + date_assignments = daily_data[assigned_date]["assignments"] + problem_counts = { + "not_attempted_count": 0, + "in_progress_count": 0, + "completed_count": 0, + } + note_counts = {"not_completed_count": 0, "completed_count": 0} + + for assignment in date_assignments: + for member in members: + status = status_dict.get((assignment.id, member.user_id)) + note = note_dict.get((assignment.problem_id, member.user_id)) + + problem_status = ( + status.status if status else ProblemStatus.NOT_ATTEMPTED + ) + note_status = "completed" if note else "not_completed" + + if problem_status == ProblemStatus.NOT_ATTEMPTED: + problem_counts["not_attempted_count"] += 1 + elif problem_status == ProblemStatus.IN_PROGRESS: + problem_counts["in_progress_count"] += 1 + elif problem_status == ProblemStatus.COMPLETED: + problem_counts["completed_count"] += 1 + + if note_status == "not_completed": + note_counts["not_completed_count"] += 1 + else: + note_counts["completed_count"] += 1 + + daily_statistics.append( + { + "date": assigned_date.isoformat(), + "assigned_count": len(date_assignments), + "problem_status_summary": problem_counts, + "note_status_summary": note_counts, + } + ) + + return daily_statistics diff --git a/core/problem_solving_status/urls.py b/core/problem_solving_status/urls.py new file mode 100644 index 0000000..2e26517 --- /dev/null +++ b/core/problem_solving_status/urls.py @@ -0,0 +1,31 @@ +from django.urls import path +from .views import ( + ProblemStatusUpdateView, + ProblemStatusView, + ProblemMembersStatusView, + StatisticsView, +) + +urlpatterns = [ + path( + "studies//assignments/update/", + ProblemStatusUpdateView.as_view(), + name="problem_status_update", + ), + path( + "studies//assignments/status/", + ProblemStatusView.as_view(), + name="problem_status", + ), + path( + "studies//problems//status/members/", + ProblemMembersStatusView.as_view(), + name="problem_members_status", + ), + path( + "studies//statistics/", + StatisticsView.as_view(), + name="statistics", + ), +] + diff --git a/core/problem_solving_status/views.py b/core/problem_solving_status/views.py new file mode 100644 index 0000000..e31603d --- /dev/null +++ b/core/problem_solving_status/views.py @@ -0,0 +1,249 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes + +from core.models import Study +from core.common.serializers import ErrorEnvelopeSerializer +from core.study.permissions import IsStudyMember + +from .serializers import ( + AssignmentStatusQuerySerializer, + AssignmentStatusResponseSerializer, + GroupAssignmentStatusResponseSerializer, + ProblemMembersStatusResponseSerializer, + StatisticsQuerySerializer, + MyStatisticsResponseSerializer, + GroupStatisticsResponseSerializer, + MemberStatisticsResponseSerializer, + UpdateCooldownErrorSerializer, +) +from .services import ProblemSolvingStatusService, UpdateCooldownError + + +@extend_schema(tags=["problem_solving_status"]) +class ProblemStatusUpdateView(APIView): + """문제 풀이 상태 업데이트 API""" + + permission_classes = [IsAuthenticated, IsStudyMember] + + @extend_schema( + summary="문제 풀이 상태 업데이트", + description="오늘의 추천 문제 풀이 상태를 백준 API로 자동 확인하여 업데이트합니다. 5분마다 1번만 가능합니다.", + responses={ + 200: None, + 429: UpdateCooldownErrorSerializer, + 400: ErrorEnvelopeSerializer, + }, + ) + def post(self, request, study_id): + """문제 풀이 상태 업데이트""" + study = get_object_or_404(Study, id=study_id) + self.check_object_permissions(request, study) + + service = ProblemSolvingStatusService() + + try: + service.update_solving_status( + user=request.user, + study_id=study_id, + ) + except UpdateCooldownError as e: + return Response( + { + "error_code": "UPDATE_COOLDOWN", + "message": str(e), + "next_available_at": e.next_available_at, + }, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + except ValueError as e: + return Response( + { + "error_code": "INVALID_REQUEST", + "message": str(e), + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response(status=status.HTTP_200_OK) + + +@extend_schema(tags=["problem_solving_status"]) +class ProblemStatusView(APIView): + """문제 풀이 상태 조회 API""" + + permission_classes = [IsAuthenticated, IsStudyMember] + + @extend_schema( + summary="문제 풀이 상태 조회", + description="오늘의 추천 문제 풀이 상태를 조회합니다.", + parameters=[ + OpenApiParameter( + name="date", + type=OpenApiTypes.DATE, + location=OpenApiParameter.QUERY, + description="날짜 (YYYY-MM-DD, 기본값: 오늘)", + required=False, + ), + OpenApiParameter( + name="view", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="조회 방식 (me: 현재 사용자, group: 모든 멤버, 기본값: me)", + required=False, + enum=["me", "group"], + ), + ], + responses={ + 200: AssignmentStatusResponseSerializer, + 400: ErrorEnvelopeSerializer, + }, + ) + def get(self, request, study_id): + """문제 풀이 상태 조회""" + study = get_object_or_404(Study, id=study_id) + self.check_object_permissions(request, study) + + # Query 파라미터 검증 + serializer = AssignmentStatusQuerySerializer(data=request.query_params) + if not serializer.is_valid(): + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + + target_date = serializer.validated_data.get("date") + view = serializer.validated_data.get("view", "me") + + service = ProblemSolvingStatusService() + result = service.get_solving_statuses( + user=request.user, + study_id=study_id, + target_date=target_date, + view=view, + ) + + # view에 따라 다른 serializer 사용 + if view == "me": + response_serializer = AssignmentStatusResponseSerializer(instance=result) + else: # view == "group" + response_serializer = GroupAssignmentStatusResponseSerializer( + instance=result + ) + + return Response(response_serializer.data, status=status.HTTP_200_OK) + + +@extend_schema(tags=["problem_solving_status"]) +class ProblemMembersStatusView(APIView): + """특정 문제의 모든 멤버 풀이 상태 조회 API""" + + permission_classes = [IsAuthenticated, IsStudyMember] + + @extend_schema( + summary="특정 문제의 모든 멤버 풀이 상태 조회", + description="특정 문제에 대한 모든 멤버의 풀이 상태를 조회합니다.", + responses={ + 200: ProblemMembersStatusResponseSerializer, + 404: ErrorEnvelopeSerializer, + }, + ) + def get(self, request, study_id, problem_id): + """특정 문제의 모든 멤버 풀이 상태 조회""" + study = get_object_or_404(Study, id=study_id) + self.check_object_permissions(request, study) + + service = ProblemSolvingStatusService() + result = service.get_problem_members_status( + study_id=study_id, problem_id=problem_id + ) + + response_serializer = ProblemMembersStatusResponseSerializer(instance=result) + return Response(response_serializer.data, status=status.HTTP_200_OK) + + +@extend_schema(tags=["problem_solving_status"]) +class StatisticsView(APIView): + """문제 풀이 통계 조회 API""" + + permission_classes = [IsAuthenticated, IsStudyMember] + + @extend_schema( + summary="문제 풀이 통계 조회", + description="문제 풀이 통계를 조회합니다.", + parameters=[ + OpenApiParameter( + name="view", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="조회 방식 (me: 현재 사용자, group: 모든 멤버, member: 특정 멤버, 기본값: me)", + required=False, + enum=["me", "group", "member"], + ), + OpenApiParameter( + name="member_id", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="멤버 ID (view=member일 때 필수)", + required=False, + ), + OpenApiParameter( + name="start_date", + type=OpenApiTypes.DATE, + location=OpenApiParameter.QUERY, + description="시작 날짜 (YYYY-MM-DD)", + required=False, + ), + OpenApiParameter( + name="end_date", + type=OpenApiTypes.DATE, + location=OpenApiParameter.QUERY, + description="종료 날짜 (YYYY-MM-DD)", + required=False, + ), + ], + responses={ + 200: MyStatisticsResponseSerializer, + 400: ErrorEnvelopeSerializer, + }, + ) + def get(self, request, study_id): + """문제 풀이 통계 조회""" + study = get_object_or_404(Study, id=study_id) + self.check_object_permissions(request, study) + + # Query 파라미터 검증 + serializer = StatisticsQuerySerializer(data=request.query_params) + if not serializer.is_valid(): + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + + view = serializer.validated_data.get("view", "me") + member_id = serializer.validated_data.get("member_id") + start_date = serializer.validated_data.get("start_date") + end_date = serializer.validated_data.get("end_date") + + service = ProblemSolvingStatusService() + result = service.get_statistics( + user=request.user, + study_id=study_id, + view=view, + member_id=member_id, + start_date=start_date, + end_date=end_date, + ) + + # view에 따라 다른 serializer 사용 + if view == "me": + response_serializer = MyStatisticsResponseSerializer(instance=result) + elif view == "group": + response_serializer = GroupStatisticsResponseSerializer(instance=result) + else: # view == "member" + response_serializer = MemberStatisticsResponseSerializer(instance=result) + + return Response(response_serializer.data, status=status.HTTP_200_OK) + diff --git a/core/study/views.py b/core/study/views.py index 5dd47ed..18cd636 100644 --- a/core/study/views.py +++ b/core/study/views.py @@ -215,6 +215,7 @@ def get(self, request): StudyMember.objects.filter(user=request.user) .select_related("study") .annotate(member_count=Count("study__members")) + .order_by("study__id") ) serializer = StudyListSerializer(study_memberships, many=True) diff --git a/core/urls.py b/core/urls.py index 89499cc..02ec576 100644 --- a/core/urls.py +++ b/core/urls.py @@ -6,4 +6,5 @@ path("", include("core.study.urls")), path("", include("core.assignments.urls")), path("", include("core.note.urls")), + path("", include("core.problem_solving_status.urls")), ] diff --git a/core/utils/solvedac.py b/core/utils/solvedac.py index 7809317..c3d1992 100644 --- a/core/utils/solvedac.py +++ b/core/utils/solvedac.py @@ -114,6 +114,56 @@ def get_problem_by_id(self, problem_id: int) -> dict: response.raise_for_status() return response.json() + def check_user_solved_problems( + self, username: str, problem_ids: List[int] + ) -> dict[int, bool]: + """ + 사용자가 여러 문제를 풀었는지 한 번에 확인 + + Args: + username: 백준 사용자명 + problem_ids: 백준 문제 번호 리스트 (단일 문제도 리스트로 전달 가능) + + Returns: + dict: {problem_id: is_solved} 형태의 딕셔너리 + - True: 문제를 풀었음 + - False: 문제를 풀지 않았음 + """ + if not problem_ids: + return {} + + try: + # 여러 문제를 한 번에 쿼리: (id:3018|id:1000|id:3019) -@username + problem_query = "|".join([f"id:{pid}" for pid in problem_ids]) + query = f"({problem_query}) -@{username}" + + response = requests.get( + f"{SOLVED_AC_URL}/search/problem", + params={ + "query": query, + "page": 1, + }, + timeout=self.timeout, + ) + response.raise_for_status() + data = response.json() + + # 결과에 나온 문제 ID들 (풀지 않은 문제들) + unsolved_problem_ids = { + item.get("problemId") for item in data.get("items", []) + } + + # 각 문제에 대해 풀이 여부 확인 + result = {} + for problem_id in problem_ids: + # 결과에 나오지 않았으면 푼 것 + result[problem_id] = problem_id not in unsolved_problem_ids + + return result + except requests.RequestException: + # API 호출 실패 시 모든 문제를 풀지 않은 것으로 처리 + return {pid: False for pid in problem_ids} + class ProblemNotFoundError(Exception): """문제를 찾을 수 없는 경우 발생하는 예외"""