From 3fb17cc460e114870868416bdee0baaee1465b9e Mon Sep 17 00:00:00 2001 From: wngjs8114 Date: Wed, 5 Aug 2026 16:41:41 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20#57=20=EA=B3=84=ED=9A=8D=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=EC=84=B1=20=ED=8C=90=EC=A0=95=20=EB=B0=8F=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EA=B3=84=ED=9A=8D=20=EC=83=9D=EC=84=B1=20View=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- planner/templates/planner/feasibility.html | 21 +++ planner/templates/planner/plan_complete.html | 13 ++ planner/tests.py | 170 +++++++++++++++++++ planner/urls.py | 6 +- planner/views.py | 167 +++++++++++++++++- 5 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 planner/templates/planner/plan_complete.html diff --git a/planner/templates/planner/feasibility.html b/planner/templates/planner/feasibility.html index e69de29..c7084e6 100644 --- a/planner/templates/planner/feasibility.html +++ b/planner/templates/planner/feasibility.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}계획 가능성 판정 - PLAN B{% endblock %} +{% block content %} +

계획 가능성 판정

+

{{ exam_period.title }}

+ +

판정: {{ result.status }}

+

확정된 작업 수: {{ task_count }}

+

최소 필요시간: {{ total_min_minutes }}분

+

최대 필요시간: {{ total_max_minutes }}분

+

가용시간: {{ total_available_minutes }}분

+ + {% if can_generate %} +
+ {% csrf_token %} + +
+ {% else %} +

{{ readiness_error|default:"가능시간 또는 학습작업을 조정해주세요." }}

+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/planner/templates/planner/plan_complete.html b/planner/templates/planner/plan_complete.html new file mode 100644 index 0000000..0b1d6e8 --- /dev/null +++ b/planner/templates/planner/plan_complete.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}계획 생성 완료 - PLAN B{% endblock %} +{% block content %} +

계획 생성 완료

+

{{ exam_period.title }}

+

총 {{ daily_plan_count }}일, {{ total_planned_minutes }}분

+ + +{% endblock %} \ No newline at end of file diff --git a/planner/tests.py b/planner/tests.py index 8998ac6..896d06d 100644 --- a/planner/tests.py +++ b/planner/tests.py @@ -3,6 +3,7 @@ from django.utils import timezone as django_timezone from unittest.mock import patch from planner.services.recovery import RecoveryPlanInvalidDataError +from django.urls import reverse from planner.services.progress_recorder import ( finalize_daily_plan, @@ -1680,3 +1681,172 @@ def test_apply_raises_invalid_data_when_remaining_minutes_not_positive(self): with self.assertRaises(RecoveryPlanInvalidDataError): apply_recovery_plan(self.maintain_volume) + +class PlanGenerateFlowTests(TestCase): + 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="plan_gen_tester", email="plangen@example.com", password="pass1234" + ) + self.other_user = User.objects.create_user( + username="other_tester", email="other@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.client.login(username="plangen@example.com", password="pass1234") + + def _make_confirmed_task(self, exam=None, order=1, min_m=20, max_m=40): + from exams.models import StudyTask + return StudyTask.objects.create( + exam=exam or self.exam, title=f"작업 {order}", importance="high", depth="core", + task_type="concept", difficulty="normal", order=order, + estimated_min_minutes=min_m, estimated_max_minutes=max_m, is_confirmed=True, + ) + + def test_feasibility_blocks_other_user(self): + response = self.client.get( + reverse('planner:feasibility', kwargs={'period_id': self.exam_period.id}) + ) + self.client.login(username="other@example.com", password="pass1234") + response = self.client.get( + reverse('planner:feasibility', kwargs={'period_id': self.exam_period.id}) + ) + self.assertEqual(response.status_code, 404) + + def test_plan_generate_rejects_no_confirmed_tasks(self): + response = self.client.post( + reverse('planner:plan_generate', kwargs={'period_id': self.exam_period.id}) + ) + self.assertRedirects( + response, reverse('planner:feasibility', kwargs={'period_id': self.exam_period.id}) + ) + self.assertEqual(DailyPlan.objects.filter(exam_period=self.exam_period).count(), 0) + + def test_plan_generate_rejects_unconfirmed_task_present(self): + from exams.models import StudyTask + self._make_confirmed_task(order=1) + StudyTask.objects.create( + exam=self.exam, title="미확정 작업", importance="high", depth="core", + task_type="concept", difficulty="normal", order=2, + estimated_min_minutes=20, estimated_max_minutes=40, is_confirmed=False, + ) + AvailableTime.objects.create(exam_period=self.exam_period, date=self.today, available_minutes=100) + + response = self.client.post( + reverse('planner:plan_generate', kwargs={'period_id': self.exam_period.id}) + ) + self.assertRedirects( + response, reverse('planner:feasibility', kwargs={'period_id': self.exam_period.id}) + ) + self.assertEqual(DailyPlan.objects.filter(exam_period=self.exam_period).count(), 0) + + def test_plan_generate_rejects_subject_without_confirmed_task(self): + from exams.models import Exam + self._make_confirmed_task(order=1) + Exam.objects.create( + exam_period=self.exam_period, subject_name="작업 없는 과목", + exam_date=self.today + timedelta(days=6), + ) + AvailableTime.objects.create(exam_period=self.exam_period, date=self.today, available_minutes=100) + + response = self.client.post( + reverse('planner:plan_generate', kwargs={'period_id': self.exam_period.id}) + ) + self.assertRedirects( + response, reverse('planner:feasibility', kwargs={'period_id': self.exam_period.id}) + ) + self.assertEqual(DailyPlan.objects.filter(exam_period=self.exam_period).count(), 0) + + def test_plan_generate_rejects_zero_estimated_time_task(self): + from exams.models import StudyTask + StudyTask.objects.create( + exam=self.exam, title="예상시간 0", importance="high", depth="core", + task_type="concept", difficulty="normal", order=1, + estimated_min_minutes=0, estimated_max_minutes=0, is_confirmed=True, + ) + AvailableTime.objects.create(exam_period=self.exam_period, date=self.today, available_minutes=100) + + response = self.client.post( + reverse('planner:plan_generate', kwargs={'period_id': self.exam_period.id}) + ) + self.assertRedirects( + response, reverse('planner:feasibility', kwargs={'period_id': self.exam_period.id}) + ) + self.assertEqual(DailyPlan.objects.filter(exam_period=self.exam_period).count(), 0) + + def test_plan_generate_ignores_past_available_time(self): + self._make_confirmed_task(order=1, min_m=20, max_m=40) + AvailableTime.objects.create( + exam_period=self.exam_period, date=self.today - timedelta(days=1), available_minutes=100 + ) + response = self.client.post( + reverse('planner:plan_generate', kwargs={'period_id': self.exam_period.id}) + ) + self.assertRedirects( + response, reverse('planner:feasibility', kwargs={'period_id': self.exam_period.id}) + ) + self.assertEqual(DailyPlan.objects.filter(exam_period=self.exam_period).count(), 0) + + def test_plan_generate_rejects_risky_feasibility(self): + self._make_confirmed_task(order=1, min_m=100, max_m=200) + AvailableTime.objects.create(exam_period=self.exam_period, date=self.today, available_minutes=150) + + response = self.client.post( + reverse('planner:plan_generate', kwargs={'period_id': self.exam_period.id}) + ) + self.assertRedirects( + response, reverse('planner:feasibility', kwargs={'period_id': self.exam_period.id}) + ) + self.assertEqual(DailyPlan.objects.filter(exam_period=self.exam_period).count(), 0) + + def test_plan_generate_success_allocates_all_tasks(self): + self._make_confirmed_task(order=1, min_m=20, max_m=40) + self._make_confirmed_task(order=2, min_m=20, max_m=40) + AvailableTime.objects.create(exam_period=self.exam_period, date=self.today, available_minutes=100) + + response = self.client.post( + reverse('planner:plan_generate', kwargs={'period_id': self.exam_period.id}) + ) + self.assertRedirects( + response, reverse('planner:plan_complete', kwargs={'period_id': self.exam_period.id}) + ) + self.assertEqual( + DailyPlanItem.objects.filter(daily_plan__exam_period=self.exam_period).count(), 2 + ) + + def test_plan_generate_twice_redirects_to_complete(self): + self._make_confirmed_task(order=1, min_m=20, max_m=40) + AvailableTime.objects.create(exam_period=self.exam_period, date=self.today, available_minutes=100) + + self.client.post(reverse('planner:plan_generate', kwargs={'period_id': self.exam_period.id})) + response = self.client.post( + reverse('planner:plan_generate', kwargs={'period_id': self.exam_period.id}) + ) + self.assertRedirects( + response, reverse('planner:plan_complete', kwargs={'period_id': self.exam_period.id}) + ) + self.assertEqual( + DailyPlanItem.objects.filter(daily_plan__exam_period=self.exam_period).count(), 1 + ) + + def test_plan_complete_redirects_when_no_plan_yet(self): + response = self.client.get( + reverse('planner:plan_complete', kwargs={'period_id': self.exam_period.id}) + ) + self.assertRedirects( + response, reverse('planner:feasibility', kwargs={'period_id': self.exam_period.id}) + ) \ No newline at end of file diff --git a/planner/urls.py b/planner/urls.py index a259d0d..44b8496 100644 --- a/planner/urls.py +++ b/planner/urls.py @@ -1,6 +1,10 @@ from django.urls import path +from . import views app_name = 'planner' urlpatterns = [ -] + path('feasibility//', views.feasibility, name='feasibility'), + path('plan/generate//', views.plan_generate, name='plan_generate'), + path('plan/complete//', views.plan_complete, name='plan_complete'), +] \ No newline at end of file diff --git a/planner/views.py b/planner/views.py index 91ea44a..0d8bfde 100644 --- a/planner/views.py +++ b/planner/views.py @@ -1,3 +1,166 @@ -from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.http import require_http_methods +from django.utils import timezone -# Create your views here. +from exams.models import ExamPeriod, StudyTask, AvailableTime +from planner.models import DailyPlan +from planner.services.feasibility_checker import calculate_feasibility, POSSIBLE +from planner.services.schedule_generator import ( + generate_schedule, + ScheduleAlreadyExistsError, + UnallocatedTasksError, + MismatchedExamPeriodError, + DuplicateTaskAllocationError, +) + + +def _get_owned_exam_period(user, period_id): + return get_object_or_404(ExamPeriod, id=period_id, user=user) + + +def _confirmed_tasks(exam_period): + return StudyTask.objects.filter( + exam__exam_period=exam_period, is_confirmed=True + ).select_related('exam') + + +def _available_times(exam_period): + """ + 계획 배치에 쓸 가용시간은 항상 '오늘 이후'만 넘긴다. 스케줄러는 + 시험일 이전인지만 검사하고 오늘 이전인지는 검사하지 않으므로, + 여기서 걸러주지 않으면 과거 날짜에 작업이 배치될 수 있다. + """ + planning_start = max(exam_period.start_date, timezone.localdate()) + return AvailableTime.objects.filter( + exam_period=exam_period, + date__gte=planning_start, + date__lte=exam_period.end_date, + ).order_by('date') + + +def _calculate_feasibility_for_period(exam_period): + tasks = list(_confirmed_tasks(exam_period)) + required_min = sum(t.estimated_min_minutes for t in tasks) + required_max = sum(t.estimated_max_minutes for t in tasks) + available = sum(at.available_minutes for at in _available_times(exam_period)) + result = calculate_feasibility(required_min, required_max, available) + return result, tasks + + +def _validate_task_readiness(exam_period): + """ + 가능성 판정·계획 생성 전에 데이터가 실제로 준비됐는지 확인한다. + 과목이 여러 개인데 일부만 확정된 상태로 조용히 계획이 생성되는 걸 막는다. + """ + exams = exam_period.exams.all() + + if not exams.exists(): + return False, "등록된 과목이 없습니다." + + all_tasks = StudyTask.objects.filter(exam__exam_period=exam_period) + + if not all_tasks.exists(): + return False, "등록된 학습 작업이 없습니다." + + if all_tasks.filter(is_confirmed=False).exists(): + return False, "아직 확정되지 않은 학습 작업이 있습니다." + + if exams.exclude(study_tasks__is_confirmed=True).exists(): + return False, "학습 작업이 확정되지 않은 과목이 있습니다." + + if all_tasks.filter(estimated_max_minutes__lte=0).exists(): + return False, "예상시간이 계산되지 않은 학습 작업이 있습니다." + + return True, None + + +@login_required +@require_http_methods(["GET"]) +def feasibility(request, period_id): + exam_period = _get_owned_exam_period(request.user, period_id) + + is_ready, readiness_error = _validate_task_readiness(exam_period) + result, tasks = _calculate_feasibility_for_period(exam_period) + + context = { + 'exam_period': exam_period, + 'result': result, + 'total_min_minutes': result['required_min_minutes'], + 'total_max_minutes': result['required_recommended_minutes'], + 'total_available_minutes': result['available_minutes'], + 'task_count': len(tasks), + 'can_generate': is_ready and result['status'] == POSSIBLE, + 'readiness_error': readiness_error, + } + return render(request, 'planner/feasibility.html', context) + + +@login_required +@require_http_methods(["POST"]) +def plan_generate(request, period_id): + exam_period = _get_owned_exam_period(request.user, period_id) + + is_ready, readiness_error = _validate_task_readiness(exam_period) + if not is_ready: + messages.error(request, readiness_error) + return redirect('planner:feasibility', period_id=exam_period.id) + + result, tasks = _calculate_feasibility_for_period(exam_period) + if result['status'] != POSSIBLE: + messages.error( + request, + "현재 상태에서는 계획을 생성할 수 없습니다. 가능시간 또는 학습작업을 조정해주세요.", + ) + return redirect('planner:feasibility', period_id=exam_period.id) + + available_times = list(_available_times(exam_period)) + + try: + generate_schedule( + exam_period=exam_period, + study_tasks=tasks, + available_times=available_times, + ) + except ScheduleAlreadyExistsError: + messages.info(request, "이미 생성된 계획이 있습니다.") + return redirect('planner:plan_complete', period_id=exam_period.id) + except UnallocatedTasksError: + messages.error( + request, + "전체 가능시간은 충분하지만 시험일 또는 날짜별 가능시간 제약으로 " + "일부 작업을 배치하지 못했습니다. 날짜별 가능시간을 조정해주세요.", + ) + return redirect('planner:feasibility', period_id=exam_period.id) + except (MismatchedExamPeriodError, DuplicateTaskAllocationError): + messages.error(request, "계획 생성 중 데이터 오류가 발생했습니다.") + return redirect('planner:feasibility', period_id=exam_period.id) + + return redirect('planner:plan_complete', period_id=exam_period.id) + + +@login_required +@require_http_methods(["GET"]) +def plan_complete(request, period_id): + exam_period = _get_owned_exam_period(request.user, period_id) + daily_plans = ( + DailyPlan.objects + .filter(exam_period=exam_period) + .order_by('date') + .prefetch_related('items') + ) + + if not daily_plans.exists(): + messages.info(request, "아직 생성된 계획이 없습니다.") + return redirect('planner:feasibility', period_id=exam_period.id) + + total_planned_minutes = sum(dp.planned_minutes for dp in daily_plans) + + context = { + 'exam_period': exam_period, + 'daily_plans': daily_plans, + 'daily_plan_count': daily_plans.count(), + 'total_planned_minutes': total_planned_minutes, + } + return render(request, 'planner/plan_complete.html', context) \ No newline at end of file