diff --git a/apps/todos/serializers.py b/apps/todos/serializers.py index 8a99fdd..f16cd84 100644 --- a/apps/todos/serializers.py +++ b/apps/todos/serializers.py @@ -53,6 +53,12 @@ def get_quest(self, obj: Todo) -> dict[str, object] | None: } +class TodoExtendRequestSerializer(serializers.Serializer): + """지난 미완료 TODO를 옮길 희망 날짜. 오늘 이후 여부는 뷰에서 검증한다.""" + + todo_date = serializers.DateField() + + class ScheduleSerializer(serializers.ModelSerializer): tag_id = serializers.PrimaryKeyRelatedField( source="tag", diff --git a/apps/todos/tasks.py b/apps/todos/tasks.py index 2e99909..7442217 100644 --- a/apps/todos/tasks.py +++ b/apps/todos/tasks.py @@ -1,7 +1,5 @@ from __future__ import annotations -from datetime import timedelta - from django.utils import timezone from celery import shared_task @@ -9,11 +7,14 @@ @shared_task def fail_incomplete_todos() -> None: - """[스케줄] 매일 자정 실행: 전날 IN_PROGRESS TODO를 FAILED로 변경한다.""" + """[스케줄] 매일 자정 실행: 지난 날짜의 IN_PROGRESS TODO를 모두 FAILED로 변경한다. + + 배치가 하루라도 거르거나 과거 날짜로 만든 TODO 때문에 '미완료'로 남는 일이 + 없도록, 어제뿐 아니라 오늘 이전(todo_date < today) 전체를 대상으로 한다. + """ from apps.todos.models import Todo - target_date = timezone.localdate() - timedelta(days=1) Todo.objects.filter( - todo_date=target_date, + todo_date__lt=timezone.localdate(), status=Todo.Status.IN_PROGRESS, ).update(status=Todo.Status.FAILED) diff --git a/apps/todos/urls.py b/apps/todos/urls.py index df15758..4a60ea8 100644 --- a/apps/todos/urls.py +++ b/apps/todos/urls.py @@ -5,6 +5,7 @@ TodoCompleteView, TodoConfirmView, TodoDetailView, + TodoExtendView, TodoFailView, TodoGenerateAIView, TodoListCreateView, @@ -28,4 +29,5 @@ path("/", TodoDetailView.as_view(), name="todo-detail"), path("/complete/", TodoCompleteView.as_view(), name="todo-complete"), path("/fail/", TodoFailView.as_view(), name="todo-fail"), + path("/extend/", TodoExtendView.as_view(), name="todo-extend"), ] diff --git a/apps/todos/views.py b/apps/todos/views.py index 3237201..878c22b 100644 --- a/apps/todos/views.py +++ b/apps/todos/views.py @@ -21,7 +21,11 @@ from apps.tags.models import Tag from apps.todos.ai_client import TodoAIClient, TodoAIClientError from apps.todos.models import Schedule, Todo -from apps.todos.serializers import ScheduleSerializer, TodoSerializer +from apps.todos.serializers import ( + ScheduleSerializer, + TodoExtendRequestSerializer, + TodoSerializer, +) from apps.todos.todo_ai_serializers import ( SavedScheduleSerializer, SavedTodoSerializer, @@ -38,6 +42,7 @@ TODO_REWARD_AMOUNT = 1 TODO_DAILY_REWARD_LIMIT = 10 +TODO_EXTENSION_TOKEN_COST = 4 class TodoListCreateView(generics.ListCreateAPIView): @@ -455,6 +460,83 @@ def patch(self, request, todo_id) -> Response: return Response({"todo_id": str(todo.todo_id), "status": todo.status}) +class TodoExtendView(APIView): + """지난 일자의 미완료 TODO를 토큰을 사용해 희망 날짜(오늘 이후)로 연장한다. + + 스키마 변경 없이 기존 필드만 사용한다: todo_date 를 옮기고, status 가 FAILED 였다면 + 다시 IN_PROGRESS 로 되살린다. 비용은 User.token_balance 에서 차감하고 + TokenTransaction(type="todo_extension") 으로 기록한다(댓글·완료보상과 동일 패턴). + """ + + permission_classes = (IsAuthenticated,) + + def patch(self, request, todo_id) -> Response: + serializer = TodoExtendRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + new_date = serializer.validated_data["todo_date"] + today = timezone.localdate() + + if new_date < today: + return Response( + {"error": "오늘 이후 날짜로만 연장할 수 있어요."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + with transaction.atomic(): + todo = generics.get_object_or_404( + Todo.objects.select_for_update(), + todo_id=todo_id, + user=request.user, + ) + if todo.status == Todo.Status.COMPLETED: + return Response( + {"error": "이미 완료한 할 일은 연장할 수 없어요."}, + status=status.HTTP_409_CONFLICT, + ) + if todo.todo_date >= today: + return Response( + {"error": "지난 할 일만 연장할 수 있어요."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.select_for_update().get(pk=request.user.pk) + if user.token_balance < TODO_EXTENSION_TOKEN_COST: + message = f"토큰이 부족해요. ({TODO_EXTENSION_TOKEN_COST}개 필요)" + return Response( + { + "error": { + "code": "INSUFFICIENT_TOKEN_BALANCE", + "message": message, + } + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # 날짜를 옮기고, 포기(FAILED)했던 건은 다시 진행 중으로 되살린다. + todo.todo_date = new_date + if todo.status == Todo.Status.FAILED: + todo.status = Todo.Status.IN_PROGRESS + todo.save(update_fields=["todo_date", "status", "updated_at"]) + + user.token_balance -= TODO_EXTENSION_TOKEN_COST + user.save(update_fields=["token_balance", "updated_at"]) + TokenTransaction.objects.create( + user=user, + amount=-TODO_EXTENSION_TOKEN_COST, + type="todo_extension", + reference_id=str(todo.todo_id), + ) + + return Response( + { + "todo_id": str(todo.todo_id), + "status": todo.status, + "todo_date": todo.todo_date, + "token_balance": user.token_balance, + } + ) + + class ScheduleCreateView(generics.CreateAPIView): serializer_class = ScheduleSerializer permission_classes = (IsAuthenticated,) diff --git a/tests/test_reflections.py b/tests/test_reflections.py index e9301e1..402a301 100644 --- a/tests/test_reflections.py +++ b/tests/test_reflections.py @@ -453,6 +453,43 @@ def test_midnight_tasks_fail_todos_and_send_reflection_notification( assert notification.data["reflection_date"] == target_date.isoformat() +@pytest.mark.django_db +def test_fail_incomplete_todos_fails_all_past_dates_not_just_yesterday( + user: User, + tag: Tag, +) -> None: + # 어제뿐 아니라 며칠 전 미완료 TODO도 모두 실패 처리한다. + today = timezone.localdate() + older = Todo.objects.create( + user=user, + tag=tag, + content="며칠전미완료", + todo_date=today - timedelta(days=5), + ) + yesterday = Todo.objects.create( + user=user, + tag=tag, + content="어제미완료", + todo_date=today - timedelta(days=1), + ) + todays = Todo.objects.create( + user=user, + tag=tag, + content="오늘할일", + todo_date=today, + ) + + fail_incomplete_todos() + + older.refresh_from_db() + yesterday.refresh_from_db() + todays.refresh_from_db() + assert older.status == Todo.Status.FAILED + assert yesterday.status == Todo.Status.FAILED + # 오늘 TODO는 아직 실패시키지 않는다. + assert todays.status == Todo.Status.IN_PROGRESS + + @pytest.mark.django_db def test_reflection_notification_skips_existing_reflection( user: User, diff --git a/tests/test_todos.py b/tests/test_todos.py index 23e56eb..927b855 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -2,13 +2,16 @@ from __future__ import annotations +from datetime import timedelta + +from django.utils import timezone import pytest from rest_framework.test import APIClient from apps.quests.models import Quest from apps.tags.models import Tag from apps.todos.models import Todo -from apps.users.models import TokenTransaction +from apps.users.models import TokenTransaction, User # 비로그인 상태에서 TODO 목록 조회 시 401 반환 확인 @@ -196,3 +199,207 @@ def test_todo_complete_respects_daily_reward_limit( assert response.json()["token_balance"] == starting_balance todo.user.refresh_from_db() assert todo.user.token_balance == starting_balance + + +def _past_todo(user: User, tag: Tag, *, status: str = Todo.Status.IN_PROGRESS) -> Todo: + """오늘보다 닷새 전 날짜의 미완료 TODO.""" + return Todo.objects.create( + user=user, + tag=tag, + content="지난할일", + todo_date=timezone.localdate() - timedelta(days=5), + status=status, + ) + + +# 지난 실패(FAILED) TODO 연장 — 날짜 이동 + 진행 중 복귀 + 토큰 4개 차감(실제 연장 대상 경로). +# 자동 실패 배치든 사용자 포기든 지난 미완료는 모두 status=FAILED 이므로 이 경로가 핵심이다. +@pytest.mark.django_db +def test_todo_extend_failed_todo_moves_revives_and_charges_token( + auth_client: APIClient, user: User, tag: Tag +) -> None: + todo = _past_todo(user, tag, status=Todo.Status.FAILED) + starting_balance = user.token_balance # 기본 5 + new_date = timezone.localdate() + timedelta(days=3) + + response = auth_client.patch( + f"/api/v1/todos/{todo.todo_id}/extend/", + {"todo_date": new_date.isoformat()}, + format="json", + ) + + assert response.status_code == 200 + body = response.json() + assert body["todo_date"] == new_date.isoformat() + assert body["status"] == Todo.Status.IN_PROGRESS + assert body["token_balance"] == starting_balance - 4 + todo.refresh_from_db() + user.refresh_from_db() + assert todo.todo_date == new_date + assert user.token_balance == starting_balance - 4 + assert TokenTransaction.objects.filter( + user=user, + amount=-4, + type="todo_extension", + reference_id=str(todo.todo_id), + ).exists() + + +# 자정 배치 전 잔여 '지난 진행 중' TODO도 방어적으로 연장 가능하다(완료만 아니면 허용). +@pytest.mark.django_db +def test_todo_extend_in_progress_past_defensive( + auth_client: APIClient, user: User, tag: Tag +) -> None: + todo = _past_todo(user, tag) # 기본 IN_PROGRESS + new_date = timezone.localdate() + timedelta(days=1) + + response = auth_client.patch( + f"/api/v1/todos/{todo.todo_id}/extend/", + {"todo_date": new_date.isoformat()}, + format="json", + ) + + assert response.status_code == 200 + todo.refresh_from_db() + assert todo.todo_date == new_date + assert todo.status == Todo.Status.IN_PROGRESS + + +# 오늘 포함 미래 날짜(=오늘)로도 연장할 수 있다 +@pytest.mark.django_db +def test_todo_extend_allows_today(auth_client: APIClient, user: User, tag: Tag) -> None: + todo = _past_todo(user, tag) + today = timezone.localdate() + + response = auth_client.patch( + f"/api/v1/todos/{todo.todo_id}/extend/", + {"todo_date": today.isoformat()}, + format="json", + ) + + assert response.status_code == 200 + todo.refresh_from_db() + assert todo.todo_date == today + + +# 이미 완료한 TODO는 연장할 수 없다(409) +@pytest.mark.django_db +def test_todo_extend_rejects_completed( + auth_client: APIClient, user: User, tag: Tag +) -> None: + todo = _past_todo(user, tag, status=Todo.Status.COMPLETED) + new_date = timezone.localdate() + timedelta(days=1) + + response = auth_client.patch( + f"/api/v1/todos/{todo.todo_id}/extend/", + {"todo_date": new_date.isoformat()}, + format="json", + ) + + assert response.status_code == 409 + user.refresh_from_db() + assert user.token_balance == 5 # 차감되지 않음 + + +# 지나지 않은(오늘/미래) TODO는 연장 대상이 아니다(400) +@pytest.mark.django_db +def test_todo_extend_rejects_non_past_todo( + auth_client: APIClient, user: User, tag: Tag +) -> None: + todo = Todo.objects.create( + user=user, + tag=tag, + content="오늘할일", + todo_date=timezone.localdate(), + ) + new_date = timezone.localdate() + timedelta(days=2) + + response = auth_client.patch( + f"/api/v1/todos/{todo.todo_id}/extend/", + {"todo_date": new_date.isoformat()}, + format="json", + ) + + assert response.status_code == 400 + + +# 과거 날짜로는 연장할 수 없다(400) +@pytest.mark.django_db +def test_todo_extend_rejects_past_new_date( + auth_client: APIClient, user: User, tag: Tag +) -> None: + todo = _past_todo(user, tag) + past_date = timezone.localdate() - timedelta(days=1) + + response = auth_client.patch( + f"/api/v1/todos/{todo.todo_id}/extend/", + {"todo_date": past_date.isoformat()}, + format="json", + ) + + assert response.status_code == 400 + todo.refresh_from_db() + assert todo.todo_date == timezone.localdate() - timedelta(days=5) + + +# 토큰이 부족하면 연장에 실패하고 아무것도 바뀌지 않는다(400) +@pytest.mark.django_db +def test_todo_extend_insufficient_tokens( + auth_client: APIClient, user: User, tag: Tag +) -> None: + user.token_balance = 3 + user.save(update_fields=["token_balance"]) + todo = _past_todo(user, tag) + original_date = todo.todo_date + new_date = timezone.localdate() + timedelta(days=1) + + response = auth_client.patch( + f"/api/v1/todos/{todo.todo_id}/extend/", + {"todo_date": new_date.isoformat()}, + format="json", + ) + + assert response.status_code == 400 + assert response.json()["error"]["code"] == "INSUFFICIENT_TOKEN_BALANCE" + todo.refresh_from_db() + user.refresh_from_db() + assert todo.todo_date == original_date + assert user.token_balance == 3 + assert not TokenTransaction.objects.filter(type="todo_extension").exists() + + +# 다른 사용자의 TODO는 연장할 수 없다(404) +@pytest.mark.django_db +def test_todo_extend_other_user_forbidden(auth_client: APIClient, tag: Tag) -> None: + other = User.objects.create_user( + email="other@test.com", + password="password123", + user_name="남", + birth="2000-01-01", + ) + other_tag = Tag.objects.create( + tag_id=2, user=other, content="공부", color="#00ff00" + ) + todo = _past_todo(other, other_tag) + new_date = timezone.localdate() + timedelta(days=1) + + response = auth_client.patch( + f"/api/v1/todos/{todo.todo_id}/extend/", + {"todo_date": new_date.isoformat()}, + format="json", + ) + + assert response.status_code == 404 + + +# 비로그인 상태에서는 연장할 수 없다(401) +@pytest.mark.django_db +def test_todo_extend_unauthenticated(user: User, tag: Tag) -> None: + todo = _past_todo(user, tag) + client = APIClient() + response = client.patch( + f"/api/v1/todos/{todo.todo_id}/extend/", + {"todo_date": timezone.localdate().isoformat()}, + format="json", + ) + assert response.status_code == 401