bottleneckcrm/backend/cases/kanban_views.py

625 lines
22 KiB
Python

"""
Kanban views for case management.
Supports both status-based (default) and custom pipeline-based kanban boards.
"""
from decimal import Decimal
from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import OpenApiParameter, extend_schema, inline_serializer
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from cases.models import Case, CasePipeline, CaseStage
from cases.serializer import (
CaseKanbanCardSerializer,
CaseMoveSerializer,
CasePipelineListSerializer,
CasePipelineSerializer,
CaseStageSerializer,
)
from common.permissions import HasOrgContext
from common.utils import STATUS_CHOICE
class CaseKanbanView(APIView):
"""
Kanban board view for cases.
Supports two modes:
1. Status-based (default): Groups cases by Case.status field
2. Pipeline-based: Groups cases by CaseStage when pipeline_id is provided
"""
permission_classes = (IsAuthenticated, HasOrgContext)
@extend_schema(
tags=["Cases Kanban"],
operation_id="cases_kanban",
parameters=[
OpenApiParameter(name="org", required=True, type=str),
OpenApiParameter(
name="pipeline_id",
description="Pipeline ID. If not provided, uses status-based columns",
required=False,
type=str,
),
OpenApiParameter(
name="assigned_to",
description="Filter by assigned user ID",
required=False,
type=str,
),
OpenApiParameter(
name="priority",
description="Filter by priority (Low/Normal/High/Urgent)",
required=False,
type=str,
),
OpenApiParameter(
name="case_type",
description="Filter by case type (Question/Incident/Problem)",
required=False,
type=str,
),
OpenApiParameter(
name="search",
description="Search in name and description",
required=False,
type=str,
),
OpenApiParameter(
name="account",
description="Filter by account ID",
required=False,
type=str,
),
],
)
def get(self, request):
"""Get kanban board data."""
org = request.profile.org
pipeline_id = request.query_params.get("pipeline_id")
# Base queryset with filters
queryset = (
Case.objects.filter(org=org, is_active=True)
.select_related("created_by", "stage", "account")
.prefetch_related("assigned_to", "tags", "contacts")
)
# Apply permission filtering
if request.profile.role != "ADMIN" and not request.user.is_superuser:
queryset = queryset.filter(
Q(assigned_to=request.profile) | Q(created_by=request.profile.user)
)
# Apply search/filters
queryset = self._apply_filters(queryset, request.query_params)
if pipeline_id:
return self._get_pipeline_kanban(queryset, pipeline_id, org)
else:
return self._get_status_kanban(queryset)
def _apply_filters(self, queryset, params):
"""Apply common filters to queryset."""
if params.get("assigned_to"):
queryset = queryset.filter(assigned_to__id=params.get("assigned_to"))
if params.get("priority"):
queryset = queryset.filter(priority=params.get("priority"))
if params.get("case_type"):
queryset = queryset.filter(case_type=params.get("case_type"))
if params.get("search"):
search = params.get("search")
queryset = queryset.filter(
Q(name__icontains=search) | Q(description__icontains=search)
)
if params.get("account"):
queryset = queryset.filter(account_id=params.get("account"))
if params.get("tags"):
queryset = queryset.filter(tags__id=params.get("tags"))
if params.get("created_at__gte"):
queryset = queryset.filter(created_at__gte=params.get("created_at__gte"))
if params.get("created_at__lte"):
queryset = queryset.filter(created_at__lte=params.get("created_at__lte"))
return queryset.distinct()
def _get_status_kanban(self, queryset):
"""Build kanban data using Case.status as columns."""
# Define column order and colors matching case workflow
status_config = {
"New": {"order": 1, "color": "#3B82F6", "type": "open"},
"Assigned": {"order": 2, "color": "#8B5CF6", "type": "open"},
"Pending": {"order": 3, "color": "#F59E0B", "type": "open"},
"Closed": {"order": 4, "color": "#22C55E", "type": "closed"},
"Rejected": {"order": 5, "color": "#EF4444", "type": "rejected"},
"Duplicate": {"order": 6, "color": "#6B7280", "type": "rejected"},
}
columns = []
for status_value, label in STATUS_CHOICE:
config = status_config.get(
status_value, {"order": 99, "color": "#6B7280", "type": "open"}
)
cases = queryset.filter(status=status_value).order_by(
"kanban_order", "-created_at"
)
columns.append(
{
"id": status_value,
"name": label,
"order": config["order"],
"color": config["color"],
"stage_type": config["type"],
"is_status_column": True,
"wip_limit": None,
"case_count": cases.count(),
"cases": CaseKanbanCardSerializer(cases[:100], many=True).data,
}
)
columns.sort(key=lambda x: x["order"])
return Response(
{
"mode": "status",
"pipeline": None,
"columns": columns,
"total_cases": queryset.count(),
}
)
def _get_pipeline_kanban(self, queryset, pipeline_id, org):
"""Build kanban data using CasePipeline stages as columns."""
pipeline = get_object_or_404(
CasePipeline, pk=pipeline_id, org=org, is_active=True
)
queryset = queryset.filter(stage__pipeline=pipeline)
columns = []
for stage in pipeline.stages.all().order_by("order"):
cases = queryset.filter(stage=stage).order_by("kanban_order", "-created_at")
columns.append(
{
"id": str(stage.id),
"name": stage.name,
"order": stage.order,
"color": stage.color,
"stage_type": stage.stage_type,
"wip_limit": stage.wip_limit,
"maps_to_status": stage.maps_to_status,
"is_status_column": False,
"case_count": cases.count(),
"cases": CaseKanbanCardSerializer(cases[:100], many=True).data,
}
)
return Response(
{
"mode": "pipeline",
"pipeline": CasePipelineListSerializer(pipeline).data,
"columns": columns,
"total_cases": queryset.count(),
}
)
class CaseMoveView(APIView):
"""Move a case to a different stage/status and update order."""
permission_classes = (IsAuthenticated, HasOrgContext)
@extend_schema(
tags=["Cases Kanban"],
operation_id="case_move",
request=CaseMoveSerializer,
)
@transaction.atomic
def patch(self, request, pk):
"""Move case to different column and/or position."""
org = request.profile.org
case = get_object_or_404(Case, pk=pk, org=org)
# Permission check
if request.profile.role != "ADMIN" and not request.user.is_superuser:
if not (
request.profile.user == case.created_by
or request.profile in case.assigned_to.all()
):
return Response(
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
)
serializer = CaseMoveSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"error": True, "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
data = serializer.validated_data
# Handle stage change
if "stage_id" in data:
if data["stage_id"]:
stage = get_object_or_404(CaseStage, pk=data["stage_id"], org=org)
# Check WIP limit
if stage.wip_limit:
current_count = stage.cases.exclude(pk=case.pk).count()
if current_count >= stage.wip_limit:
return Response(
{
"error": f"Stage '{stage.name}' has reached its WIP limit of {stage.wip_limit}"
},
status=status.HTTP_400_BAD_REQUEST,
)
case.stage = stage
# Auto-update status if stage has maps_to_status
if stage.maps_to_status:
case.status = stage.maps_to_status
else:
case.stage = None
# Handle status change (for status-based kanban)
if "status" in data:
case.status = data["status"]
# Calculate new order
new_order = self._calculate_order(data, case, org)
case.kanban_order = new_order
case.save()
return Response(
{
"error": False,
"message": "Case moved successfully",
"case": CaseKanbanCardSerializer(case).data,
}
)
def _calculate_order(self, data, case, org):
"""Calculate the new kanban_order based on position hints."""
if "kanban_order" in data:
return data["kanban_order"]
above_id = data.get("above_case_id")
below_id = data.get("below_case_id")
if above_id and below_id:
above_case = Case.objects.filter(pk=above_id, org=org).first()
below_case = Case.objects.filter(pk=below_id, org=org).first()
if above_case and below_case:
return (above_case.kanban_order + below_case.kanban_order) / 2
if above_id:
above_case = Case.objects.filter(pk=above_id, org=org).first()
if above_case:
if case.stage:
next_case = (
Case.objects.filter(
org=org,
stage=case.stage,
kanban_order__gt=above_case.kanban_order,
)
.order_by("kanban_order")
.first()
)
else:
next_case = (
Case.objects.filter(
org=org,
status=case.status,
stage__isnull=True,
kanban_order__gt=above_case.kanban_order,
)
.order_by("kanban_order")
.first()
)
if next_case:
return (above_case.kanban_order + next_case.kanban_order) / 2
return above_case.kanban_order + Decimal("1000")
if below_id:
below_case = Case.objects.filter(pk=below_id, org=org).first()
if below_case:
return below_case.kanban_order - Decimal("1000")
# Default: append to end
if case.stage:
last_case = (
Case.objects.filter(stage=case.stage, org=org)
.exclude(pk=case.pk)
.order_by("-kanban_order")
.first()
)
else:
last_case = (
Case.objects.filter(status=case.status, stage__isnull=True, org=org)
.exclude(pk=case.pk)
.order_by("-kanban_order")
.first()
)
if last_case:
return last_case.kanban_order + Decimal("1000")
return Decimal("1000")
# Pipeline CRUD Views (following Lead/Task pattern)
class CasePipelineListCreateView(APIView):
"""List and create case pipelines."""
permission_classes = (IsAuthenticated, HasOrgContext)
@extend_schema(
tags=["Case Pipelines"], responses={200: CasePipelineListSerializer(many=True)}
)
def get(self, request):
"""List all pipelines for the organization."""
org = request.profile.org
pipelines = CasePipeline.objects.filter(org=org, is_active=True)
serializer = CasePipelineListSerializer(pipelines, many=True)
return Response({"pipelines": serializer.data})
@extend_schema(
tags=["Case Pipelines"],
request=CasePipelineSerializer,
responses={201: CasePipelineSerializer},
)
def post(self, request):
"""Create a new pipeline."""
org = request.profile.org
if request.profile.role != "ADMIN" and not request.user.is_superuser:
return Response(
{"error": "Only admins can create pipelines"},
status=status.HTTP_403_FORBIDDEN,
)
serializer = CasePipelineSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"error": True, "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
pipeline = serializer.save(org=org, created_by=request.user)
# Create default stages if requested
if request.data.get("create_default_stages", True):
default_stages = [
{
"name": "New",
"order": 1,
"color": "#3B82F6",
"stage_type": "open",
"maps_to_status": "New",
},
{
"name": "Assigned",
"order": 2,
"color": "#8B5CF6",
"stage_type": "open",
"maps_to_status": "Assigned",
},
{
"name": "In Progress",
"order": 3,
"color": "#F59E0B",
"stage_type": "open",
"maps_to_status": "Pending",
},
{
"name": "Resolved",
"order": 4,
"color": "#22C55E",
"stage_type": "closed",
"maps_to_status": "Closed",
},
{
"name": "Rejected",
"order": 5,
"color": "#EF4444",
"stage_type": "rejected",
"maps_to_status": "Rejected",
},
]
for stage_data in default_stages:
CaseStage.objects.create(
pipeline=pipeline, org=org, created_by=request.user, **stage_data
)
# Refresh to include created stages
pipeline.refresh_from_db()
return Response(
CasePipelineSerializer(pipeline).data, status=status.HTTP_201_CREATED
)
class CasePipelineDetailView(APIView):
"""Retrieve, update, delete a pipeline."""
permission_classes = (IsAuthenticated, HasOrgContext)
def get_object(self, pk, org):
return get_object_or_404(CasePipeline, pk=pk, org=org)
@extend_schema(tags=["Case Pipelines"], responses={200: CasePipelineSerializer})
def get(self, request, pk):
pipeline = self.get_object(pk, request.profile.org)
return Response(CasePipelineSerializer(pipeline).data)
@extend_schema(
tags=["Case Pipelines"],
request=CasePipelineSerializer,
responses={200: CasePipelineSerializer},
)
def put(self, request, pk):
if request.profile.role != "ADMIN" and not request.user.is_superuser:
return Response(
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
)
pipeline = self.get_object(pk, request.profile.org)
serializer = CasePipelineSerializer(pipeline, data=request.data, partial=True)
if not serializer.is_valid():
return Response(
{"error": True, "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
pipeline = serializer.save(updated_by=request.user)
return Response(CasePipelineSerializer(pipeline).data)
@extend_schema(tags=["Case Pipelines"], responses={204: None})
def delete(self, request, pk):
if request.profile.role != "ADMIN" and not request.user.is_superuser:
return Response(
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
)
pipeline = self.get_object(pk, request.profile.org)
case_count = Case.objects.filter(stage__pipeline=pipeline).count()
if case_count > 0:
return Response(
{
"error": f"Cannot delete pipeline with {case_count} cases. Move cases first."
},
status=status.HTTP_400_BAD_REQUEST,
)
pipeline.is_active = False
pipeline.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class CaseStageCreateView(APIView):
"""Create a stage in a pipeline."""
permission_classes = (IsAuthenticated, HasOrgContext)
@extend_schema(
tags=["Case Stages"],
request=CaseStageSerializer,
responses={201: CaseStageSerializer},
)
def post(self, request, pipeline_pk):
if request.profile.role != "ADMIN" and not request.user.is_superuser:
return Response(
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
)
org = request.profile.org
pipeline = get_object_or_404(CasePipeline, pk=pipeline_pk, org=org)
serializer = CaseStageSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"error": True, "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
stage = serializer.save(pipeline=pipeline, org=org, created_by=request.user)
return Response(CaseStageSerializer(stage).data, status=status.HTTP_201_CREATED)
class CaseStageDetailView(APIView):
"""Update or delete a stage."""
permission_classes = (IsAuthenticated, HasOrgContext)
@extend_schema(
tags=["Case Stages"],
request=CaseStageSerializer,
responses={200: CaseStageSerializer},
)
def put(self, request, pk):
if request.profile.role != "ADMIN" and not request.user.is_superuser:
return Response(
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
)
stage = get_object_or_404(CaseStage, pk=pk, org=request.profile.org)
serializer = CaseStageSerializer(stage, data=request.data, partial=True)
if not serializer.is_valid():
return Response(
{"error": True, "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
stage = serializer.save(updated_by=request.user)
return Response(CaseStageSerializer(stage).data)
@extend_schema(tags=["Case Stages"], responses={204: None})
def delete(self, request, pk):
if request.profile.role != "ADMIN" and not request.user.is_superuser:
return Response(
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
)
stage = get_object_or_404(CaseStage, pk=pk, org=request.profile.org)
case_count = stage.cases.count()
if case_count > 0:
return Response(
{
"error": f"Cannot delete stage with {case_count} cases. Move cases first."
},
status=status.HTTP_400_BAD_REQUEST,
)
stage.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class CaseStageReorderView(APIView):
"""Bulk reorder stages in a pipeline."""
permission_classes = (IsAuthenticated, HasOrgContext)
@extend_schema(
tags=["Case Stages"],
request=inline_serializer(
name="CaseStageReorderRequest",
fields={"stage_ids": serializers.ListField(child=serializers.UUIDField())},
),
)
@transaction.atomic
def post(self, request, pipeline_pk):
if request.profile.role != "ADMIN" and not request.user.is_superuser:
return Response(
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
)
org = request.profile.org
pipeline = get_object_or_404(CasePipeline, pk=pipeline_pk, org=org)
stage_ids = request.data.get("stage_ids", [])
stages = CaseStage.objects.filter(pipeline=pipeline, id__in=stage_ids)
if stages.count() != len(stage_ids):
return Response(
{"error": "Invalid stage IDs provided"},
status=status.HTTP_400_BAD_REQUEST,
)
for order, stage_id in enumerate(stage_ids):
CaseStage.objects.filter(id=stage_id).update(order=order)
return Response({"message": "Stages reordered successfully"})