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 %}
+
+ {% 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 }}분
+
+
+ {% for dp in daily_plans %}
+ - {{ dp.date }} - {{ dp.planned_minutes }}분 ({{ dp.items.count }}개 작업)
+ {% endfor %}
+
+{% 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