Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/todos/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 6 additions & 5 deletions apps/todos/tasks.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
from __future__ import annotations

from datetime import timedelta

from django.utils import timezone

from celery import shared_task


@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)
2 changes: 2 additions & 0 deletions apps/todos/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
TodoCompleteView,
TodoConfirmView,
TodoDetailView,
TodoExtendView,
TodoFailView,
TodoGenerateAIView,
TodoListCreateView,
Expand All @@ -28,4 +29,5 @@
path("<uuid:todo_id>/", TodoDetailView.as_view(), name="todo-detail"),
path("<uuid:todo_id>/complete/", TodoCompleteView.as_view(), name="todo-complete"),
path("<uuid:todo_id>/fail/", TodoFailView.as_view(), name="todo-fail"),
path("<uuid:todo_id>/extend/", TodoExtendView.as_view(), name="todo-extend"),
]
84 changes: 83 additions & 1 deletion apps/todos/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -38,6 +42,7 @@

TODO_REWARD_AMOUNT = 1
TODO_DAILY_REWARD_LIMIT = 10
TODO_EXTENSION_TOKEN_COST = 4


class TodoListCreateView(generics.ListCreateAPIView):
Expand Down Expand Up @@ -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,)
Expand Down
37 changes: 37 additions & 0 deletions tests/test_reflections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading