bottleneckcrm/backend/cases/solution_views.py
Ashwin cab3e92c60 feat: enhance article management with tagging system
- Implemented tagging functionality in the article editing and creation forms, allowing users to select tags for articles.
- Updated the article detail view to display related articles based on shared tags.
- Added a new portal view for customers to access help articles, including search functionality.
- Introduced a suggestions feature in the request form to display relevant articles based on user input.
- Enhanced the mobile solution detail screen to support tag selection and display.
- Updated data models to include tag IDs for articles, ensuring proper handling of tags in the backend.
- Added tests to verify the correct behavior of the tagging system in the solution detail screen.
2026-08-16 07:29:38 +05:30

336 lines
12 KiB
Python

"""
Solution (Knowledge Base) Views
"""
import json
from django.db.models import Count, Q
from drf_spectacular.utils import OpenApiParameter, extend_schema, inline_serializer
from rest_framework import serializers, status
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from cases.kb_access import (
assert_solution_delete_access,
assert_solution_release_access,
assert_solution_write_access,
get_solution_or_404,
)
from cases.models import Solution
from cases.solution_serializers import (
SolutionCreateUpdateSerializer,
SolutionDetailSerializer,
SolutionSerializer,
)
from common.models import Tags
from common.permissions import HasOrgContext
from common.validators import uuid_list_param
def _wants_release(data, instance=None):
"""True when this payload would approve or publish the article.
Both are admin-only acts (see `cases.kb_access`), and both can arrive as
ordinary fields on an ordinary create or update, which is exactly how
they were being performed. Read as a transition, not as a value: a PATCH
that repeats the `approved` an article already has is not an approval, so
an author fixing a typo on their own approved draft is not stopped by a
gate meant for the person who approves it.
"""
moving_to_approved = (
"status" in data
and data.get("status") == "approved"
and (instance is None or instance.status != "approved")
)
publishing = "is_published" in data and _as_bool(data.get("is_published")) != bool(
getattr(instance, "is_published", False)
)
return moving_to_approved or publishing
def _as_bool(value):
"""Query params and form bodies arrive as strings; JSON bodies do not."""
if isinstance(value, bool):
return value
return str(value).strip().lower() in ("true", "1", "yes", "on")
def _apply_tags(solution, data, org):
"""Set an article's tags from a request body, org-safely.
Set here rather than on `SolutionCreateUpdateSerializer` so the org filter
is unavoidable: the ids arrive from the client, and resolving them against
`org` means a tag belonging to another org is simply not found, rather than
being attached and creating a cross-tenant link.
Absent means unchanged, which is the difference between this and the case
endpoints. `cases` clears and re-adds whenever it runs, and
`AccountDetailView.put` clears tags outright; both mean a PATCH that never
mentions tags silently drops them. An explicit `"tags": []` still clears.
"""
if "tags" not in data:
return
ids = data.get("tags") or []
if isinstance(ids, str):
ids = json.loads(ids)
# The web form posts ids; some clients post whole tag objects.
ids = [item.get("id") if isinstance(item, dict) else item for item in ids]
solution.tags.set(Tags.objects.filter(id__in=ids, org=org, is_active=True))
class SolutionListView(APIView, LimitOffsetPagination):
"""
List and create solutions (Knowledge Base)
"""
permission_classes = (IsAuthenticated, HasOrgContext)
@extend_schema(
tags=["Solutions (Knowledge Base)"],
description="List all solutions with filters",
parameters=[
OpenApiParameter(
"status", str, description="Filter by status (draft/reviewed/approved)"
),
OpenApiParameter(
"is_published", bool, description="Filter by published status"
),
OpenApiParameter(
"search", str, description="Search in title and description"
),
OpenApiParameter(
"tags", str, description="Comma-separated tag ids; matches any of them"
),
],
responses={
200: inline_serializer(
name="SolutionListResponse",
fields={
"count": serializers.IntegerField(),
"results": SolutionSerializer(many=True),
"totals": serializers.DictField(),
},
)
},
)
def get(self, request):
"""List solutions with filters"""
org = request.profile.org
base = Solution.objects.filter(org=org)
# The article list rendered `case_count` per row and a nested `org`
# per row, neither of which the queryset fetched: 13 articles cost 27
# queries. Both are one join now, and the count is the same number the
# serializer used to compute a row at a time.
queryset = (
base.select_related("org", "created_by")
.prefetch_related("tags")
.annotate(linked_case_count=Count("cases", distinct=True))
.order_by("-updated_at", "-id")
)
# Filters
status_filter = request.query_params.get("status")
is_published = request.query_params.get("is_published")
search = request.query_params.get("search")
if status_filter:
queryset = queryset.filter(status=status_filter)
if is_published is not None:
# `is_published.lower() == "true"` meant every other spelling:
# `1`, `yes`, `on`: silently filtered to *unpublished*, which is
# the opposite of what the caller asked for.
queryset = queryset.filter(is_published=_as_bool(is_published))
if search:
queryset = queryset.filter(
Q(title__icontains=search) | Q(description__icontains=search)
)
# `distinct()` because an article matching two of the requested tags
# would otherwise be returned twice, which also breaks the page count.
tag_ids = uuid_list_param(request.query_params, "tags")
if tag_ids:
queryset = queryset.filter(tags__id__in=tag_ids).distinct()
# Counted over the whole knowledge base, not over the filtered page.
# These four are a partition of the KB, "12 total, 3 of them drafts"
# , so recomputing them inside a `?status=draft` filter would leave
# the other three cards reading zero and say nothing.
counts = base.aggregate(
count=Count("id"),
published=Count("id", filter=Q(is_published=True)),
draft=Count("id", filter=Q(status="draft")),
reviewed=Count("id", filter=Q(status="reviewed")),
approved_unpublished=Count(
"id", filter=Q(status="approved", is_published=False)
),
)
# Paginate
results = self.paginate_queryset(queryset, request, view=self)
serializer = SolutionSerializer(results, many=True)
response = self.get_paginated_response(serializer.data)
response.data["totals"] = counts
return response
@extend_schema(
tags=["Solutions (Knowledge Base)"],
description="Create a new solution",
request=SolutionCreateUpdateSerializer,
responses={201: SolutionSerializer},
)
def post(self, request):
"""Create a new solution"""
# Approving or publishing on the way in is still approving and
# publishing. Without this an author could write an article and hand
# it to customers in a single request, never touching the endpoints
# that exist to gate exactly that.
if _wants_release(request.data):
assert_solution_release_access(request.profile)
serializer = SolutionCreateUpdateSerializer(data=request.data)
if serializer.is_valid():
solution = serializer.save(org=request.profile.org, created_by=request.user)
_apply_tags(solution, request.data, request.profile.org)
response_serializer = SolutionSerializer(solution)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class SolutionDetailView(APIView):
"""
Get, update, or delete a solution
"""
permission_classes = (IsAuthenticated, HasOrgContext)
@extend_schema(
tags=["Solutions (Knowledge Base)"],
description="Get solution details",
responses={200: SolutionDetailSerializer},
)
def get(self, request, pk):
"""Get solution details"""
solution = get_solution_or_404(request.profile, pk)
# The requester's profile decides which linked cases come back. See
# `SolutionDetailSerializer.get_linked_cases`.
serializer = SolutionDetailSerializer(
solution, context={"profile": request.profile}
)
return Response(serializer.data)
@extend_schema(
tags=["Solutions (Knowledge Base)"],
description="Update solution",
request=SolutionCreateUpdateSerializer,
responses={200: SolutionSerializer},
)
def put(self, request, pk):
"""Update solution"""
return self._update(request, pk, partial=False)
@extend_schema(
tags=["Solutions (Knowledge Base)"],
description="Partially update solution",
request=SolutionCreateUpdateSerializer,
responses={200: SolutionSerializer},
)
def patch(self, request, pk):
"""Partially update solution"""
return self._update(request, pk, partial=True)
def _update(self, request, pk, partial):
solution = get_solution_or_404(request.profile, pk)
assert_solution_write_access(request.profile, solution)
if _wants_release(request.data, solution):
assert_solution_release_access(request.profile)
serializer = SolutionCreateUpdateSerializer(
solution, data=request.data, partial=partial
)
if serializer.is_valid():
solution = serializer.save(updated_by=request.user)
_apply_tags(solution, request.data, request.profile.org)
return Response(SolutionSerializer(solution).data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(
tags=["Solutions (Knowledge Base)"],
description="Delete solution",
responses={204: None},
)
def delete(self, request, pk):
"""Delete solution"""
solution = get_solution_or_404(request.profile, pk)
assert_solution_delete_access(request.profile, solution)
solution.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class SolutionPublishView(APIView):
"""
Publish or unpublish a solution
"""
permission_classes = (IsAuthenticated, HasOrgContext)
@extend_schema(
tags=["Solutions (Knowledge Base)"],
description="Publish an approved solution",
request=None,
responses={200: SolutionSerializer},
)
def post(self, request, pk):
"""Publish solution"""
solution = get_solution_or_404(request.profile, pk)
assert_solution_release_access(request.profile)
if solution.status != "approved":
return Response(
{
"error": "Only approved solutions can be published. Please approve it first."
},
status=status.HTTP_400_BAD_REQUEST,
)
solution.publish()
serializer = SolutionSerializer(solution)
return Response(serializer.data)
class SolutionUnpublishView(APIView):
"""
Unpublish a solution
"""
permission_classes = (IsAuthenticated, HasOrgContext)
@extend_schema(
tags=["Solutions (Knowledge Base)"],
description="Unpublish a solution",
request=None,
responses={200: SolutionSerializer},
)
def post(self, request, pk):
"""Unpublish solution"""
solution = get_solution_or_404(request.profile, pk)
# Taking an article away from customers is the same switch as giving
# it to them, so it is the same rule. Pulling a bad answer down is
# urgent, but "urgent" is an argument for having enough admins, not
# for letting anyone in the org silently un-answer a question.
assert_solution_release_access(request.profile)
solution.unpublish()
serializer = SolutionSerializer(solution)
return Response(serializer.data)