feat(src/dashboard/board_views): add atomic transaction and select_for_update to move_ticket view

Ensure consistent status updates and prevent race conditions when moving tickets
This commit is contained in:
sankalp committed 2026-06-29 07:19:35 +05:45
1 parent 7431c8f050
commit 1bdb254e94
3 files changed
+43 -40

No files matched your search

+36 -24
View File
@@ -2,6 +2,7 @@ import json
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import transaction
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
@@ -48,6 +49,11 @@ def board_create_ticket(request, project_id):
due_date=due_date, due_date=due_date,
created_by=request.user, created_by=request.user,
) )
ticket = (
Ticket.objects.select_related("project", "created_by")
.prefetch_related("assignees")
.get(pk=ticket.pk)
)
for uid in assignee_ids: for uid in assignee_ids:
TicketUser.objects.get_or_create(ticket=ticket, user_id=uid) TicketUser.objects.get_or_create(ticket=ticket, user_id=uid)
@@ -89,34 +95,41 @@ def board_create_ticket(request, project_id):
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def move_ticket(request, project_id, ticket_id): def move_ticket(request, project_id, ticket_id):
ticket = get_object_or_404(Ticket, pk=ticket_id, project_id=project_id) with transaction.atomic():
data = json.loads(request.body) ticket = get_object_or_404(
status_id = data.get("status_id") Ticket.objects.select_for_update()
if not status_id: .select_related("project", "created_by")
return JsonResponse( .prefetch_related("assignees"),
{"success": False, "error": "status_id required"}, status=400 pk=ticket_id,
project_id=project_id,
) )
data = json.loads(request.body)
status_id = data.get("status_id")
if not status_id:
return JsonResponse(
{"success": False, "error": "status_id required"}, status=400
)
status = get_object_or_404(TicketStatus, pk=status_id, project_id=project_id) status = get_object_or_404(TicketStatus, pk=status_id, project_id=project_id)
if not ticket.project.user_can_move_to_status(request.user, status): if not ticket.project.user_can_move_to_status(request.user, status):
return JsonResponse( return JsonResponse(
{ {
"success": False, "success": False,
"error": "You don't have permission to move tickets to this status.", "error": "You don't have permission to move tickets to this status.",
}, },
status=403, status=403,
)
ticket.status = status
ticket.save(update_fields=["status", "updated_at"])
TicketHistory.objects.create(
ticket=ticket,
user=request.user,
status=status,
) )
ticket.status = status
ticket.save(update_fields=["status", "updated_at"])
TicketHistory.objects.create(
ticket=ticket,
user=request.user,
status=status,
)
notify_users = list(ticket.assignees.all()) notify_users = list(ticket.assignees.all())
if ticket.created_by and ticket.created_by not in notify_users: if ticket.created_by and ticket.created_by not in notify_users:
notify_users.append(ticket.created_by) notify_users.append(ticket.created_by)
@@ -128,7 +141,6 @@ def move_ticket(request, project_id, ticket_id):
message=f"Ticket moved to {status.name} by {request.user.get_full_name() or request.user.username}", message=f"Ticket moved to {status.name} by {request.user.get_full_name() or request.user.username}",
data={"ticket_id": ticket.id, "ticket_uuid": ticket.uuid}, data={"ticket_id": ticket.id, "ticket_uuid": ticket.uuid},
) )
# Send email notification
send_notification_email( send_notification_email(
user=u, user=u,
notification_type="status_changed", notification_type="status_changed",
+6 -15
View File
@@ -47,9 +47,7 @@ def project_board(request, project_id=None):
pk=project_id, pk=project_id,
) )
project_members = selected_project.project_members.select_related("user").all() project_members = selected_project.project_members.select_related("user").all()
statuses = TicketStatus.objects.filter(project=selected_project).order_by( statuses = list(selected_project.ticket_statuses.order_by("sort_order"))
"sort_order"
)
tickets_qs = ( tickets_qs = (
Ticket.objects.filter(project=selected_project) Ticket.objects.filter(project=selected_project)
@@ -132,25 +130,18 @@ def project_board(request, project_id=None):
first_status_name = None first_status_name = None
statuses_json = "[]" statuses_json = "[]"
if selected_project: if selected_project:
first_status = ( first_status = statuses[0] if statuses else None
TicketStatus.objects.filter(project=selected_project)
.order_by("sort_order")
.first()
)
if first_status: if first_status:
first_status_id = first_status.id first_status_id = first_status.id
first_status_name = first_status.name first_status_name = first_status.name
statuses_json = json.dumps( statuses_json = json.dumps(
[ [{"id": s.id, "name": s.name, "color": s.color} for s in statuses]
{"id": s.id, "name": s.name, "color": s.color}
for s in TicketStatus.objects.filter(project=selected_project).order_by(
"sort_order"
)
]
) )
permissions = ( permissions = (
BoardRolePermission.objects.filter(project=selected_project).order_by("role") BoardRolePermission.objects.filter(project=selected_project)
.prefetch_related("move_to_statuses")
.order_by("role")
if selected_project if selected_project
else [] else []
) )
+1 -1
View File
@@ -25,7 +25,7 @@ def export_board_tickets(request, project_id):
tickets = ( tickets = (
Ticket.objects.filter(project=project) Ticket.objects.filter(project=project)
.select_related("status", "priority", "epic", "created_by") .select_related("status", "priority", "epic", "created_by", "project")
.prefetch_related("assignees") .prefetch_related("assignees")
.order_by("-created_at") .order_by("-created_at")
) )