diff --git a/planner/tests.py b/planner/tests.py index 9287ab3..d5cef7f 100644 --- a/planner/tests.py +++ b/planner/tests.py @@ -2217,6 +2217,332 @@ def test_today_eod_includes_pending_as_not_done(self): self.assertEqual(response.context['eod']['pending_count'], 1) self.assertEqual(response.context['eod']['not_done_count'], 1) +class DailyPlanFinalizeViewTests(TestCase): + """ + #79 daily_plan_finalize API 테스트. + + 로그인한 사용자의 ACTIVE 시험기간에 속한 오늘 DailyPlan을 조회하고, + finalize_daily_plan()을 호출해 하루 계획을 마감하는 JSON API를 검증한다. + """ + + def setUp(self): + from django.contrib.auth import get_user_model + + User = get_user_model() + + self.user = User.objects.create_user( + username="finalize_view_tester", + email="finalize_view@example.com", + password="pass1234", + ) + self.today = django_timezone.localdate() + + self.client.login( + username="finalize_view@example.com", + password="pass1234", + ) + self.url = reverse("planner:daily_plan_finalize") + + def _make_active_exam_period(self, user=None, title="마감 테스트 시험기간"): + from exams.models import ExamPeriod + + return ExamPeriod.objects.create( + user=user or self.user, + title=title, + start_date=self.today - timedelta(days=1), + end_date=self.today + timedelta(days=10), + status="active", + ) + + def _make_exam(self, exam_period, exam_date=None): + from exams.models import Exam + + return Exam.objects.create( + exam_period=exam_period, + subject_name="테스트 과목", + exam_date=exam_date or self.today + timedelta(days=5), + ) + + def _make_task(self, exam, importance="high", depth="core"): + from exams.models import StudyTask + + return StudyTask.objects.create( + exam=exam, + title="마감 대상 작업", + importance=importance, + depth=depth, + task_type="concept", + difficulty="normal", + order=1, + estimated_min_minutes=20, + estimated_max_minutes=40, + is_confirmed=True, + ) + + def _make_today_plan_with_item( + self, + exam_period, + importance="high", + depth="core", + ): + exam = self._make_exam(exam_period) + task = self._make_task( + exam, + importance=importance, + depth=depth, + ) + + daily_plan = DailyPlan.objects.create( + exam_period=exam_period, + date=self.today, + available_minutes=40, + planned_minutes=40, + ) + item = DailyPlanItem.objects.create( + daily_plan=daily_plan, + study_task=task, + planned_minutes=40, + order=1, + ) + + return daily_plan, item + + def test_no_active_exam_period_returns_404(self): + response = self.client.post(self.url) + + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.json()["message"], + "오늘 마감할 계획이 없습니다.", + ) + + def test_no_today_plan_returns_404(self): + self._make_active_exam_period() + + response = self.client.post(self.url) + + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.json()["message"], + "오늘 마감할 계획이 없습니다.", + ) + + def test_other_users_today_plan_returns_404(self): + from django.contrib.auth import get_user_model + + User = get_user_model() + + other_user = User.objects.create_user( + username="other_finalize_tester", + email="other_finalize@example.com", + password="pass1234", + ) + other_period = self._make_active_exam_period(user=other_user) + self._make_today_plan_with_item(other_period) + + response = self.client.post(self.url) + + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.json()["message"], + "오늘 마감할 계획이 없습니다.", + ) + + def test_all_done_finalize_needs_recovery_false(self): + exam_period = self._make_active_exam_period() + daily_plan, item = self._make_today_plan_with_item(exam_period) + + record_progress( + daily_plan_item=item, + status="done", + actual_minutes=35, + ) + + response = self.client.post(self.url) + body = response.json() + + self.assertEqual(response.status_code, 200) + self.assertFalse(body["needs_recovery"]) + self.assertFalse(body["recovery_available"]) + self.assertIsNone(body["recovery_group_id"]) + self.assertEqual(body["auto_marked_not_done_count"], 0) + + daily_plan.refresh_from_db() + self.assertIsNotNone(daily_plan.finalized_at) + + def test_partial_or_not_done_needs_recovery_true(self): + exam_period = self._make_active_exam_period() + _daily_plan, item = self._make_today_plan_with_item( + exam_period, + importance="low", + depth="optional", + ) + + record_progress( + daily_plan_item=item, + status="not_done", + actual_minutes=0, + ) + AvailableTime.objects.create( + exam_period=exam_period, + date=self.today + timedelta(days=1), + available_minutes=60, + ) + + response = self.client.post(self.url) + body = response.json() + + self.assertEqual(response.status_code, 200) + self.assertTrue(body["needs_recovery"]) + + def test_unrecorded_task_auto_marked_not_done(self): + exam_period = self._make_active_exam_period() + _daily_plan, item = self._make_today_plan_with_item( + exam_period, + importance="low", + depth="optional", + ) + AvailableTime.objects.create( + exam_period=exam_period, + date=self.today + timedelta(days=1), + available_minutes=60, + ) + + response = self.client.post(self.url) + body = response.json() + + self.assertEqual(response.status_code, 200) + self.assertEqual(body["auto_marked_not_done_count"], 1) + self.assertTrue(body["needs_recovery"]) + + item.refresh_from_db() + self.assertEqual( + item.progress_log.progress_status, + "not_done", + ) + + def test_recovery_available_true_returns_group_id(self): + exam_period = self._make_active_exam_period() + _daily_plan, item = self._make_today_plan_with_item( + exam_period, + importance="low", + depth="optional", + ) + + record_progress( + daily_plan_item=item, + status="not_done", + actual_minutes=0, + ) + AvailableTime.objects.create( + exam_period=exam_period, + date=self.today + timedelta(days=1), + available_minutes=60, + ) + + response = self.client.post(self.url) + body = response.json() + + self.assertEqual(response.status_code, 200) + self.assertTrue(body["recovery_available"]) + self.assertIsNotNone(body["recovery_group_id"]) + + self.assertTrue( + RecoveryPlan.objects.filter( + recovery_group_id=body["recovery_group_id"], + ).exists() + ) + + def test_recovery_unavailable_when_both_types_fail(self): + exam_period = self._make_active_exam_period() + + # high/core 작업은 핵심 집중형 제외 후보가 아니다. + # 미래 가용시간도 없으므로 분량 유지형도 생성되지 않는다. + _daily_plan, item = self._make_today_plan_with_item( + exam_period, + importance="high", + depth="core", + ) + + record_progress( + daily_plan_item=item, + status="not_done", + actual_minutes=0, + ) + + response = self.client.post(self.url) + body = response.json() + + self.assertEqual(response.status_code, 200) + self.assertTrue(body["needs_recovery"]) + self.assertFalse(body["recovery_available"]) + self.assertIsNone(body["recovery_group_id"]) + + def test_already_finalized_returns_409(self): + exam_period = self._make_active_exam_period() + _daily_plan, item = self._make_today_plan_with_item(exam_period) + + record_progress( + daily_plan_item=item, + status="done", + actual_minutes=35, + ) + + first_response = self.client.post(self.url) + self.assertEqual(first_response.status_code, 200) + + second_response = self.client.post(self.url) + + self.assertEqual(second_response.status_code, 409) + self.assertEqual( + second_response.json()["message"], + "이미 마감된 계획입니다.", + ) + + def test_get_method_not_allowed(self): + exam_period = self._make_active_exam_period() + self._make_today_plan_with_item(exam_period) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 405) + + def test_finds_today_plan_in_older_active_exam_period(self): + """ + 더 최근에 생성된 ACTIVE 시험기간에는 오늘 계획이 없고, + 이전 ACTIVE 시험기간에만 오늘 계획이 있어도 정상 마감해야 한다. + """ + from exams.models import ExamPeriod + + older_period = self._make_active_exam_period( + title="이전 시험기간", + ) + daily_plan, item = self._make_today_plan_with_item(older_period) + + newer_period = self._make_active_exam_period( + title="최근 시험기간", + ) + + # 생성 시각의 우선순위를 명확하게 만든다. + ExamPeriod.objects.filter(pk=older_period.pk).update( + created_at=django_timezone.now() - timedelta(minutes=1), + ) + ExamPeriod.objects.filter(pk=newer_period.pk).update( + created_at=django_timezone.now(), + ) + + record_progress( + daily_plan_item=item, + status="done", + actual_minutes=35, + ) + + response = self.client.post(self.url) + + self.assertEqual(response.status_code, 200) + + daily_plan.refresh_from_db() + self.assertIsNotNone(daily_plan.finalized_at) class ProgressRecordViewTests(TestCase): def setUp(self): @@ -2345,4 +2671,4 @@ def test_rejects_edit_after_finalized(self): def test_speed_factor_updated_after_record(self): self._post(self.item.id, {"status": "done", "actual_minutes": 60}) self.exam.refresh_from_db() - self.assertNotEqual(self.exam.speed_factor, 1.0) \ No newline at end of file + self.assertNotEqual(self.exam.speed_factor, 1.0) diff --git a/planner/urls.py b/planner/urls.py index d98cf92..8c40638 100644 --- a/planner/urls.py +++ b/planner/urls.py @@ -8,6 +8,7 @@ path('plan/generate//', views.plan_generate, name='plan_generate'), path('plan/complete//', views.plan_complete, name='plan_complete'), path('today/', views.today, name='today'), + path('daily-plans/finalize/', views.daily_plan_finalize, name='daily_plan_finalize'), path('', views.dashboard, name='dashboard'), path('progress//', views.progress_record, name='progress_record'), ] \ No newline at end of file diff --git a/planner/views.py b/planner/views.py index 02d7e8c..b3939d4 100644 --- a/planner/views.py +++ b/planner/views.py @@ -4,6 +4,7 @@ from planner.services.progress_recorder import record_progress, FinalizedDailyPlanEditError from django.contrib.auth.decorators import login_required from django.contrib import messages +from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.http import require_http_methods from core.choices import ExamPeriodStatus, RecoveryPlanStatus, ProgressStatus @@ -18,7 +19,10 @@ MismatchedExamPeriodError, DuplicateTaskAllocationError, ) - +from planner.services.progress_recorder import ( + finalize_daily_plan, + DailyPlanAlreadyFinalizedError, +) def _get_owned_exam_period(user, period_id): return get_object_or_404(ExamPeriod, id=period_id, user=user) @@ -362,13 +366,12 @@ def today(request): }) return render(request, 'planner/today.html', context) - @login_required @require_http_methods(["POST"]) def progress_record(request, item_id): item = ( DailyPlanItem.objects - .select_related('daily_plan', 'study_task__exam') + .select_related("daily_plan", "study_task__exam") .filter( id=item_id, daily_plan__exam_period__user=request.user, @@ -380,28 +383,32 @@ def progress_record(request, item_id): if item is None: return JsonResponse( - {"message": "오늘 학습 작업을 찾을 수 없습니다."}, status=404, + {"message": "오늘 학습 작업을 찾을 수 없습니다."}, + status=404, ) try: payload = json.loads(request.body) except (json.JSONDecodeError, UnicodeDecodeError): return JsonResponse( - {"message": "올바른 JSON 요청이 아닙니다."}, status=400, + {"message": "올바른 JSON 요청이 아닙니다."}, + status=400, ) if not isinstance(payload, dict): return JsonResponse( - {"message": "요청 본문은 JSON 객체여야 합니다."}, status=400, + {"message": "요청 본문은 JSON 객체여야 합니다."}, + status=400, ) - status = payload.get('status') - actual_minutes = payload.get('actual_minutes') - completion_percent = payload.get('completion_percent') + status = payload.get("status") + actual_minutes = payload.get("actual_minutes") + completion_percent = payload.get("completion_percent") if status not in ProgressStatus.values: return JsonResponse( - {"message": "올바르지 않은 학습 상태입니다."}, status=400, + {"message": "올바르지 않은 학습 상태입니다."}, + status=400, ) if status in (ProgressStatus.DONE, ProgressStatus.PARTIAL): @@ -411,7 +418,8 @@ def progress_record(request, item_id): or not 1 <= actual_minutes <= 1439 ): return JsonResponse( - {"message": "실제 공부시간은 1~1439분 사이의 정수여야 합니다."}, status=400, + {"message": "실제 공부시간은 1~1439분 사이의 정수여야 합니다."}, + status=400, ) else: actual_minutes = None @@ -423,7 +431,8 @@ def progress_record(request, item_id): or not 1 <= completion_percent <= 99 ): return JsonResponse( - {"message": "일부완료 진행률은 1~99 사이의 정수여야 합니다."}, status=400, + {"message": "일부완료 진행률은 1~99 사이의 정수여야 합니다."}, + status=400, ) else: completion_percent = None @@ -440,11 +449,70 @@ def progress_record(request, item_id): except (ValueError, TypeError) as exc: return JsonResponse({"message": str(exc)}, status=400) - progress_log = result['progress_log'] + progress_log = result["progress_log"] + return JsonResponse({ "item_id": item.id, "status": progress_log.progress_status, "actual_minutes": progress_log.actual_minutes, "completion_percent": progress_log.completion_percent, - "daily_plan_status": result['daily_plan_status'], - }) \ No newline at end of file + "daily_plan_status": result["daily_plan_status"], + }) + + +def _get_today_daily_plan(user): + return ( + DailyPlan.objects + .filter( + exam_period__user=user, + exam_period__status=ExamPeriodStatus.ACTIVE, + date=timezone.localdate(), + ) + .order_by("-exam_period__created_at") + .first() + ) + + +@login_required +@require_http_methods(["POST"]) +def daily_plan_finalize(request): + daily_plan = _get_today_daily_plan(request.user) + + if daily_plan is None: + return JsonResponse( + {"message": "오늘 마감할 계획이 없습니다."}, + status=404, + ) + + try: + result = finalize_daily_plan( + daily_plan, + mark_unrecorded_as_not_done=True, + ) + except DailyPlanAlreadyFinalizedError: + return JsonResponse( + {"message": "이미 마감된 계획입니다."}, + status=409, + ) + + recovery_plans = result["recovery_plans"] or {} + recovery_plan = ( + recovery_plans.get("maintain_volume") + or recovery_plans.get("core_focus") + ) + + recovery_group_id = ( + str(recovery_plan.recovery_group_id) + if recovery_plan + else None + ) + + return JsonResponse({ + "needs_recovery": result["needs_recovery"], + "recovery_available": recovery_plan is not None, + "recovery_group_id": recovery_group_id, + "auto_marked_not_done_count": ( + result["auto_marked_not_done_count"] + ), + }) + \ No newline at end of file