-
Notifications
You must be signed in to change notification settings - Fork 10
Undo send #450
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Undo send #450
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,119 @@ | ||||||||||||||||||||
| """API ViewSet for canceling queued send tasks.""" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| import logging | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from drf_spectacular.utils import OpenApiExample, extend_schema | ||||||||||||||||||||
| from rest_framework import exceptions as drf_exceptions | ||||||||||||||||||||
| from rest_framework import serializers as drf_serializers | ||||||||||||||||||||
| from rest_framework import status | ||||||||||||||||||||
| from rest_framework.response import Response | ||||||||||||||||||||
| from rest_framework.views import APIView | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from core import models | ||||||||||||||||||||
| from core.mda.tasks import celery_app | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from .. import permissions | ||||||||||||||||||||
|
|
||||||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| class CancelSendSerializer(drf_serializers.Serializer): | ||||||||||||||||||||
| """Serializer for canceling send tasks.""" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| taskId = drf_serializers.CharField(required=True, help_text="Celery task ID to cancel") | ||||||||||||||||||||
| messageId = drf_serializers.UUIDField(required=True, help_text="Message ID to keep as draft") | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| @extend_schema( | ||||||||||||||||||||
| tags=["messages"], | ||||||||||||||||||||
| request=CancelSendSerializer, | ||||||||||||||||||||
| responses={ | ||||||||||||||||||||
| 200: OpenApiExample( | ||||||||||||||||||||
| "Success", | ||||||||||||||||||||
| value={"detail": "Send cancelled successfully."}, | ||||||||||||||||||||
| ), | ||||||||||||||||||||
| 400: OpenApiExample( | ||||||||||||||||||||
| "Validation Error", | ||||||||||||||||||||
| value={"detail": "Invalid request data."}, | ||||||||||||||||||||
| ), | ||||||||||||||||||||
| 404: OpenApiExample( | ||||||||||||||||||||
| "Not Found", | ||||||||||||||||||||
| value={"detail": "Message not found."}, | ||||||||||||||||||||
| ), | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| description=""" | ||||||||||||||||||||
| Cancel a queued send task and keep the message as a draft. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| This endpoint revokes the Celery task responsible for sending the message | ||||||||||||||||||||
| and ensures the message remains in draft state. | ||||||||||||||||||||
| """, | ||||||||||||||||||||
| examples=[ | ||||||||||||||||||||
| OpenApiExample( | ||||||||||||||||||||
| "Cancel Send", | ||||||||||||||||||||
| value={ | ||||||||||||||||||||
| "taskId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", | ||||||||||||||||||||
| "messageId": "123e4567-e89b-12d3-a456-426614174000", | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| ), | ||||||||||||||||||||
| ], | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| class CancelSendView(APIView): | ||||||||||||||||||||
| """Cancel a queued send task.""" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| permission_classes = [permissions.IsAllowedToAccess] | ||||||||||||||||||||
|
|
||||||||||||||||||||
| def post(self, request): | ||||||||||||||||||||
| """Cancel a queued send task.""" | ||||||||||||||||||||
| serializer = CancelSendSerializer(data=request.data) | ||||||||||||||||||||
| serializer.is_valid(raise_exception=True) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| task_id = serializer.validated_data.get("taskId") | ||||||||||||||||||||
| message_id = serializer.validated_data.get("messageId") | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Ensure message stays as draft | ||||||||||||||||||||
| try: | ||||||||||||||||||||
| message = models.Message.objects.get(id=message_id) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Check if already sent - if draft_blob is gone, it's too late | ||||||||||||||||||||
| if not message.draft_blob: | ||||||||||||||||||||
| raise drf_exceptions.ValidationError( | ||||||||||||||||||||
| "Cannot cancel: message has already been sent." | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| celery_app.control.revoke(task_id, terminate=True) | ||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Task revocation with terminate=True could disrupt email sending. Using Consider:
Alternatively, design the Celery task to check a cancellation flag at the start of execution and abort if set, avoiding the need for terminate=True. 🤖 Prompt for AI Agents
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree with coderabbit here, we need to cancel from the task, not from the outside There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @sylvinus Understood! You're right — canceling from within the task is much safer and more predictable than external termination. Here's the recommended approach: Implementation Strategy1. Add a cancellation flag to the Message modelAdd a field to track cancellation intent: # In models.py
is_send_cancelled = models.BooleanField(default=False)2. Modify the cancel_send viewInstead of revoking the task, set the cancellation flag: # In cancel_send.py
def post(self, request):
# ... validation ...
message = models.Message.objects.get(id=message_id)
if not message.draft_blob:
raise drf_exceptions.ValidationError(
"Cannot cancel: message has already been sent."
)
# Set cancellation flag instead of revoking
message.is_send_cancelled = True
message.save(update_fields=["is_send_cancelled"])
# Optional: still call revoke without terminate for cleanup
celery_app.control.revoke(task_id, terminate=False)
return Response({"detail": "Send cancelled successfully."})3. Check the flag at the start of the Celery taskIn # In tasks.py or outbound.py
@celery_app.task
def send_message_task(message_id, ...):
message = Message.objects.get(id=message_id)
# Check cancellation flag BEFORE any send operations
if message.is_send_cancelled:
logger.info(f"Send cancelled for message {message_id}, keeping as draft")
# Clean up: reset the flag for potential future sends
message.is_send_cancelled = False
message.save(update_fields=["is_send_cancelled"])
return # Abort cleanly
# Proceed with normal send flow
send_message(message, ...)This approach ensures:
Would you like me to help draft the complete implementation with these changes? |
||||||||||||||||||||
| logger.info(f"Revoked Celery task {task_id} for message {message_id}") | ||||||||||||||||||||
|
|
||||||||||||||||||||
| needs_save = False | ||||||||||||||||||||
| update_fields = [] | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if not message.is_draft: | ||||||||||||||||||||
| message.is_draft = True | ||||||||||||||||||||
| needs_save = True | ||||||||||||||||||||
| update_fields.append("is_draft") | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Remove MIME blob - drafts shouldn't have one | ||||||||||||||||||||
| if message.blob: | ||||||||||||||||||||
| try: | ||||||||||||||||||||
| message.blob.delete() | ||||||||||||||||||||
| logger.info(f"Deleted blob {message.blob_id} for cancelled message {message_id}") | ||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||
| logger.warning(f"Failed to delete blob for message {message_id}: {e}") | ||||||||||||||||||||
|
|
||||||||||||||||||||
| message.blob = None | ||||||||||||||||||||
| needs_save = True | ||||||||||||||||||||
| update_fields.append("blob") | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if needs_save: | ||||||||||||||||||||
| message.save(update_fields=update_fields) | ||||||||||||||||||||
| message.thread.update_stats() | ||||||||||||||||||||
| logger.info(f"Message {message_id} reverted to draft state") | ||||||||||||||||||||
|
Comment on lines
+106
to
+109
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential AttributeError if message.thread is None. Line 108 calls Add a null check before calling thread methods: if needs_save:
message.save(update_fields=update_fields)
- message.thread.update_stats()
+ if message.thread:
+ message.thread.update_stats()
logger.info(f"Message {message_id} reverted to draft state")📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| else: | ||||||||||||||||||||
| logger.info(f"Message {message_id} was already in draft state") | ||||||||||||||||||||
| except models.Message.DoesNotExist as e: | ||||||||||||||||||||
| logger.warning(f"Message {message_id} not found, but task {task_id} was revoked") | ||||||||||||||||||||
| raise drf_exceptions.NotFound("Message not found.") from e | ||||||||||||||||||||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| return Response( | ||||||||||||||||||||
| {"detail": "Send cancelled successfully."}, | ||||||||||||||||||||
| status=status.HTTP_200_OK, | ||||||||||||||||||||
| ) | ||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing object-level permission check.
The view fetches the message but never validates that the requesting user has permission to cancel it. Looking at the send view (src/backend/core/api/viewsets/send.py line 103), it calls
self.check_object_permissions(request, message)after fetching.Add permission check after fetching the message:
try: message = models.Message.objects.get(id=message_id) + self.check_object_permissions(request, message) # Check if already sent - if draft_blob is gone, it's too late if not message.draft_blob:📝 Committable suggestion
🤖 Prompt for AI Agents