From 58d9313549b04728d5d0c1b9681ca8c57d834ab4 Mon Sep 17 00:00:00 2001 From: dlwlehd Date: Mon, 12 Jan 2026 12:42:06 +0900 Subject: [PATCH 01/11] =?UTF-8?q?Feat:=20cursor=20=ED=8F=B4=EB=8D=94=20.gi?= =?UTF-8?q?tignore=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 67a1524f088a6b9e164c19ac7c6355e3ed818a0c Mon Sep 17 00:00:00 2001 From: dlwlehd Date: Mon, 12 Jan 2026 12:53:32 +0900 Subject: [PATCH 02/11] =?UTF-8?q?Feat:=20Problem=20Solving=20Status=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...y_template_content_problemsolvingstatus.py | 38 ++++++++++++++ core/models.py | 49 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 core/migrations/0009_alter_study_template_content_problemsolvingstatus.py 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()}" From 11ead2bef1885fe4fb0d99c1866e1f539e90d8d0 Mon Sep 17 00:00:00 2001 From: dlwlehd Date: Mon, 12 Jan 2026 12:58:43 +0900 Subject: [PATCH 03/11] =?UTF-8?q?Feat:=20ProblemSolvingStatusService=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/assignments/services.py | 61 +++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/core/assignments/services.py b/core/assignments/services.py index c9bfc44..867b0b5 100644 --- a/core/assignments/services.py +++ b/core/assignments/services.py @@ -4,10 +4,17 @@ from django.db import transaction from django.utils import timezone -from core.models import DailyAssignment, Problem, Study, StudyMember +from core.models import ( + DailyAssignment, + Problem, + ProblemSolvingStatus, + Study, + StudyMember, +) from core.utils.solvedac import SolvedAC DAILY_ASSIGNMENT_REFRESH_COOLDOWN = 1800 # 30분 +PROBLEM_STATUS_UPDATE_COOLDOWN = 300 # 5분 class DailyAssignmentService: @@ -226,3 +233,55 @@ def __init__(self, boj_number: int): super().__init__( f"{boj_number}번 문제는 이미 오늘 추천 목록에 있는 문제입니다." ) + + +class ProblemSolvingStatusService: + """문제 풀이 상태 관리 서비스""" + + 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, + user=user, + ) + .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 From 901ff71888e35a4cc407e507854ebf224f602945 Mon Sep 17 00:00:00 2001 From: dlwlehd Date: Mon, 12 Jan 2026 13:12:20 +0900 Subject: [PATCH 04/11] =?UTF-8?q?Feat:=20=EB=AC=B8=EC=A0=9C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20method=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/assignments/services.py | 61 +--- core/problem_solving_status/__init__.py | 0 core/problem_solving_status/services.py | 434 ++++++++++++++++++++++++ core/utils/solvedac.py | 50 +++ 4 files changed, 485 insertions(+), 60 deletions(-) create mode 100644 core/problem_solving_status/__init__.py create mode 100644 core/problem_solving_status/services.py diff --git a/core/assignments/services.py b/core/assignments/services.py index 867b0b5..c9bfc44 100644 --- a/core/assignments/services.py +++ b/core/assignments/services.py @@ -4,17 +4,10 @@ from django.db import transaction from django.utils import timezone -from core.models import ( - DailyAssignment, - Problem, - ProblemSolvingStatus, - Study, - StudyMember, -) +from core.models import DailyAssignment, Problem, Study, StudyMember from core.utils.solvedac import SolvedAC DAILY_ASSIGNMENT_REFRESH_COOLDOWN = 1800 # 30분 -PROBLEM_STATUS_UPDATE_COOLDOWN = 300 # 5분 class DailyAssignmentService: @@ -233,55 +226,3 @@ def __init__(self, boj_number: int): super().__init__( f"{boj_number}번 문제는 이미 오늘 추천 목록에 있는 문제입니다." ) - - -class ProblemSolvingStatusService: - """문제 풀이 상태 관리 서비스""" - - 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, - user=user, - ) - .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 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/services.py b/core/problem_solving_status/services.py new file mode 100644 index 0000000..a733e1a --- /dev/null +++ b/core/problem_solving_status/services.py @@ -0,0 +1,434 @@ +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, + user=user, + ) + .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) + + # 풀이 여부에 따라 상태 설정 + 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__boj_number") + + 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, + } + + # 사용자의 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} + + 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 + + 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 + ), + } + + def _get_group_solving_statuses(self, study, assignments, target_date) -> dict: + """모든 멤버의 문제 풀이 상태 조회 (그룹 조회)""" + 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, + }, + }, + } + + # 스터디 멤버 조회 + 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, + } + + 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 + + members_list.append( + { + "member_id": member.user_id, + "member_email": member.user.email, + "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, + }, + } + 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): """문제를 찾을 수 없는 경우 발생하는 예외""" From 14d39187eb9a87fd5c49ad641b2365a4e5cc487b Mon Sep 17 00:00:00 2001 From: dlwlehd Date: Mon, 12 Jan 2026 13:24:38 +0900 Subject: [PATCH 05/11] =?UTF-8?q?Feat:=20=ED=86=B5=EA=B3=84=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20method=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/problem_solving_status/services.py | 640 ++++++++++++++++++++++++ 1 file changed, 640 insertions(+) diff --git a/core/problem_solving_status/services.py b/core/problem_solving_status/services.py index a733e1a..a0e8cb7 100644 --- a/core/problem_solving_status/services.py +++ b/core/problem_solving_status/services.py @@ -432,3 +432,643 @@ def _get_group_solving_statuses(self, study, assignments, target_date) -> dict: }, } + 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.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.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.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.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 + From a4894cf7a6103229cb2f4ec30768cfb878641b8b Mon Sep 17 00:00:00 2001 From: dlwlehd Date: Mon, 12 Jan 2026 13:30:14 +0900 Subject: [PATCH 06/11] =?UTF-8?q?Feat:=20Problem=20Solving=20Status=20Seri?= =?UTF-8?q?alizer=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/problem_solving_status/serializers.py | 213 +++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 core/problem_solving_status/serializers.py diff --git a/core/problem_solving_status/serializers.py b/core/problem_solving_status/serializers.py new file mode 100644 index 0000000..d45ed7b --- /dev/null +++ b/core/problem_solving_status/serializers.py @@ -0,0 +1,213 @@ +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) + 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) + + +class MemberWithAssignmentsSerializer(serializers.Serializer): + """멤버와 과제 목록 Serializer""" + + member_id = serializers.IntegerField() + member_email = serializers.EmailField() + 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() + + +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) + 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) + 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) + 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() + From 1237517c1ba773931e532cf0016437d0d25272fd Mon Sep 17 00:00:00 2001 From: dlwlehd Date: Mon, 12 Jan 2026 13:31:35 +0900 Subject: [PATCH 07/11] =?UTF-8?q?Feat:=20Problem=20Solving=20Status=20View?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/problem_solving_status/views.py | 249 +++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 core/problem_solving_status/views.py 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) + From 1739668c60e31096ea70192d39bd021e920d8716 Mon Sep 17 00:00:00 2001 From: dlwlehd Date: Mon, 12 Jan 2026 13:37:35 +0900 Subject: [PATCH 08/11] =?UTF-8?q?Feat:=20Problem=20Solving=20Status=20URL?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/problem_solving_status/urls.py | 31 +++++++++++++++++++++++++++++ core/urls.py | 1 + 2 files changed, 32 insertions(+) create mode 100644 core/problem_solving_status/urls.py 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/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")), ] From b47c037c65fc80eef340f5bde0a0f091a92bdf77 Mon Sep 17 00:00:00 2001 From: dlwlehd Date: Mon, 12 Jan 2026 13:38:00 +0900 Subject: [PATCH 09/11] =?UTF-8?q?Fix:=20=EC=BF=A8=EB=8B=A4=EC=9A=B4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=A9=A4=EB=B2=84=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EB=90=98=EB=8A=94=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/problem_solving_status/services.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/core/problem_solving_status/services.py b/core/problem_solving_status/services.py index a0e8cb7..55773c7 100644 --- a/core/problem_solving_status/services.py +++ b/core/problem_solving_status/services.py @@ -34,10 +34,10 @@ def __init__(self): def can_update_status( self, user, study: Study, target_date: Optional[date] = None ) -> tuple[bool, Optional[datetime]]: - """상태 업데이트 가능 여부 확인 (5분 쿨다운) + """상태 업데이트 가능 여부 확인 (5분 쿨다운, 스터디 멤버 전체 공유) Args: - user: 사용자 + user: 사용자 (쿨다운 체크에는 사용되지 않음, 스터디 전체 공유) study: 스터디 target_date: 대상 날짜 (기본값: 오늘) @@ -56,12 +56,12 @@ def can_update_status( if not assignments.exists(): return True, None - # 해당 날짜의 문제들에 대한 사용자의 가장 최근 업데이트 시간 확인 + # 해당 날짜의 문제들에 대한 스터디의 모든 멤버 중 가장 최근 업데이트 시간 확인 + # 쿨다운은 스터디 멤버 전체가 공유함 latest_status = ( ProblemSolvingStatus.objects.filter( assignment__study=study, assignment__assigned_date=target_date, - user=user, ) .order_by("-last_updated_at") .first() @@ -141,6 +141,9 @@ def update_solving_status( is_solved = solved_problems.get(problem_id, False) # 풀이 여부에 따라 상태 설정 + # TODO: in_progress 상태 자동 판단 로직 추가 필요 + # 현재는 solved.ac API로 풀었는지만 확인 가능하므로, + # "제출했지만 틀린 상태"를 판단하려면 백준 제출 이력 API 추가 확인 필요 if is_solved: status = ProblemStatus.COMPLETED else: From 6094e95c78a911059b19716fcef4b541a41edb35 Mon Sep 17 00:00:00 2001 From: Dongwoo Kang Date: Thu, 15 Jan 2026 21:21:44 +0900 Subject: [PATCH 10/11] =?UTF-8?q?Feat:=20=ED=92=80=EC=9D=B4=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A1=B0=ED=9A=8C=20=EB=A6=AC=EC=8A=A4=ED=8F=B0?= =?UTF-8?q?=EC=8A=A4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 오늘의 추천 문제 조회 리스폰스를 problemId 순으로 정렬하도록 수정 --- core/assignments/services.py | 4 +- core/problem_solving_status/serializers.py | 10 +- core/problem_solving_status/services.py | 112 +++++++++++++-------- 3 files changed, 80 insertions(+), 46 deletions(-) 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/problem_solving_status/serializers.py b/core/problem_solving_status/serializers.py index d45ed7b..f0af73d 100644 --- a/core/problem_solving_status/serializers.py +++ b/core/problem_solving_status/serializers.py @@ -62,6 +62,7 @@ class MemberStatusSerializer(serializers.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) @@ -93,6 +94,7 @@ class AssignmentStatusResponseSerializer(serializers.Serializer): 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): @@ -101,6 +103,7 @@ class MemberWithAssignmentsSerializer(serializers.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() @@ -122,6 +125,9 @@ class GroupAssignmentStatusResponseSerializer(serializers.Serializer): 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): @@ -151,6 +157,7 @@ class MyStatisticsResponseSerializer(serializers.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() @@ -166,6 +173,7 @@ class MemberStatisticsItemSerializer(serializers.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() @@ -195,6 +203,7 @@ class MemberStatisticsResponseSerializer(serializers.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() @@ -210,4 +219,3 @@ class UpdateCooldownErrorSerializer(serializers.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 index 55773c7..ca7e351 100644 --- a/core/problem_solving_status/services.py +++ b/core/problem_solving_status/services.py @@ -79,7 +79,6 @@ def can_update_status( return True, None return False, next_available_at - @transaction.atomic def update_solving_status( self, @@ -108,9 +107,7 @@ def update_solving_status( study = Study.objects.get(id=study_id) # 쿨다운 체크 - can_update, next_available_at = self.can_update_status( - user, study, target_date - ) + can_update, next_available_at = self.can_update_status(user, study, target_date) if not can_update: raise UpdateCooldownError(next_available_at) @@ -183,9 +180,11 @@ def get_solving_statuses( study = Study.objects.get(id=study_id) # 해당 날짜의 모든 DailyAssignment 조회 - assignments = DailyAssignment.objects.filter( - study=study, assigned_date=target_date - ).select_related("problem").order_by("problem__boj_number") + 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) @@ -194,9 +193,7 @@ def get_solving_statuses( else: raise ValueError(f"잘못된 view 값입니다: {view}") - def _get_my_solving_statuses( - self, user, study, assignments, target_date - ) -> dict: + def _get_my_solving_statuses(self, user, study, assignments, target_date) -> dict: """현재 사용자의 문제 풀이 상태 조회""" if not assignments.exists(): return { @@ -215,6 +212,7 @@ def _get_my_solving_statuses( }, "can_update": True, "next_available_at": None, + "last_updated_at": None, } # 사용자의 ProblemSolvingStatus 조회 @@ -244,14 +242,13 @@ def _get_my_solving_statuses( "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 - ) + problem_status = status.status if status else ProblemStatus.NOT_ATTEMPTED note_status = "completed" if note else "not_completed" assignment_list.append( @@ -280,6 +277,14 @@ def _get_my_solving_statuses( 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", @@ -291,10 +296,16 @@ def _get_my_solving_statuses( "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 None + ), } 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(), @@ -314,6 +325,11 @@ def _get_group_solving_statuses(self, study, assignments, target_date) -> dict: "total": 0, }, }, + "can_update": can_update, + "next_available_at": ( + next_available_at.isoformat() if next_available_at else None + ), + "last_updated_at": None, } # 스터디 멤버 조회 @@ -335,9 +351,7 @@ def _get_group_solving_statuses(self, study, assignments, target_date) -> dict: ).select_related("problem", "user") # note를 (problem_id, user_id)로 매핑 - note_dict = { - (note.problem_id, note.user_id): note for note in solution_notes - } + note_dict = {(note.problem_id, note.user_id): note for note in solution_notes} # 멤버별로 상태 정보 구성 members_list = [] @@ -352,6 +366,7 @@ def _get_group_solving_statuses(self, study, assignments, target_date) -> dict: "completed_count": 0, "total": 0, } + latest_updated_at = None for member in members: member_assignments = [] @@ -412,11 +427,20 @@ def _get_group_solving_statuses(self, study, assignments, target_date) -> dict: 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.boj_username, + "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, @@ -433,6 +457,13 @@ def _get_group_solving_statuses(self, study, assignments, target_date) -> dict: "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 None + ), } def get_problem_members_status(self, study_id: int, problem_id: int) -> dict: @@ -516,16 +547,15 @@ def get_problem_members_status(self, study_id: int, problem_id: int) -> dict: status = status_dict.get(member.user_id) note = note_dict.get(member.user_id) - problem_status = ( - status.status if status else ProblemStatus.NOT_ATTEMPTED - ) + 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.boj_username, + "username": member.user.username, + "boj_username": member.user.boj_username, "problem_status": problem_status, "note_status": note_status, "last_updated_at": ( @@ -599,9 +629,7 @@ def _get_my_statistics( # 날짜 범위 필터링 assignments_query = DailyAssignment.objects.filter(study=study) if start_date: - assignments_query = assignments_query.filter( - assigned_date__gte=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) @@ -635,9 +663,7 @@ def _get_my_statistics( status = status_dict.get(assignment.id) note = note_dict.get(assignment.problem_id) - problem_status = ( - status.status if status else ProblemStatus.NOT_ATTEMPTED - ) + problem_status = status.status if status else ProblemStatus.NOT_ATTEMPTED note_status = "completed" if note else "not_completed" if problem_status == ProblemStatus.NOT_ATTEMPTED: @@ -671,7 +697,8 @@ def _get_my_statistics( "view": "me", "member_id": user.id, "member_email": user.email, - "username": user.boj_username, + "username": user.username, + "boj_username": user.boj_username, "total_assigned": total_assigned, "problem_status_summary": problem_status_counts, "note_status_summary": note_status_counts, @@ -691,9 +718,7 @@ def _get_group_statistics( # 날짜 범위 필터링 assignments_query = DailyAssignment.objects.filter(study=study) if start_date: - assignments_query = assignments_query.filter( - assigned_date__gte=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) @@ -793,7 +818,8 @@ def _get_group_statistics( { "member_id": member.user_id, "member_email": member.user.email, - "username": member.user.boj_username, + "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, @@ -804,9 +830,7 @@ def _get_group_statistics( # 평균 완료율 계산 average_problem_rate = ( - sum(completion_rates) / len(completion_rates) - if completion_rates - else 0.0 + sum(completion_rates) / len(completion_rates) if completion_rates else 0.0 ) average_note_rate = ( sum( @@ -843,7 +867,11 @@ def _get_group_statistics( } def _get_member_statistics( - self, study, member_id: int, start_date: Optional[date], end_date: Optional[date] + self, + study, + member_id: int, + start_date: Optional[date], + end_date: Optional[date], ) -> dict: """특정 멤버의 통계 조회""" from core.models import User @@ -853,9 +881,7 @@ def _get_member_statistics( # 날짜 범위 필터링 assignments_query = DailyAssignment.objects.filter(study=study) if start_date: - assignments_query = assignments_query.filter( - assigned_date__gte=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) @@ -889,9 +915,7 @@ def _get_member_statistics( status = status_dict.get(assignment.id) note = note_dict.get(assignment.problem_id) - problem_status = ( - status.status if status else ProblemStatus.NOT_ATTEMPTED - ) + problem_status = status.status if status else ProblemStatus.NOT_ATTEMPTED note_status = "completed" if note else "not_completed" if problem_status == ProblemStatus.NOT_ATTEMPTED: @@ -925,7 +949,8 @@ def _get_member_statistics( "view": "member", "member_id": member.id, "member_email": member.email, - "username": member.boj_username, + "username": member.username, + "boj_username": member.boj_username, "total_assigned": total_assigned, "problem_status_summary": problem_status_counts, "note_status_summary": note_status_counts, @@ -1074,4 +1099,3 @@ def _calculate_group_daily_statistics( ) return daily_statistics - From 424f945dd35376905e3fdfa57e7c58cc2d800399 Mon Sep 17 00:00:00 2001 From: Dongwoo Kang Date: Fri, 16 Jan 2026 17:59:47 +0900 Subject: [PATCH 11/11] =?UTF-8?q?Feat:=20=EB=82=B4=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94=20=EC=A1=B0=ED=9A=8C=20=EB=A6=AC=EC=8A=A4=ED=8F=B0?= =?UTF-8?q?=EC=8A=A4=EB=A5=BC=20id=20=EA=B8=B0=EC=A4=80=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 풀이 상태 조회 리스폰스에서 마지막 업데이트 시각이 없다면 오늘 정각을 주는 것으로 변경 --- core/problem_solving_status/services.py | 20 ++++++++++++++++---- core/study/views.py | 1 + 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/core/problem_solving_status/services.py b/core/problem_solving_status/services.py index ca7e351..f4423f6 100644 --- a/core/problem_solving_status/services.py +++ b/core/problem_solving_status/services.py @@ -212,7 +212,9 @@ def _get_my_solving_statuses(self, user, study, assignments, target_date) -> dic }, "can_update": True, "next_available_at": None, - "last_updated_at": None, + "last_updated_at": timezone.make_aware( + datetime.combine(target_date, datetime.min.time()) + ).isoformat(), } # 사용자의 ProblemSolvingStatus 조회 @@ -297,7 +299,11 @@ def _get_my_solving_statuses(self, user, study, assignments, target_date) -> dic next_available_at.isoformat() if next_available_at else None ), "last_updated_at": ( - latest_updated_at.isoformat() if latest_updated_at else None + latest_updated_at.isoformat() + if latest_updated_at + else timezone.make_aware( + datetime.combine(target_date, datetime.min.time()) + ).isoformat() ), } @@ -329,7 +335,9 @@ def _get_group_solving_statuses(self, study, assignments, target_date) -> dict: "next_available_at": ( next_available_at.isoformat() if next_available_at else None ), - "last_updated_at": None, + "last_updated_at": timezone.make_aware( + datetime.combine(target_date, datetime.min.time()) + ).isoformat(), } # 스터디 멤버 조회 @@ -462,7 +470,11 @@ def _get_group_solving_statuses(self, study, assignments, target_date) -> dict: next_available_at.isoformat() if next_available_at else None ), "last_updated_at": ( - latest_updated_at.isoformat() if latest_updated_at else None + latest_updated_at.isoformat() + if latest_updated_at + else timezone.make_aware( + datetime.combine(target_date, datetime.min.time()) + ).isoformat() ), } 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)