From f17a845a2b3dcc2021478df35d2c3977d62060f5 Mon Sep 17 00:00:00 2001 From: wngjs8114 Date: Sat, 1 Aug 2026 22:20:38 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20#37=20DailyPlan.finalized=5Fat,=20R?= =?UTF-8?q?ecoveryPlanItem.remaining=5Fminutes=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0002_dailyplan_finalized_at_and_more.py | 23 +++++++++++++++++++ planner/models.py | 7 +++++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 planner/migrations/0002_dailyplan_finalized_at_and_more.py diff --git a/planner/migrations/0002_dailyplan_finalized_at_and_more.py b/planner/migrations/0002_dailyplan_finalized_at_and_more.py new file mode 100644 index 0000000..ba0b732 --- /dev/null +++ b/planner/migrations/0002_dailyplan_finalized_at_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.16 on 2026-08-01 13:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('planner', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='dailyplan', + name='finalized_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='recoveryplanitem', + name='remaining_minutes', + field=models.PositiveIntegerField(default=0), + ), + ] diff --git a/planner/models.py b/planner/models.py index dcf4921..04fe627 100644 --- a/planner/models.py +++ b/planner/models.py @@ -25,6 +25,10 @@ class DailyPlan(models.Model): choices=DailyPlanStatus.choices, default=DailyPlanStatus.PLANNED, ) + # 마감 여부는 status와 별개로 관리 (status는 진행 기록 입력 때마다 갱신되므로 + # "마감됨"이라는 1회성 이벤트를 별도 필드로 분리) + finalized_at = models.DateTimeField(null=True, blank=True) + class Meta: ordering = ['date'] @@ -129,6 +133,7 @@ class RecoveryPlanItem(models.Model): max_length=20, choices=RecoveryActionType.choices ) reason = models.TextField(null=True, blank=True) - + remaining_minutes = models.PositiveIntegerField(default=0) + def __str__(self): return f'{self.recovery_plan} - {self.study_task} ({self.action_type})' From a4f3ea5c1ac95ba6fe2753621ef25f087ea6a9f5 Mon Sep 17 00:00:00 2001 From: wngjs8114 Date: Sat, 1 Aug 2026 23:09:17 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20#37=20=ED=95=98=EB=A3=A8=20?= =?UTF-8?q?=EB=A7=88=EA=B0=90=20=EB=B0=8F=20=EB=B3=B5=EA=B5=AC=EC=95=88=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- planner/services/progress_recorder.py | 124 ++++++-- planner/services/recovery.py | 262 ++++++++++++++++ planner/tests.py | 423 ++++++++++++++++++++++++++ 3 files changed, 789 insertions(+), 20 deletions(-) diff --git a/planner/services/progress_recorder.py b/planner/services/progress_recorder.py index 90b34c6..8b4ecad 100644 --- a/planner/services/progress_recorder.py +++ b/planner/services/progress_recorder.py @@ -1,18 +1,19 @@ """ 학습 진행 결과(ProgressLog)를 저장하고, 관련 상태(DailyPlanItem, DailyPlan)와 -speed_factor를 갱신하는 모듈. +speed_factor를 갱신하는 모듈. 하루 마감(finalize_daily_plan) 시 +복구 필요 여부를 판단하고 recovery.py에 복구안 생성을 위임한다. -책임 범위는 여기까지다: +책임 범위: ProgressLog 생성/수정 -> DailyPlanItem 상태 동기화 -> speed_factor 재계산 -> DailyPlan 상태 재계산 - -하루 마감 시 복구 필요 여부를 판단하는 로직(finalize_daily_plan)은 -별도 PR/모듈에서 다룬다. 여기서 복구 여부를 판단하지 않는다. + (마감 시) 미완료 항목 판단 -> recovery.generate_recovery_options() 호출 """ -from django.db import transaction +from django.db import transaction +from django.utils import timezone +from planner.services.recovery import generate_recovery_options from core.choices import DailyPlanStatus, ProgressStatus -from planner.models import ProgressLog +from planner.models import ProgressLog, DailyPlan from planner.services.speed_calibrator import recalculate_speed_factor PROGRESS_TO_ITEM_STATUS = { @@ -48,7 +49,7 @@ def normalize_actual_minutes(status, actual_minutes): status에 따라 actual_minutes 값을 검증/보정한다. - done, partial: 필수, 0보다 커야 함 - - not_done: 없으면 0으로 보정 + - not_done: 입력값과 무관하게 항상 0으로 고정 """ if status in (ProgressStatus.DONE, ProgressStatus.PARTIAL): if actual_minutes is None or actual_minutes <= 0: @@ -56,7 +57,9 @@ def normalize_actual_minutes(status, actual_minutes): "완료/일부완료 상태에서는 actual_minutes가 0보다 커야 합니다." ) return actual_minutes - return actual_minutes or 0 + if status == ProgressStatus.NOT_DONE: + return 0 + raise ValueError(f"알 수 없는 progress_status: {status}") def determine_daily_plan_status(item_statuses: list[str]) -> str: @@ -85,6 +88,8 @@ def determine_daily_plan_status(item_statuses: list[str]) -> str: return DailyPlanStatus.PLANNED +class FinalizedDailyPlanEditError(Exception): + pass @transaction.atomic def record_progress( @@ -94,16 +99,17 @@ def record_progress( actual_minutes: int | None, completion_percent: int | None = None, ): - """ - 진행 결과를 저장하고 관련 상태를 갱신한다. - - 처리 순서: - 1. 입력값 검증/정규화 - 2. ProgressLog update_or_create (재제출 시 갱신) - 3. DailyPlanItem.status 동기화 - 4. 해당 과목 speed_factor 재계산 - 5. DailyPlan 상태 재계산 - """ + daily_plan = ( + DailyPlan.objects + .select_for_update() + .get(pk=daily_plan_item.daily_plan_id) + ) + + if daily_plan.finalized_at is not None: + raise FinalizedDailyPlanEditError( + "마감된 계획의 진행 기록은 수정할 수 없습니다." + ) + normalized_percent = normalize_completion_percent(status, completion_percent) normalized_minutes = normalize_actual_minutes(status, actual_minutes) @@ -122,7 +128,6 @@ def record_progress( exam = daily_plan_item.study_task.exam updated_speed_factor = recalculate_speed_factor(exam) - daily_plan = daily_plan_item.daily_plan item_statuses = list( daily_plan.items.values_list("status", flat=True) ) @@ -134,4 +139,83 @@ def record_progress( "daily_plan_item_status": daily_plan_item.status, "daily_plan_status": daily_plan.status, "updated_speed_factor": updated_speed_factor, + } + + +class DailyPlanAlreadyFinalizedError(Exception): + pass + +class FutureDailyPlanFinalizeError(Exception): + pass + +class IncompleteProgressError(Exception): + def __init__(self, unrecorded_items): + self.unrecorded_items = unrecorded_items + super().__init__( + f"{len(unrecorded_items)}개 작업에 진행 기록이 입력되지 않았습니다." + ) + + +@transaction.atomic +def finalize_daily_plan(daily_plan) -> dict: + """ + 하루 계획을 마감한다. + + - 미래 날짜 계획은 마감 불가 (과거/오늘은 허용 — 마감을 놓친 날도 + 나중에 복구할 수 있어야 하므로) + - 조건부 UPDATE로 finalized_at을 원자적으로 선점해 중복 마감을 막는다 + (select_for_update만으로는 SQLite에서 실제 잠금이 걸리지 않으므로 병행) + - 진행 기록(ProgressLog)이 없는 항목이 하나라도 있으면 IncompleteProgressError + - PARTIAL/NOT_DONE 항목이 있으면 복구안(생성만, 미적용)까지 트랜잭션 안에서 생성 + - DailyPlanItem/ProgressLog는 건드리지 않고 과거 기록으로 보존 + """ + if daily_plan.date > timezone.localdate(): + raise FutureDailyPlanFinalizeError("미래 계획은 마감할 수 없습니다.") + + now = timezone.now() + claimed = DailyPlan.objects.filter( + pk=daily_plan.pk, finalized_at__isnull=True, + ).update(finalized_at=now) + + if claimed == 0: + already = DailyPlan.objects.get(pk=daily_plan.pk) + raise DailyPlanAlreadyFinalizedError( + f"{already}는 이미 {already.finalized_at}에 마감되었습니다." + ) + + locked_plan = ( + DailyPlan.objects + .select_for_update() + .get(pk=daily_plan.pk) + ) + + items = list( + locked_plan.items.select_related('progress_log', 'study_task__exam') + ) + + unrecorded_items = [ + item for item in items if not hasattr(item, 'progress_log') + ] + if unrecorded_items: + raise IncompleteProgressError(unrecorded_items) + + unfinished_items = [ + item for item in items + if item.progress_log.progress_status in ( + ProgressStatus.PARTIAL, ProgressStatus.NOT_DONE + ) + ] + + recovery_plans = None + if unfinished_items: + recovery_plans = generate_recovery_options( + daily_plan=locked_plan, + unfinished_items=unfinished_items, + ) + + return { + 'daily_plan': locked_plan, + 'needs_recovery': bool(unfinished_items), + 'unfinished_items': unfinished_items, + 'recovery_plans': recovery_plans, } \ No newline at end of file diff --git a/planner/services/recovery.py b/planner/services/recovery.py index e69de29..1ad436e 100644 --- a/planner/services/recovery.py +++ b/planner/services/recovery.py @@ -0,0 +1,262 @@ +""" +finalize_daily_plan()에서 넘겨받은 미완료 작업(PARTIAL/NOT_DONE)을 기준으로 +분량유지형/핵심집중형 복구안 2종을 계산만 하고 저장한다. +DailyPlan/DailyPlanItem은 건드리지 않는다 (사용자 승인 전까지 미적용). +""" +import uuid + +from django.db.models import Sum +from django.utils import timezone + +from core.choices import RecoveryType, RecoveryActionType, ProgressStatus +from exams.models import AvailableTime +from planner.models import RecoveryPlan, RecoveryPlanItem, DailyPlanItem +from planner.services.scheduler import ( + TaskInput, AvailableTimeInput, allocate_tasks_to_days, +) +from planner.services.time_estimator import estimate_task_minutes, round_up_to_five + +# 핵심 집중형에서 제외 후보를 고르는 3단계 우선순위. +# importance=high 또는 depth=core인 작업은 어떤 tier에도 해당하지 않아 +# 절대 제외되지 않는다. +EXCLUSION_TIERS = [ + ("low", "optional"), + ("low", "basic"), + ("medium", "optional"), +] + + +def _remaining_minutes(item) -> int: + """ + 복구 시점의 최신 speed_factor로 예상시간을 다시 계산한다. + 최초 스케줄러가 estimated_max_minutes 기준으로 배치하므로, + 복구 계획도 같은 보수적 기준(estimated_max)을 유지한다. + (평균값을 쓰면 복구 일정이 낙관적으로 잡혀 다시 무너질 위험이 커짐) + """ + log = item.progress_log + task = item.study_task + exam = task.exam + + _estimated_min, estimated_max = estimate_task_minutes( + task.task_type, task.difficulty, exam.speed_factor + ) + + if log.progress_status == ProgressStatus.NOT_DONE: + return estimated_max + + # PARTIAL: 완료율만큼 뺀 나머지 비율만 적용 + remaining_ratio = (100 - log.completion_percent) / 100 + return round_up_to_five(estimated_max * remaining_ratio) + + +def _future_available_capacity(exam_period, from_date) -> list[AvailableTimeInput]: + """ + 복구 작업을 배치할 수 있는 날짜별 순수 잔여 가용시간. + 시작일은 항상 '내일'로 고정한다 (from_date가 과거여도 오늘 이전으로는 + 절대 배치하지 않기 위함 — 늦게 마감해도 복구는 항상 내일부터 시작). + """ + from datetime import timedelta + start_date = max( + from_date + timedelta(days=1), + timezone.localdate() + timedelta(days=1), + ) + + available_times = AvailableTime.objects.filter( + exam_period=exam_period, date__gte=start_date + ) + occupied = dict( + DailyPlanItem.objects.filter( + daily_plan__exam_period=exam_period, + daily_plan__date__gte=start_date, + ) + .values('daily_plan__date') + .annotate(total=Sum('planned_minutes')) + .values_list('daily_plan__date', 'total') + ) + return [ + AvailableTimeInput( + date=at.date, + available_minutes=max( + at.available_minutes - occupied.get(at.date, 0), 0 + ), + ) + for at in available_times + ] + + +def _build_task_inputs(items_with_remaining) -> list[TaskInput]: + return [ + TaskInput( + id=item.id, + exam_date=item.study_task.exam.exam_date, + importance=item.study_task.importance, + order=item.study_task.order, + estimated_max_minutes=remaining, + depth=item.study_task.depth, + ) + for item, remaining in items_with_remaining + ] + + +def _try_allocate(exam_period, from_date, items_with_remaining) -> dict: + task_inputs = _build_task_inputs(items_with_remaining) + available_time_inputs = _future_available_capacity(exam_period, from_date) + return allocate_tasks_to_days(task_inputs, available_time_inputs) + + +def _exclusion_sort_key(item): + """ + 같은 tier 안에서 제외 후보를 고르는 순서. + - 시험일이 먼 과목 우선 제외 (가까운 시험 보호) + date는 직접 음수화할 수 없으므로 toordinal()로 정수 변환 후 음수화한다. + - remaining_minutes 내림차순 (큰 것부터 빼야 제외 개수가 최소화됨) + - order 내림차순, id 내림차순: 최종 타이브레이커 + """ + remaining = item._recovery_remaining_minutes + return ( + -item.study_task.exam.exam_date.toordinal(), + -remaining, + -item.study_task.order, + -item.id, + ) + + +def _try_core_focus_exclusion(exam_period, from_date, items_with_remaining): + """ + 3단계 tier를 순서대로 적용해 작업을 하나씩 제외하며 재배치를 시도한다. + 핵심 집중형은 분량 유지형과 구분되어야 하므로, 제외 없이 배치가 이미 + 성공하는 상황이어도 최소 한 개는 제외한 뒤 결과를 만든다. + + importance=high 또는 depth=core인 작업은 후보에서 아예 제외한다. + + 반환: 성공 시 {"remaining": [...], "excluded": [...], "allocation_result": {...}}, + 실패(제외 가능한 후보 자체가 없거나 다 빼도 배치 불가) 시 None + """ + remaining_items = list(items_with_remaining) + excluded_items = [] + + for item, remaining in remaining_items: + item._recovery_remaining_minutes = remaining + + for tier_importance, tier_depth in EXCLUSION_TIERS: + while True: + candidates = [ + (item, remaining) for item, remaining in remaining_items + if item.study_task.importance == tier_importance + and item.study_task.depth == tier_depth + ] + if not candidates: + break + + candidates.sort(key=lambda pair: _exclusion_sort_key(pair[0])) + to_exclude = candidates[0] + remaining_items.remove(to_exclude) + excluded_items.append(to_exclude[0]) + + if not remaining_items: + return None + + result = _try_allocate(exam_period, from_date, remaining_items) + if not result['unallocated_tasks']: + return { + 'remaining': remaining_items, + 'excluded': excluded_items, + 'allocation_result': result, + } + + return None + + +def _create_recovery_plan( + exam_period, recovery_group_id, recovery_type, + items_with_remaining, allocation_result, excluded_items=None, +): + """ + 이미 계산된 allocation_result를 그대로 저장만 한다 (재배치하지 않음). + """ + if allocation_result.get('unallocated_tasks'): + raise ValueError( + "미배치 작업이 있는 결과로 RecoveryPlan을 생성할 수 없습니다." + ) + + recovery_plan = RecoveryPlan.objects.create( + exam_period=exam_period, + recovery_group_id=recovery_group_id, + recovery_type=recovery_type, + ) + + items_by_id = {item.id: (item, remaining) for item, remaining in items_with_remaining} + + recovery_items = [] + for alloc in allocation_result['allocations']: + item, remaining = items_by_id[alloc['task_id']] + recovery_items.append(RecoveryPlanItem( + recovery_plan=recovery_plan, + study_task=item.study_task, + original_date=item.daily_plan.date, + changed_date=alloc['date'], + action_type=RecoveryActionType.RESCHEDULE, + remaining_minutes=remaining, + reason="재배치: 가용시간 내 재계산", + )) + + for item in (excluded_items or []): + remaining = getattr(item, '_recovery_remaining_minutes', 0) + recovery_items.append(RecoveryPlanItem( + recovery_plan=recovery_plan, + study_task=item.study_task, + original_date=item.daily_plan.date, + changed_date=None, + action_type=RecoveryActionType.EXCLUDE, + remaining_minutes=remaining, + reason="핵심 집중형: 우선순위 낮은 작업 단계적 제외", + )) + + RecoveryPlanItem.objects.bulk_create(recovery_items) + return recovery_plan + + +def generate_recovery_options(daily_plan, unfinished_items) -> dict: + exam_period = daily_plan.exam_period + from_date = daily_plan.date + recovery_group_id = uuid.uuid4() + + items_with_remaining = [ + (item, _remaining_minutes(item)) for item in unfinished_items + ] + + maintain_volume_allocation = _try_allocate( + exam_period, from_date, items_with_remaining + ) + maintain_volume = None + maintain_volume_failure_reason = None + if maintain_volume_allocation['unallocated_tasks']: + maintain_volume_failure_reason = "남은 가용시간이 부족합니다." + else: + maintain_volume = _create_recovery_plan( + exam_period, recovery_group_id, RecoveryType.MAINTAIN_VOLUME, + items_with_remaining, maintain_volume_allocation, + ) + + core_focus_result = _try_core_focus_exclusion( + exam_period, from_date, items_with_remaining + ) + + core_focus = None + core_focus_failure_reason = None + if core_focus_result is not None: + core_focus = _create_recovery_plan( + exam_period, recovery_group_id, RecoveryType.CORE_FOCUS, + core_focus_result['remaining'], + core_focus_result['allocation_result'], + excluded_items=core_focus_result['excluded'], + ) + else: + core_focus_failure_reason = "제외 가능한 작업을 줄여도 배치할 수 없습니다." + + return { + 'maintain_volume': maintain_volume, + 'maintain_volume_failure_reason': maintain_volume_failure_reason, + 'core_focus': core_focus, + 'core_focus_failure_reason': core_focus_failure_reason, + } \ No newline at end of file diff --git a/planner/tests.py b/planner/tests.py index 91a07af..00be165 100644 --- a/planner/tests.py +++ b/planner/tests.py @@ -1,5 +1,19 @@ from django.test import TestCase +from datetime import timedelta +from django.utils import timezone as django_timezone +from planner.services.progress_recorder import ( + finalize_daily_plan, + record_progress, + DailyPlanAlreadyFinalizedError, + FutureDailyPlanFinalizeError, + FinalizedDailyPlanEditError, +) +from planner.models import RecoveryPlan +from core.choices import RecoveryType +from exams.models import AvailableTime +from planner.services.progress_recorder import IncompleteProgressError +from planner.services.time_estimator import estimate_task_minutes, round_up_to_five # Create your tests here. from planner.services.feasibility_checker import ( calculate_feasibility, @@ -880,3 +894,412 @@ def test_raises_when_task_belongs_to_other_exam_period(self): study_tasks=[other_task], available_times=available_times, ) + +class FinalizeDailyPlanTests(TestCase): + """ + finalize_daily_plan()과 recovery.py 연동 테스트. + 공통 셋업: 사용자 1명, 시험기간(오늘~오늘+10일), DailyPlan은 오늘 날짜로 생성. + """ + + def setUp(self): + from django.contrib.auth import get_user_model + from exams.models import Exam, ExamPeriod, StudyTask + + User = get_user_model() + self.user = User.objects.create_user( + username="finalize_tester", email="finalize@example.com", password="pass1234" + ) + self.today = django_timezone.localdate() + self.exam_period = ExamPeriod.objects.create( + user=self.user, + title="복구 테스트 시험기간", + start_date=self.today - timedelta(days=1), + end_date=self.today + timedelta(days=10), + ) + + def _make_exam(self, exam_date): + from exams.models import Exam + return Exam.objects.create( + exam_period=self.exam_period, + subject_name="테스트 과목", + exam_date=exam_date, + ) + + def _make_task(self, exam, importance="high", depth="basic", + task_type="concept", difficulty="normal", order=1): + from exams.models import StudyTask + return StudyTask.objects.create( + exam=exam, + title="복구 대상 작업", + importance=importance, + depth=depth, + task_type=task_type, + difficulty=difficulty, + order=order, + estimated_min_minutes=20, + estimated_max_minutes=40, + is_confirmed=True, + ) + + def _make_daily_plan(self, date, available_minutes=60, planned_minutes=40): + return DailyPlan.objects.create( + exam_period=self.exam_period, + date=date, + available_minutes=available_minutes, + planned_minutes=planned_minutes, + ) + + def _make_item(self, daily_plan, task, planned_minutes=40, order=1): + return DailyPlanItem.objects.create( + daily_plan=daily_plan, + study_task=task, + planned_minutes=planned_minutes, + order=order, + ) + + def _record(self, item, status, actual_minutes=None, completion_percent=None): + record_progress( + daily_plan_item=item, + status=status, + actual_minutes=actual_minutes, + completion_percent=completion_percent, + ) + + # ── 1. 분량 유지형 성공 ────────────────────────────── + def test_maintain_volume_succeeds_when_capacity_enough(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + item = self._make_item(daily_plan, task) + self._record(item, "not_done") + + AvailableTime.objects.create( + exam_period=self.exam_period, + date=self.today + timedelta(days=1), + available_minutes=40, + ) + + result = finalize_daily_plan(daily_plan) + + self.assertTrue(result["needs_recovery"]) + recovery = result["recovery_plans"] + self.assertIsNotNone(recovery["maintain_volume"]) + self.assertEqual( + recovery["maintain_volume"].items.count(), 1 + ) + + # ── 2. 분량 유지형 실패 시 Plan 미생성 ────────────── + def test_maintain_volume_fails_when_capacity_insufficient(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + item = self._make_item(daily_plan, task) + self._record(item, "not_done") + # 미래 가용시간을 아예 만들지 않음 -> 배치 불가 + + result = finalize_daily_plan(daily_plan) + + recovery = result["recovery_plans"] + self.assertIsNone(recovery["maintain_volume"]) + self.assertIsNotNone(recovery["maintain_volume_failure_reason"]) + self.assertFalse( + RecoveryPlan.objects.filter(recovery_type=RecoveryType.MAINTAIN_VOLUME).exists() + ) + + # ── 3. 핵심 집중형에서 실제 제외 작업 1개 이상 생성 (보호 작업은 유지) ── + def test_core_focus_always_excludes_at_least_one_when_candidate_exists(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + low_task = self._make_task(exam, importance="low", depth="optional", order=1) + protected_task = self._make_task(exam, importance="high", depth="core", order=2) + daily_plan = self._make_daily_plan(self.today) + low_item = self._make_item(daily_plan, low_task, order=1) + protected_item = self._make_item(daily_plan, protected_task, order=2) + self._record(low_item, "not_done") + self._record(protected_item, "not_done") + + # 두 작업(각 40분) 다 배치 가능한 넉넉한 용량 -> 분량유지형도 성공 + AvailableTime.objects.create( + exam_period=self.exam_period, + date=self.today + timedelta(days=1), + available_minutes=80, + ) + + result = finalize_daily_plan(daily_plan) + recovery = result["recovery_plans"] + + self.assertIsNotNone(recovery["maintain_volume"]) + self.assertIsNotNone(recovery["core_focus"]) + + excluded_task_ids = set( + recovery["core_focus"].items.filter( + action_type="exclude" + ).values_list("study_task_id", flat=True) + ) + self.assertGreaterEqual(len(excluded_task_ids), 1) + self.assertIn(low_task.id, excluded_task_ids) + self.assertNotIn(protected_task.id, excluded_task_ids) + self.assertEqual( + recovery["maintain_volume"].recovery_group_id, + recovery["core_focus"].recovery_group_id, + ) + + # ── 4. 가까운 시험 작업이 보호되는지 확인 ──────────── + def test_core_focus_protects_near_exam_excludes_far_exam_first(self): + near_exam = self._make_exam(exam_date=self.today + timedelta(days=3)) + far_exam = self._make_exam(exam_date=self.today + timedelta(days=8)) + + near_task = self._make_task( + near_exam, importance="low", depth="optional", order=1 + ) + far_task = self._make_task( + far_exam, importance="low", depth="optional", order=2 + ) + + daily_plan = self._make_daily_plan(self.today) + near_item = self._make_item(daily_plan, near_task, order=1) + far_item = self._make_item(daily_plan, far_task, order=2) + self._record(near_item, "not_done") + self._record(far_item, "not_done") + + # 두 작업(각 40분) 다 배치하기엔 부족하지만, 하나만 빼면 충분한 용량 + AvailableTime.objects.create( + exam_period=self.exam_period, + date=self.today + timedelta(days=1), + available_minutes=40, + ) + + result = finalize_daily_plan(daily_plan) + core_focus = result["recovery_plans"]["core_focus"] + + self.assertIsNotNone(core_focus) + excluded_tasks = set( + core_focus.items.filter(action_type="exclude").values_list( + "study_task_id", flat=True + ) + ) + rescheduled_tasks = set( + core_focus.items.filter(action_type="reschedule").values_list( + "study_task_id", flat=True + ) + ) + # 시험일이 먼 작업(far_task)이 제외되고, 가까운 작업(near_task)은 재배치돼야 함 + self.assertIn(far_task.id, excluded_tasks) + self.assertIn(near_task.id, rescheduled_tasks) + + # ── 5. 순차 중복 호출 시 복구 그룹이 추가 생성되지 않는지 확인 ─────── + # (실제 동시 요청 레이스 컨디션은 여기서 검증하지 않음. MVP에서는 + # finalize_daily_plan() 내부 조건부 UPDATE로 방어하며, 실제 동시성 + # 검증은 TransactionTestCase + 별도 스레드/PostgreSQL 환경이 필요함) + def test_finalize_twice_raises_and_does_not_duplicate_recovery(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + item = self._make_item(daily_plan, task) + self._record(item, "not_done") + + AvailableTime.objects.create( + exam_period=self.exam_period, + date=self.today + timedelta(days=1), + available_minutes=40, + ) + + finalize_daily_plan(daily_plan) + recovery_group_count_after_first = RecoveryPlan.objects.values( + "recovery_group_id" + ).distinct().count() + + daily_plan.refresh_from_db() + with self.assertRaises(DailyPlanAlreadyFinalizedError): + finalize_daily_plan(daily_plan) + + recovery_group_count_after_second = RecoveryPlan.objects.values( + "recovery_group_id" + ).distinct().count() + + self.assertEqual( + recovery_group_count_after_first, recovery_group_count_after_second + ) + + # ── 6. 마감 후 진행 기록 수정 거부 ──────────────────── + def test_record_progress_rejected_after_finalize(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + item = self._make_item(daily_plan, task) + self._record(item, "done", actual_minutes=30) + + finalize_daily_plan(daily_plan) + + with self.assertRaises(FinalizedDailyPlanEditError): + record_progress( + daily_plan_item=item, + status="done", + actual_minutes=35, + ) + + # ── 7. 미래 계획은 마감 거부 (과거 계획은 허용) ─────── + def test_finalize_rejects_future_daily_plan(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + future_plan = self._make_daily_plan(self.today + timedelta(days=1)) + item = self._make_item(future_plan, task) + self._record(item, "done", actual_minutes=30) + + with self.assertRaises(FutureDailyPlanFinalizeError): + finalize_daily_plan(future_plan) + + def test_finalize_allows_past_daily_plan(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + past_plan = self._make_daily_plan(self.today - timedelta(days=1)) + item = self._make_item(past_plan, task) + self._record(item, "not_done") + + AvailableTime.objects.create( + exam_period=self.exam_period, + date=self.today + timedelta(days=1), + available_minutes=40, + ) + + result = finalize_daily_plan(past_plan) + + self.assertTrue(result["needs_recovery"]) + # 복구 배치는 반드시 오늘 이후(내일부터)여야 한다 + for item in result["recovery_plans"]["maintain_volume"].items.all(): + if item.changed_date is not None: + self.assertGreater(item.changed_date, self.today) + + # ── 8. (보너스) 완료 항목만 있으면 복구 없음 ───────── + def test_finalize_no_recovery_when_all_completed(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + item = self._make_item(daily_plan, task) + self._record(item, "done", actual_minutes=30) + + result = finalize_daily_plan(daily_plan) + + self.assertFalse(result["needs_recovery"]) + self.assertIsNone(result["recovery_plans"]) + + # ── 9. 진행 기록 누락 시 finalized_at이 롤백되는지 확인 ───── + def test_finalize_with_unrecorded_item_rolls_back_claim(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + self._make_item(daily_plan, task) + # ProgressLog를 일부러 만들지 않는다. + + with self.assertRaises(IncompleteProgressError): + finalize_daily_plan(daily_plan) + + daily_plan.refresh_from_db() + + self.assertIsNone(daily_plan.finalized_at) + self.assertFalse( + RecoveryPlan.objects.filter(exam_period=self.exam_period).exists() + ) + + # ── 10. PARTIAL 항목의 남은 시간이 estimated_max × 잔여비율로 계산되는지 ── + def test_partial_item_uses_latest_speed_factor_and_remaining_ratio(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task( + exam, importance="high", depth="core", + task_type="concept", difficulty="normal", + ) + daily_plan = self._make_daily_plan(self.today) + item = self._make_item(daily_plan, task) + + self._record(item, "partial", actual_minutes=30, completion_percent=50) + + exam.refresh_from_db() + _estimated_min, estimated_max = estimate_task_minutes( + task.task_type, task.difficulty, exam.speed_factor + ) + expected_remaining = round_up_to_five(estimated_max * 0.5) + + AvailableTime.objects.create( + exam_period=self.exam_period, + date=self.today + timedelta(days=1), + available_minutes=200, + ) + + result = finalize_daily_plan(daily_plan) + + recovery_item = ( + result["recovery_plans"]["maintain_volume"].items.get(study_task=task) + ) + self.assertEqual(recovery_item.remaining_minutes, expected_remaining) + + # ── 11. NOT_DONE에 시간이 들어와도 0으로 고정되는지 (record_progress 저장까지) ── + def test_not_done_progress_stores_zero_minutes(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + item = self._make_item(daily_plan, task) + + result = record_progress( + daily_plan_item=item, status="not_done", actual_minutes=50, + ) + self.assertEqual(result["progress_log"].actual_minutes, 0) + + # ── 12. 미래 일정에 이미 배치된 작업이 복구 가용시간에서 차감되는지 ── + def test_existing_future_items_reduce_recovery_capacity(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + + unfinished_task = self._make_task(exam, importance="high", depth="core", order=1) + current_plan = self._make_daily_plan(self.today) + unfinished_item = self._make_item(current_plan, unfinished_task) + self._record(unfinished_item, "not_done") + + tomorrow = self.today + timedelta(days=1) + AvailableTime.objects.create( + exam_period=self.exam_period, date=tomorrow, available_minutes=60, + ) + + occupied_task = self._make_task(exam, importance="high", depth="core", order=2) + future_plan = self._make_daily_plan(tomorrow, available_minutes=60, planned_minutes=30) + self._make_item(future_plan, occupied_task, planned_minutes=30) + + result = finalize_daily_plan(current_plan) + + # 가용시간 60분 - 기존 작업 30분 = 30분만 남으므로, + # estimated_max 기준 40분짜리 미완료 작업은 들어갈 자리가 없어야 함 + self.assertIsNone(result["recovery_plans"]["maintain_volume"]) + + # ── 13. 핵심 집중형이 유일한 작업까지 전부 제외하지 않는지 ── + def test_core_focus_does_not_exclude_every_task(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="low", depth="optional") + daily_plan = self._make_daily_plan(self.today) + item = self._make_item(daily_plan, task) + self._record(item, "not_done") + + AvailableTime.objects.create( + exam_period=self.exam_period, + date=self.today + timedelta(days=1), + available_minutes=40, + ) + + result = finalize_daily_plan(daily_plan) + recovery = result["recovery_plans"] + + self.assertIsNotNone(recovery["maintain_volume"]) + self.assertIsNone(recovery["core_focus"]) + self.assertIsNotNone(recovery["core_focus_failure_reason"]) + + # ── 14. 전체 완료 시 DB에도 복구안이 없고 finalized_at은 저장됨 ── + def test_finalize_no_recovery_when_all_completed_db_check(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + item = self._make_item(daily_plan, task) + self._record(item, "done", actual_minutes=30) + + finalize_daily_plan(daily_plan) + daily_plan.refresh_from_db() + + self.assertIsNotNone(daily_plan.finalized_at) + self.assertFalse( + RecoveryPlan.objects.filter(exam_period=self.exam_period).exists() + ) \ No newline at end of file From da40fad528f9afb6ca67f745a6c78c8dfe9d2cdc Mon Sep 17 00:00:00 2001 From: wngjs8114 Date: Sun, 2 Aug 2026 22:53:47 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20#37=20RecoveryPlan.source=5Fdaily?= =?UTF-8?q?=5Fplan=20FK=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...recoveryplan_source_daily_plan_and_more.py | 25 +++++++++++ planner/models.py | 30 ++++++++++++- planner/services/recovery.py | 27 ++++++++---- planner/tests.py | 42 ++++++++++++++++++- 4 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 planner/migrations/0003_recoveryplan_source_daily_plan_and_more.py diff --git a/planner/migrations/0003_recoveryplan_source_daily_plan_and_more.py b/planner/migrations/0003_recoveryplan_source_daily_plan_and_more.py new file mode 100644 index 0000000..8ac56b4 --- /dev/null +++ b/planner/migrations/0003_recoveryplan_source_daily_plan_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.16 on 2026-08-02 13:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('exams', '0001_initial'), + ('planner', '0002_dailyplan_finalized_at_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='recoveryplan', + name='source_daily_plan', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='recovery_plans', to='planner.dailyplan'), + preserve_default=False, + ), + migrations.AddConstraint( + model_name='recoveryplan', + constraint=models.UniqueConstraint(condition=models.Q(('status', 'applied')), fields=('recovery_group_id',), name='unique_applied_recovery_plan_per_group'), + ), + ] diff --git a/planner/models.py b/planner/models.py index 04fe627..ceb0a73 100644 --- a/planner/models.py +++ b/planner/models.py @@ -1,7 +1,7 @@ import uuid from django.db import models - +from django.core.exceptions import ValidationError from core.choices import ( DailyPlanStatus, ProgressStatus, @@ -98,6 +98,11 @@ class RecoveryPlan(models.Model): on_delete=models.CASCADE, related_name='recovery_plans', ) + source_daily_plan = models.ForeignKey( + 'planner.DailyPlan', + on_delete=models.CASCADE, + related_name='recovery_plans', + ) # 같은 계산 시점에 생성된 분량유지형/핵심집중형 두 복구안을 묶어서 비교하기 위한 그룹 키 recovery_group_id = models.UUIDField(default=uuid.uuid4, db_index=True) recovery_type = models.CharField( @@ -112,6 +117,29 @@ class RecoveryPlan(models.Model): created_at = models.DateTimeField(auto_now_add=True) applied_at = models.DateTimeField(null=True, blank=True) + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['recovery_group_id'], + condition=models.Q(status=RecoveryPlanStatus.APPLIED), + name='unique_applied_recovery_plan_per_group', + ) + ] + + def clean(self): + super().clean() + + if not self.exam_period_id or not self.source_daily_plan_id: + return + + if self.exam_period_id != self.source_daily_plan.exam_period_id: + raise ValidationError({ + 'source_daily_plan': ( + 'source_daily_plan의 exam_period와 ' + 'RecoveryPlan의 exam_period가 일치해야 합니다.' + ) + }) + def __str__(self): return f'{self.exam_period} - {self.recovery_type}' diff --git a/planner/services/recovery.py b/planner/services/recovery.py index 1ad436e..5f830b1 100644 --- a/planner/services/recovery.py +++ b/planner/services/recovery.py @@ -168,8 +168,13 @@ def _try_core_focus_exclusion(exam_period, from_date, items_with_remaining): def _create_recovery_plan( - exam_period, recovery_group_id, recovery_type, - items_with_remaining, allocation_result, excluded_items=None, + *, + source_daily_plan, + recovery_type, + recovery_group_id, + items_with_remaining, + allocation_result, + excluded_items=None, ): """ 이미 계산된 allocation_result를 그대로 저장만 한다 (재배치하지 않음). @@ -180,7 +185,8 @@ def _create_recovery_plan( ) recovery_plan = RecoveryPlan.objects.create( - exam_period=exam_period, + exam_period=source_daily_plan.exam_period, + source_daily_plan=source_daily_plan, recovery_group_id=recovery_group_id, recovery_type=recovery_type, ) @@ -234,8 +240,11 @@ def generate_recovery_options(daily_plan, unfinished_items) -> dict: maintain_volume_failure_reason = "남은 가용시간이 부족합니다." else: maintain_volume = _create_recovery_plan( - exam_period, recovery_group_id, RecoveryType.MAINTAIN_VOLUME, - items_with_remaining, maintain_volume_allocation, + source_daily_plan=daily_plan, + recovery_type=RecoveryType.MAINTAIN_VOLUME, + recovery_group_id=recovery_group_id, + items_with_remaining=items_with_remaining, + allocation_result=maintain_volume_allocation, ) core_focus_result = _try_core_focus_exclusion( @@ -246,9 +255,11 @@ def generate_recovery_options(daily_plan, unfinished_items) -> dict: core_focus_failure_reason = None if core_focus_result is not None: core_focus = _create_recovery_plan( - exam_period, recovery_group_id, RecoveryType.CORE_FOCUS, - core_focus_result['remaining'], - core_focus_result['allocation_result'], + source_daily_plan=daily_plan, + recovery_type=RecoveryType.CORE_FOCUS, + recovery_group_id=recovery_group_id, + items_with_remaining=core_focus_result['remaining'], + allocation_result=core_focus_result['allocation_result'], excluded_items=core_focus_result['excluded'], ) else: diff --git a/planner/tests.py b/planner/tests.py index 00be165..582ec73 100644 --- a/planner/tests.py +++ b/planner/tests.py @@ -1302,4 +1302,44 @@ def test_finalize_no_recovery_when_all_completed_db_check(self): self.assertIsNotNone(daily_plan.finalized_at) self.assertFalse( RecoveryPlan.objects.filter(exam_period=self.exam_period).exists() - ) \ No newline at end of file + ) + +# ── 15. RecoveryPlan에 source_daily_plan이 정확히 저장되는지 ────── + def test_recovery_plans_set_source_daily_plan(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + item = self._make_item(daily_plan, task) + self._record(item, "not_done") + + AvailableTime.objects.create( + exam_period=self.exam_period, + date=self.today + timedelta(days=1), + available_minutes=40, + ) + + result = finalize_daily_plan(daily_plan) + recovery = result["recovery_plans"] + + self.assertEqual(recovery["maintain_volume"].source_daily_plan, daily_plan) + + # ── 16. RecoveryPlan.exam_period가 source_daily_plan.exam_period와 항상 일치 ── + def test_recovery_plan_exam_period_matches_source_daily_plan(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + item = self._make_item(daily_plan, task) + self._record(item, "not_done") + + AvailableTime.objects.create( + exam_period=self.exam_period, + date=self.today + timedelta(days=1), + available_minutes=40, + ) + + finalize_daily_plan(daily_plan) + + plans = RecoveryPlan.objects.filter(source_daily_plan=daily_plan) + self.assertTrue(plans.exists()) + for plan in plans: + self.assertEqual(plan.exam_period_id, daily_plan.exam_period_id) \ No newline at end of file From 5811e4560a429154fefed09a52197b8453dcebd7 Mon Sep 17 00:00:00 2001 From: wngjs8114 Date: Mon, 3 Aug 2026 00:16:39 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20#37=20=ED=95=98=EB=A3=A8=20?= =?UTF-8?q?=EB=A7=88=EA=B0=90=20=EC=8B=9C=20=EB=AF=B8=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=9D=BC=EA=B4=84=20=EB=AA=BB=ED=95=A8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- planner/services/progress_recorder.py | 44 +++++++++++-- planner/tests.py | 94 ++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 8 deletions(-) diff --git a/planner/services/progress_recorder.py b/planner/services/progress_recorder.py index 8b4ecad..95c361c 100644 --- a/planner/services/progress_recorder.py +++ b/planner/services/progress_recorder.py @@ -155,19 +155,34 @@ def __init__(self, unrecorded_items): f"{len(unrecorded_items)}개 작업에 진행 기록이 입력되지 않았습니다." ) +def _mark_items_as_not_done(items): + """ + 미입력 DailyPlanItem들을 일괄 NOT_DONE으로 기록한다. + 사용자의 명시적 동의(mark_unrecorded_as_not_done=True) 하에서만 호출되며, + record_progress()의 검증/속도재계산 경로를 반복하지 않기 위해 분리했다. + """ + for item in items: + ProgressLog.objects.create( + daily_plan_item=item, + progress_status=ProgressStatus.NOT_DONE, + actual_minutes=0, + completion_percent=0, + ) + item.status = PROGRESS_TO_ITEM_STATUS[ProgressStatus.NOT_DONE] + item.save(update_fields=['status']) @transaction.atomic -def finalize_daily_plan(daily_plan) -> dict: +def finalize_daily_plan(daily_plan, *, mark_unrecorded_as_not_done: bool = False) -> dict: """ 하루 계획을 마감한다. - - 미래 날짜 계획은 마감 불가 (과거/오늘은 허용 — 마감을 놓친 날도 - 나중에 복구할 수 있어야 하므로) + - 미래 날짜 계획은 마감 불가 - 조건부 UPDATE로 finalized_at을 원자적으로 선점해 중복 마감을 막는다 (select_for_update만으로는 SQLite에서 실제 잠금이 걸리지 않으므로 병행) - - 진행 기록(ProgressLog)이 없는 항목이 하나라도 있으면 IncompleteProgressError - - PARTIAL/NOT_DONE 항목이 있으면 복구안(생성만, 미적용)까지 트랜잭션 안에서 생성 - - DailyPlanItem/ProgressLog는 건드리지 않고 과거 기록으로 보존 + - 진행 기록이 없는 항목이 있으면: + mark_unrecorded_as_not_done=False -> IncompleteProgressError + mark_unrecorded_as_not_done=True -> NOT_DONE으로 일괄 기록 후 DailyPlan.status 재계산 + - PARTIAL/NOT_DONE(자동 기록 포함) 항목이 있으면 복구안 생성 """ if daily_plan.date > timezone.localdate(): raise FutureDailyPlanFinalizeError("미래 계획은 마감할 수 없습니다.") @@ -196,8 +211,22 @@ def finalize_daily_plan(daily_plan) -> dict: unrecorded_items = [ item for item in items if not hasattr(item, 'progress_log') ] + + auto_marked_not_done_count = 0 if unrecorded_items: - raise IncompleteProgressError(unrecorded_items) + if not mark_unrecorded_as_not_done: + raise IncompleteProgressError(unrecorded_items) + + _mark_items_as_not_done(unrecorded_items) + auto_marked_not_done_count = len(unrecorded_items) + + items = list( + locked_plan.items.select_related('progress_log', 'study_task__exam') + ) + locked_plan.status = determine_daily_plan_status( + [item.status for item in items] + ) + locked_plan.save(update_fields=['status']) unfinished_items = [ item for item in items @@ -217,5 +246,6 @@ def finalize_daily_plan(daily_plan) -> dict: 'daily_plan': locked_plan, 'needs_recovery': bool(unfinished_items), 'unfinished_items': unfinished_items, + 'auto_marked_not_done_count': auto_marked_not_done_count, 'recovery_plans': recovery_plans, } \ No newline at end of file diff --git a/planner/tests.py b/planner/tests.py index 582ec73..d3e171d 100644 --- a/planner/tests.py +++ b/planner/tests.py @@ -1,6 +1,7 @@ from django.test import TestCase from datetime import timedelta from django.utils import timezone as django_timezone +from unittest.mock import patch from planner.services.progress_recorder import ( finalize_daily_plan, @@ -1342,4 +1343,95 @@ def test_recovery_plan_exam_period_matches_source_daily_plan(self): plans = RecoveryPlan.objects.filter(source_daily_plan=daily_plan) self.assertTrue(plans.exists()) for plan in plans: - self.assertEqual(plan.exam_period_id, daily_plan.exam_period_id) \ No newline at end of file + self.assertEqual(plan.exam_period_id, daily_plan.exam_period_id) + +# ── 17. 옵션 False면 기존처럼 IncompleteProgressError ────── + def test_finalize_without_auto_mark_still_raises_on_unrecorded(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + self._make_item(daily_plan, task) + + with self.assertRaises(IncompleteProgressError): + finalize_daily_plan(daily_plan, mark_unrecorded_as_not_done=False) + + # ── 18. 옵션 True면 미입력 항목이 전부 NOT_DONE으로 기록됨 ── + def test_finalize_auto_marks_unrecorded_as_not_done(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + item = self._make_item(daily_plan, task) + + result = finalize_daily_plan(daily_plan, mark_unrecorded_as_not_done=True) + + item.refresh_from_db() + self.assertEqual(item.progress_log.progress_status, "not_done") + self.assertEqual(item.progress_log.actual_minutes, 0) + self.assertEqual(item.progress_log.completion_percent, 0) + self.assertEqual(result["auto_marked_not_done_count"], 1) + + # ── 19. 자동 기록 후 finalized_at 정상 저장 ──────────────── + def test_finalize_auto_mark_still_saves_finalized_at(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + self._make_item(daily_plan, task) + + finalize_daily_plan(daily_plan, mark_unrecorded_as_not_done=True) + + daily_plan.refresh_from_db() + self.assertIsNotNone(daily_plan.finalized_at) + + # ── 20. 자동 기록된 항목이 복구 대상에 포함됨 ────────────── + def test_auto_marked_items_included_in_recovery(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + item = self._make_item(daily_plan, task) + + AvailableTime.objects.create( + exam_period=self.exam_period, + date=self.today + timedelta(days=1), + available_minutes=60, + ) + + result = finalize_daily_plan(daily_plan, mark_unrecorded_as_not_done=True) + + self.assertTrue(result["needs_recovery"]) + self.assertIn(item, result["unfinished_items"]) + self.assertIsNotNone(result["recovery_plans"]) + + # ── 21. 자동 기록 경로에서도 속도 재계산을 별도로 수행하지 않음 ── + # (기존 CalculateSpeedFactorTests.test_not_done_logs_excluded가 + # "NOT_DONE 로그 자체가 계산에서 제외됨"을 검증한다면, + # 이 테스트는 "자동 마감 경로를 타도 speed_factor가 아예 바뀌지 않는다"를 검증) + def test_auto_marked_not_done_does_not_affect_speed_factor(self): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + self._make_item(daily_plan, task) + + finalize_daily_plan(daily_plan, mark_unrecorded_as_not_done=True) + + exam.refresh_from_db() + self.assertEqual(exam.speed_factor, 1.0) + + # ── 22. 마감 처리 중 예외 발생 시 자동 생성 로그와 선점도 함께 롤백 ── + @patch( + "planner.services.progress_recorder.generate_recovery_options", + side_effect=RuntimeError("복구안 생성 실패"), + ) + def test_finalize_rolls_back_auto_marked_logs_on_error(self, _mock): + exam = self._make_exam(exam_date=self.today + timedelta(days=5)) + task = self._make_task(exam, importance="high", depth="core") + daily_plan = self._make_daily_plan(self.today) + item = self._make_item(daily_plan, task) + + with self.assertRaises(RuntimeError): + finalize_daily_plan(daily_plan, mark_unrecorded_as_not_done=True) + + self.assertFalse( + ProgressLog.objects.filter(daily_plan_item=item).exists() + ) + daily_plan.refresh_from_db() + self.assertIsNone(daily_plan.finalized_at) \ No newline at end of file