diff --git a/accounts/forms.py b/accounts/forms.py index 3140f04..324454a 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -45,6 +45,19 @@ def __init__(self, *args, **kwargs): 'placeholder': '비밀번호를 한번 더 입력하세요', }) + def clean_email(self): + email = self.cleaned_data.get('email') + if User.objects.filter(email=email).exists(): + raise forms.ValidationError('이미 가입된 이메일입니다.') + return email + + def save(self, commit=True): + user = super().save(commit=False) + user.username = self.cleaned_data['email'] + if commit: + user.save() + return user + class CustomAuthenticationForm(AuthenticationForm): """ diff --git a/accounts/tests.py b/accounts/tests.py index 7ce503c..dbc60b2 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -1,3 +1,48 @@ from django.test import TestCase +from django.urls import reverse +from django.contrib.auth import get_user_model -# Create your tests here. +User = get_user_model() + + +class SignupTests(TestCase): + def test_signup_with_different_emails_succeeds_each_time(self): + """서로 다른 이메일로 회원가입을 두 번 해도 둘 다 정상 생성되어야 한다.""" + response1 = self.client.post(reverse('accounts:signup'), { + 'email': 'user1@example.com', + 'nickname': '유저1', + 'password1': 'StrongPass123!', + 'password2': 'StrongPass123!', + }) + response2 = self.client.post(reverse('accounts:signup'), { + 'email': 'user2@example.com', + 'nickname': '유저2', + 'password1': 'StrongPass123!', + 'password2': 'StrongPass123!', + }) + + self.assertEqual(response1.status_code, 302) + self.assertEqual(response2.status_code, 302) + self.assertEqual(User.objects.count(), 2) + + user1 = User.objects.get(email='user1@example.com') + user2 = User.objects.get(email='user2@example.com') + # username에 email이 채워지고 서로 달라 unique 제약을 통과해야 함 + self.assertEqual(user1.username, 'user1@example.com') + self.assertEqual(user2.username, 'user2@example.com') + + def test_signup_with_duplicate_email_fails(self): + User.objects.create_user( + username='user1@example.com', email='user1@example.com', password='StrongPass123!' + ) + response = self.client.post(reverse('accounts:signup'), { + 'email': 'user1@example.com', + 'nickname': '중복유저', + 'password1': 'StrongPass123!', + 'password2': 'StrongPass123!', + }) + self.assertEqual(response.status_code, 200) # 에러와 함께 폼 재렌더링 + self.assertEqual(User.objects.count(), 1) + self.assertFormError( + response.context['form'], 'email', '이미 가입된 이메일입니다.' + ) \ No newline at end of file diff --git a/accounts/views.py b/accounts/views.py index 91ea44a..502da3b 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,3 +1,68 @@ -from django.shortcuts import render +from django.shortcuts import render, redirect +from django.contrib.auth import login, logout +from django.views.decorators.http import require_http_methods -# Create your views here. +from .forms import CustomUserCreationForm, CustomAuthenticationForm + + +# ========================================== +# 1. 회원가입 뷰 (Signup) +# ========================================== +@require_http_methods(["GET", "POST"]) +def signup_view(request): + """ + URL: /accounts/signup/ + 템플릿: accounts/signup.html + """ + if request.user.is_authenticated: + return redirect('/') + + if request.method == 'POST': + form = CustomUserCreationForm(request.POST) + if form.is_valid(): + form.save() + return redirect('accounts:login') + # form.is_valid()가 False면 에러가 담긴 form을 내려보냄 + else: + form = CustomUserCreationForm() + + context = {'form': form} + return render(request, 'accounts/signup.html', context) + + +# ========================================== +# 2. 로그인 뷰 (Login) +# ========================================== +@require_http_methods(["GET", "POST"]) +def login_view(request): + """ + URL: /accounts/login/ + 템플릿: accounts/login.html + """ + if request.user.is_authenticated: + return redirect('/') + + if request.method == 'POST': + form = CustomAuthenticationForm(request, data=request.POST) + if form.is_valid(): + user = form.get_user() + login(request, user) + next_url = request.POST.get('next') or request.GET.get('next') or '/' + return redirect(next_url) + else: + form = CustomAuthenticationForm() + + context = {'form': form} + return render(request, 'accounts/login.html', context) + + +# ========================================== +# 3. 로그아웃 뷰 (Logout) +# ========================================== +@require_http_methods(["POST"]) +def logout_view(request): + """ + URL: /accounts/logout/ + """ + logout(request) + return redirect('accounts:login') \ No newline at end of file diff --git a/exams/forms.py b/exams/forms.py index 8d317fe..ab8987d 100644 --- a/exams/forms.py +++ b/exams/forms.py @@ -108,9 +108,9 @@ def save(self, commit=True): class ExamForm(forms.ModelForm): class Meta: model = Exam - fields = ['name', 'exam_date', 'priority'] + fields = ['subject_name', 'exam_date', 'priority'] widgets = { - 'name': forms.TextInput(attrs={ + 'subject_name': forms.TextInput(attrs={ 'class': 'form-control', 'placeholder': '예: 신호및시스템, 공학수학' }), diff --git a/exams/migrations/0002_studymaterial_analysis_error_message_and_more.py b/exams/migrations/0002_studymaterial_analysis_error_message_and_more.py new file mode 100644 index 0000000..f423681 --- /dev/null +++ b/exams/migrations/0002_studymaterial_analysis_error_message_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.16 on 2026-08-01 06:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('exams', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='studymaterial', + name='analysis_error_message', + field=models.TextField(blank=True, null=True, verbose_name='AI 분석 실패 사유'), + ), + migrations.AddField( + model_name='studymaterial', + name='analysis_retry_count', + field=models.PositiveSmallIntegerField(default=0, verbose_name='AI 분석 사용자 재시도 횟수'), + ), + migrations.AddField( + model_name='studymaterial', + name='analysis_status', + field=models.CharField(choices=[('pending', '대기 중'), ('processing', '처리 중'), ('completed', '완료'), ('failed', '실패')], default='pending', max_length=20, verbose_name='AI 분석 상태'), + ), + ] diff --git a/exams/models.py b/exams/models.py index 49c4f05..20b8749 100644 --- a/exams/models.py +++ b/exams/models.py @@ -48,7 +48,7 @@ def __str__(self): class AvailableTime(models.Model): """ - 시험기간 내 날짜별 전체 공부 가능시간 + 시험기간 내 날짜별 전체 공부 가능시간 """ exam_period = models.ForeignKey( ExamPeriod, @@ -72,7 +72,7 @@ def __str__(self): class Exam(models.Model): """ - 개별 시험 과목 모델 + 개별 시험 과목 모델 """ exam_period = models.ForeignKey( ExamPeriod, @@ -98,7 +98,7 @@ class Meta: ordering = ['exam_date'] def __str__(self): - return f"{self.name} ({self.exam_date})" + return f"{self.subject_name} ({self.exam_date})" class StudyMaterial(models.Model): @@ -127,19 +127,32 @@ class StudyMaterial(models.Model): verbose_name="텍스트 추출 상태" ) error_message = models.TextField(null=True, blank=True, verbose_name="추출/파싱 실패 원인") + + # AI 분석(E-AI-01/02/03) 상태 - 위 status/error_message(텍스트 추출)와는 별개 필드. + # 리뷰 확정 사항: 텍스트 추출 성공 여부와 AI 분석 성공 여부는 서로 다른 단계라 분리한다. + analysis_status = models.CharField( + max_length=20, + choices=MaterialStatus.choices, + default=MaterialStatus.PENDING, + verbose_name="AI 분석 상태" + ) + analysis_error_message = models.TextField(null=True, blank=True, verbose_name="AI 분석 실패 사유") + analysis_retry_count = models.PositiveSmallIntegerField(default=0, verbose_name="AI 분석 사용자 재시도 횟수") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="생성일시") + class Meta: db_table = 'study_materials' verbose_name = '학습 자료' verbose_name_plural = '학습 자료 목록' def __str__(self): - return f"[{self.exam.name}] {self.title}" + return f"[{self.exam.subject_name}] {self.title}" class StudyTask(models.Model): """ - 하루 학습 분량 + 하루 학습 분량 """ exam = models.ForeignKey( Exam, @@ -211,4 +224,4 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) def __str__(self): - return f"[{self.exam.name}] {self.title}" \ No newline at end of file + return f"[{self.exam.subject_name}] {self.title}" \ No newline at end of file diff --git a/exams/templates/exams/task_form.html b/exams/templates/exams/task_form.html new file mode 100644 index 0000000..e69de29 diff --git a/exams/views.py b/exams/views.py index 91ea44a..14f6207 100644 --- a/exams/views.py +++ b/exams/views.py @@ -1,3 +1,346 @@ -from django.shortcuts import render +import datetime +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.views.decorators.http import require_http_methods +from django.contrib import messages +from django.db import transaction -# Create your views here. +from core.choices import ExamPeriodStatus +from .models import ExamPeriod, AvailableTime, Exam, StudyMaterial, StudyTask +from .forms import ( + ExamPeriodForm, + AvailableTimeFormSet, + ExamForm, + StudyMaterialForm, + StudyTaskForm, + StudyTaskFormSet, +) + + +# ===================================================================== +# 시험기간 목록 (exams:period_list) +# ===================================================================== +@login_required +@require_http_methods(["GET"]) +def period_list(request): + periods = ExamPeriod.objects.filter(user=request.user) + ongoing_periods = periods.exclude( + status__in=[ExamPeriodStatus.COMPLETED, ExamPeriodStatus.ARCHIVED] + ) + completed_periods = periods.filter( + status__in=[ExamPeriodStatus.COMPLETED, ExamPeriodStatus.ARCHIVED] + ) + context = { + 'ongoing_periods': ongoing_periods, + 'completed_periods': completed_periods, + 'has_periods': periods.exists(), + } + return render(request, 'exams/period_list.html', context) + + +# ===================================================================== +# 시험기간 생성 (exams:period_create) +# ===================================================================== +@login_required +@require_http_methods(["GET", "POST"]) +def period_create(request): + """ + 생성 전용. 수정은 period_update가 따로 담당. + - active 시험기간 1개 제한 (MVP 정책) + - 생성 성공 시 '시험기간 상세'로 이동 (표의 연동화면 기준) + - start_date~end_date 범위의 AvailableTime을 0분으로 미리 채워둠 + """ + if request.method == 'POST': + form = ExamPeriodForm(request.POST) + if form.is_valid(): + if ExamPeriod.objects.filter( + user=request.user, status=ExamPeriodStatus.ACTIVE + ).exists(): + messages.error(request, "이미 진행 중인 시험기간이 있습니다. 기존 시험기간을 완료하거나 보관 처리한 뒤 새로 만들어주세요.") + return render(request, 'exams/period_form.html', {'form': form}) + + period = form.save(commit=False) + period.user = request.user + period.status = ExamPeriodStatus.ACTIVE + period.save() + + curr_date = period.start_date + while curr_date <= period.end_date: + AvailableTime.objects.get_or_create( + exam_period=period, date=curr_date, defaults={'available_minutes': 0} + ) + curr_date += datetime.timedelta(days=1) + + return redirect('exams:period_detail', period_id=period.id) + else: + form = ExamPeriodForm() + + return render(request, 'exams/period_form.html', {'form': form}) + + +# ===================================================================== +# 시험기간 수정 (exams:period_update) +# ===================================================================== +@login_required +@require_http_methods(["GET", "POST"]) +def period_update(request, period_id): + period = get_object_or_404(ExamPeriod, id=period_id, user=request.user) + + if request.method == 'POST': + form = ExamPeriodForm(request.POST, instance=period) + if form.is_valid(): + with transaction.atomic(): + updated = form.save() + + # 축소된 경우: 새 범위(start_date~end_date) 밖의 AvailableTime 삭제 + AvailableTime.objects.filter(exam_period=updated).exclude( + date__range=(updated.start_date, updated.end_date) + ).delete() + + # 확장된 경우: 새로 생긴 날짜만 0분으로 채움 (기존 값은 안 건드림) + curr_date = updated.start_date + while curr_date <= updated.end_date: + AvailableTime.objects.get_or_create( + exam_period=updated, date=curr_date, defaults={'available_minutes': 0} + ) + curr_date += datetime.timedelta(days=1) + + return redirect('exams:period_detail', period_id=period.id) + else: + form = ExamPeriodForm(instance=period) + + return render(request, 'exams/period_form.html', {'form': form, 'period': period}) + +# ===================================================================== +# 시험기간 삭제 (exams:period_delete) +# ===================================================================== +@login_required +@require_http_methods(["POST"]) +def period_delete(request, period_id): + period = get_object_or_404(ExamPeriod, id=period_id, user=request.user) + title = period.title + period.delete() + messages.success(request, f"'{title}' 시험기간이 삭제되었습니다.") + return redirect('exams:period_list') + + +# ===================================================================== +# 시험기간 상세 (exams:period_detail) +# ===================================================================== +@login_required +@require_http_methods(["GET"]) +def period_detail(request, period_id): + period = get_object_or_404(ExamPeriod, id=period_id, user=request.user) + exams = period.exams.all() + available_times = period.available_times.all() + context = {'period': period, 'exams': exams, 'available_times': available_times} + return render(request, 'exams/period_detail.html', context) + + +# ===================================================================== +# 과목 추가 (exams:subject_create) +# ===================================================================== +@login_required +@require_http_methods(["GET", "POST"]) +def subject_create(request, period_id): + period = get_object_or_404(ExamPeriod, id=period_id, user=request.user) + + if request.method == 'POST': + form = ExamForm(request.POST, exam_period=period) + if form.is_valid(): + exam = form.save(commit=False) + exam.exam_period = period + exam.save() + return redirect('exams:period_detail', period_id=period.id) # 표: 연동화면=시험기간 상세 + else: + form = ExamForm(exam_period=period) + + return render(request, 'exams/subject_form.html', {'form': form, 'period': period}) + + +# ===================================================================== +# 과목 수정 (exams:subject_update) +# ===================================================================== +@login_required +@require_http_methods(["GET", "POST"]) +def subject_update(request, period_id, exam_id): + period = get_object_or_404(ExamPeriod, id=period_id, user=request.user) + exam = get_object_or_404(Exam, id=exam_id, exam_period=period) + + if request.method == 'POST': + form = ExamForm(request.POST, instance=exam, exam_period=period) + if form.is_valid(): + form.save() + return redirect('exams:period_detail', period_id=period.id) + else: + form = ExamForm(instance=exam, exam_period=period) + + return render(request, 'exams/subject_form.html', {'form': form, 'period': period, 'exam': exam}) + + +# ===================================================================== +# 과목 삭제 (exams:subject_delete) +# ===================================================================== +@login_required +@require_http_methods(["POST"]) +def subject_delete(request, period_id, exam_id): + period = get_object_or_404(ExamPeriod, id=period_id, user=request.user) + exam = get_object_or_404(Exam, id=exam_id, exam_period=period) + subject_name = exam.subject_name + exam.delete() + messages.success(request, f"'{subject_name}' 과목이 삭제되었습니다.") + return redirect('exams:period_detail', period_id=period.id) + + +# ===================================================================== +# 가능시간 입력 (exams:available_time_update) +# ===================================================================== +@login_required +@require_http_methods(["GET", "POST"]) +def available_time_update(request, period_id): + """ + period_create/update에서 이미 0분으로 AvailableTime을 생성해두므로 + 여기서는 값만 채우는 '수정' 개념 (extra=0 formset으로 충분) + """ + period = get_object_or_404(ExamPeriod, id=period_id, user=request.user) + queryset = AvailableTime.objects.filter(exam_period=period).order_by('date') + + if request.method == 'POST': + formset = AvailableTimeFormSet(request.POST, queryset=queryset) + if formset.is_valid(): + instances = formset.save(commit=False) + for instance in instances: + instance.exam_period = period + instance.save() + return redirect('exams:period_detail', period_id=period.id) # 표: 연동화면=시험기간 상세 + else: + formset = AvailableTimeFormSet(queryset=queryset) + + return render(request, 'exams/available_time_form.html', {'formset': formset, 'period': period}) + + +# ===================================================================== +# 자료 등록 (exams:material_create) +# ===================================================================== +@login_required +@require_http_methods(["GET", "POST"]) +def material_create(request, exam_id): + exam = get_object_or_404(Exam, id=exam_id, exam_period__user=request.user) + + if request.method == 'POST': + form = StudyMaterialForm(request.POST, request.FILES) + if form.is_valid(): + material = form.save(commit=False) + material.exam = exam + material.save() + return redirect('exams:material_detail', material_id=material.id) # 표: 연동화면=자료 상세 + else: + form = StudyMaterialForm() + + return render(request, 'exams/material_form.html', {'form': form, 'exam': exam}) + + +# ===================================================================== +# 자료 상세 (exams:material_detail) +# ===================================================================== +@login_required +@require_http_methods(["GET"]) +def material_detail(request, material_id): + material = get_object_or_404(StudyMaterial, id=material_id, exam__exam_period__user=request.user) + tasks = material.study_tasks.all() + return render(request, 'exams/material_detail.html', {'material': material, 'tasks': tasks}) + + +# ===================================================================== +# PDF 텍스트 추출 (exams:material_extract) +# TODO: 실제 추출 로직(pypdf/pdfplumber)은 별도 작업으로 구현 예정 +# ===================================================================== +@login_required +@require_http_methods(["POST"]) +def material_extract(request, material_id): + material = get_object_or_404(StudyMaterial, id=material_id, exam__exam_period__user=request.user) + # TODO: status=PROCESSING → 추출 → SUCCESS(+extracted_text) / FAILED(+error_message) + messages.info(request, "PDF 텍스트 추출 기능은 준비 중입니다.") + return redirect('exams:material_detail', material_id=material.id) + + +# ===================================================================== +# 자료 삭제 (exams:material_delete) +# ===================================================================== +@login_required +@require_http_methods(["POST"]) +def material_delete(request, material_id): + material = get_object_or_404(StudyMaterial, id=material_id, exam__exam_period__user=request.user) + exam = material.exam + period_id = exam.exam_period_id + material.delete() + messages.success(request, "학습자료가 삭제되었습니다.") + return redirect('exams:period_detail', period_id=period_id) + +# ===================================================================== +# AI 작업 검토 (exams:task_review) +# ===================================================================== +@login_required +@require_http_methods(["GET", "POST"]) +def task_review(request, exam_id): + exam = get_object_or_404(Exam, id=exam_id, exam_period__user=request.user) + queryset = StudyTask.objects.filter(exam=exam) + + if request.method == 'POST': + formset = StudyTaskFormSet(request.POST, queryset=queryset) + if formset.is_valid(): + instances = formset.save(commit=False) + for instance in instances: + instance.exam = exam + instance.is_user_modified = True + instance.save() + for obj in formset.deleted_objects: + obj.delete() + return redirect('exams:task_review', exam_id=exam.id) + else: + formset = StudyTaskFormSet(queryset=queryset) + + return render(request, 'exams/task_review.html', {'formset': formset, 'exam': exam}) + + +# ===================================================================== +# 학습 작업 직접 추가 (exams:task_create) +# ===================================================================== +@login_required +@require_http_methods(["GET", "POST"]) +def study_task_create(request, exam_id): + exam = get_object_or_404(Exam, id=exam_id, exam_period__user=request.user) + + if request.method == 'POST': + form = StudyTaskForm(request.POST) + if form.is_valid(): + task = form.save(commit=False) + task.exam = exam + task.is_user_modified = True + task.save() + return redirect('exams:task_review', exam_id=exam.id) + else: + form = StudyTaskForm() + + return render(request, 'exams/task_form.html', {'form': form, 'exam': exam}) + + +# ===================================================================== +# 학습 작업 확정 (exams:task_confirm) +# ===================================================================== +@login_required +@require_http_methods(["POST"]) +def study_task_confirm(request, exam_id): + exam = get_object_or_404(Exam, id=exam_id, exam_period__user=request.user) + tasks = StudyTask.objects.filter(exam=exam, is_confirmed=False) + + if not tasks.exists(): + messages.warning(request, "확정할 학습 작업이 없습니다. 먼저 작업을 검토해주세요.") + return redirect('exams:task_review', exam_id=exam.id) + + for task in tasks: + task.is_confirmed = True + task.save() + + messages.success(request, f"{tasks.count()}개 학습 작업이 확정되었습니다.") + return redirect('planner:feasibility', period_id=exam.exam_period.id) \ No newline at end of file