From d5853864eb3819fd3764f84e159a3ada34ed02d1 Mon Sep 17 00:00:00 2001 From: wngjs8114 Date: Sat, 8 Aug 2026 00:59:59 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20#85=20=ED=95=98=EB=A3=A8=20=EB=A7=88?= =?UTF-8?q?=EA=B0=90=20API=20=EC=97=B0=EA=B2=B0=20(daily=5Fplan=5Ffinalize?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- planner/tests.py | 96 +++++++++++++++++++++++++++++++++++++++++++++++- planner/urls.py | 1 + planner/views.py | 53 ++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/planner/tests.py b/planner/tests.py index 9287ab3..5454405 100644 --- a/planner/tests.py +++ b/planner/tests.py @@ -2345,4 +2345,98 @@ 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) + +class DailyPlanFinalizeViewTests(TestCase): + def setUp(self): + from django.contrib.auth import get_user_model + from exams.models import ExamPeriod, Exam, StudyTask + + User = get_user_model() + self.user = User.objects.create_user( + username="finalize_view_tester", email="finalizeview@example.com", password="pass1234" + ) + self.today = django_timezone.localdate() + self.exam_period = ExamPeriod.objects.create( + user=self.user, title="테스트 시험기간", + start_date=self.today, end_date=self.today + timedelta(days=10), + status="active", + ) + self.exam = Exam.objects.create( + exam_period=self.exam_period, subject_name="테스트 과목", + exam_date=self.today + timedelta(days=5), + ) + self.task = StudyTask.objects.create( + exam=self.exam, title="작업", importance="high", depth="core", + task_type="concept", difficulty="normal", order=1, + estimated_min_minutes=20, estimated_max_minutes=40, is_confirmed=True, + ) + self.daily_plan = DailyPlan.objects.create( + exam_period=self.exam_period, date=self.today, + available_minutes=60, planned_minutes=40, + ) + self.item = DailyPlanItem.objects.create( + daily_plan=self.daily_plan, study_task=self.task, planned_minutes=40, order=1, + ) + self.client.login(username="finalizeview@example.com", password="pass1234") + + def _finalize(self): + return self.client.post(reverse('planner:daily_plan_finalize')) + + def test_finalize_without_unfinished_tasks_no_recovery(self): + record_progress(daily_plan_item=self.item, status="done", actual_minutes=40) + + response = self._finalize() + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertFalse(data['needs_recovery']) + self.assertIsNone(data['recovery_group_id']) + + self.daily_plan.refresh_from_db() + self.assertIsNotNone(self.daily_plan.finalized_at) + + def test_finalize_with_unfinished_task_creates_recovery(self): + AvailableTime.objects.create( + exam_period=self.exam_period, date=self.today + timedelta(days=1), available_minutes=60, + ) + # 진행 기록 없이 마감 -> mark_unrecorded_as_not_done=True로 자동 못함 처리됨 + + response = self._finalize() + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['needs_recovery']) + self.assertIsNotNone(data['recovery_group_id']) + + def test_finalize_no_plan_today_returns_404(self): + self.daily_plan.delete() + response = self._finalize() + self.assertEqual(response.status_code, 404) + + def test_finalize_twice_returns_409(self): + record_progress(daily_plan_item=self.item, status="done", actual_minutes=40) + self._finalize() + + response = self._finalize() + self.assertEqual(response.status_code, 409) + + def test_finalize_future_plan_rejected(self): + future_plan = DailyPlan.objects.create( + exam_period=self.exam_period, date=self.today + timedelta(days=1), + available_minutes=60, planned_minutes=0, + ) + # today_date 기준 조회라 future_plan은 애초에 조회 안 됨 -> 404 + self.daily_plan.delete() + + response = self._finalize() + self.assertEqual(response.status_code, 404) + + def test_finalize_does_not_show_other_user_plan(self): + from django.contrib.auth import get_user_model + User = get_user_model() + other_user = User.objects.create_user( + username="other_finalize_view", email="other_finalizeview@example.com", password="pass1234" + ) + self.client.login(username="other_finalizeview@example.com", password="pass1234") + + response = self._finalize() + self.assertEqual(response.status_code, 404) \ No newline at end of file diff --git a/planner/urls.py b/planner/urls.py index d98cf92..7e85222 100644 --- a/planner/urls.py +++ b/planner/urls.py @@ -10,4 +10,5 @@ path('today/', views.today, name='today'), path('', views.dashboard, name='dashboard'), path('progress//', views.progress_record, name='progress_record'), + path('daily-plans/finalize/', views.daily_plan_finalize, name='daily_plan_finalize'), ] \ No newline at end of file diff --git a/planner/views.py b/planner/views.py index 02d7e8c..fcb2524 100644 --- a/planner/views.py +++ b/planner/views.py @@ -11,6 +11,12 @@ from django.urls import reverse from exams.models import ExamPeriod, StudyTask, AvailableTime from planner.services.feasibility_checker import calculate_feasibility, POSSIBLE +from planner.services.progress_recorder import ( + finalize_daily_plan, + DailyPlanAlreadyFinalizedError, + FutureDailyPlanFinalizeError, + IncompleteProgressError, +) from planner.services.schedule_generator import ( generate_schedule, ScheduleAlreadyExistsError, @@ -447,4 +453,51 @@ def progress_record(request, item_id): "actual_minutes": progress_log.actual_minutes, "completion_percent": progress_log.completion_percent, "daily_plan_status": result['daily_plan_status'], + }) + +@login_required +@require_http_methods(["POST"]) +def daily_plan_finalize(request): + today_date = timezone.localdate() + + daily_plan = ( + DailyPlan.objects + .filter( + exam_period__user=request.user, + exam_period__status=ExamPeriodStatus.ACTIVE, + date=today_date, + ) + .first() + ) + + 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 as exc: + return JsonResponse({"message": str(exc)}, status=409) + except FutureDailyPlanFinalizeError as exc: + return JsonResponse({"message": str(exc)}, status=400) + except IncompleteProgressError as exc: + return JsonResponse({"message": str(exc)}, status=400) + + recovery_plans = result.get('recovery_plans') + recovery_group_id = None + if recovery_plans: + candidate = recovery_plans.get('maintain_volume') or recovery_plans.get('core_focus') + if candidate: + recovery_group_id = str(candidate.recovery_group_id) + + if result['needs_recovery'] and recovery_group_id: + redirect_url = "#" # TODO: recovery_compare View 생기면 교체 + else: + redirect_url = reverse('planner:dashboard') + + return JsonResponse({ + "needs_recovery": result['needs_recovery'], + "recovery_group_id": recovery_group_id, + "redirect_url": redirect_url, }) \ No newline at end of file