336 lines
12 KiB
Python
336 lines
12 KiB
Python
"""The customer's half of a support case.
|
|
|
|
Every query carries both the org filter and the contact filter. RLS is the
|
|
safety net underneath; these two are the contract. `_my_cases` is the single
|
|
place that decides what "mine" means, so widening it to account-wide later is
|
|
one edit rather than an audit of every view.
|
|
"""
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.db import transaction
|
|
from django.db.models import Q
|
|
from rest_framework import status
|
|
from rest_framework.pagination import LimitOffsetPagination
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from cases.kb_text import snippet, text_match
|
|
from cases.models import Case, Solution
|
|
from cases.portal_serializers import (
|
|
PortalCaseCreateSerializer,
|
|
PortalCaseDetailSerializer,
|
|
PortalCaseSerializer,
|
|
PortalCommentCreateSerializer,
|
|
PortalCommentSerializer,
|
|
PortalSolutionDetailSerializer,
|
|
PortalSolutionSerializer,
|
|
)
|
|
from cases.signals import route_after_relations
|
|
from common.models import Comment
|
|
from common.modules import module_is_available
|
|
from common.portal_auth import IsPortalContact, PortalContactAuthentication
|
|
from common.utils import STATUS_CHOICE
|
|
|
|
VALID_STATUSES = {choice[0] for choice in STATUS_CHOICE}
|
|
|
|
# Three, and not configurable. A deflection panel is a nudge beside a form, so
|
|
# the useful question is "is the answer already here", not "how many can I
|
|
# scroll". The agent picker is the surface that wants a tunable limit.
|
|
SUGGEST_LIMIT = 3
|
|
|
|
# Same reasoning as SUGGEST_LIMIT: a short list under an article people are
|
|
# already reading, not a second index of the knowledge base.
|
|
RELATED_LIMIT = 3
|
|
|
|
|
|
def published_articles(org):
|
|
"""The only definition of "an article a customer may read".
|
|
|
|
Shared by the signed-in portal and the anonymous public help center, which
|
|
show customers the same knowledge base and so must agree on what is in it.
|
|
|
|
Both conditions, not just `is_published`. The pairing is enforced on
|
|
write by `SolutionSerializer.validate`, but that rule arrived after the
|
|
model did, so rows created before it can be published drafts. Requiring
|
|
`approved` here means such a row is never shown to a customer, and the
|
|
first edit to it repairs the pair anyway.
|
|
"""
|
|
return Solution.objects.filter(org=org, is_published=True, status="approved")
|
|
|
|
|
|
def related_articles(org, article):
|
|
"""Other articles the agents filed under the same tags.
|
|
|
|
The tag vocabulary is shared with leads and deals and reads like
|
|
"At Risk" and "VIP", so it is used to *find* these rows and never
|
|
appears in the response. Ids and titles only.
|
|
|
|
Built from `published_articles`, so a shared tag cannot reach a draft
|
|
or another org's article: relatedness narrows the visible set, it never
|
|
widens it.
|
|
"""
|
|
tag_ids = list(article.tags.values_list("id", flat=True))
|
|
if not tag_ids:
|
|
return []
|
|
|
|
siblings = (
|
|
published_articles(org)
|
|
.filter(tags__id__in=tag_ids)
|
|
.exclude(pk=article.pk)
|
|
.distinct()
|
|
.order_by("-updated_at")[:RELATED_LIMIT]
|
|
)
|
|
return [{"id": str(s.id), "title": s.title} for s in siblings]
|
|
|
|
|
|
class PortalBaseView(APIView):
|
|
# Declared here rather than inherited from settings, on purpose. The portal
|
|
# credential is deliberately absent from DEFAULT_AUTHENTICATION_CLASSES,
|
|
# because that setting applies to every view in the project.
|
|
authentication_classes = (PortalContactAuthentication,)
|
|
permission_classes = (IsPortalContact,)
|
|
module: str
|
|
|
|
def closed(self, request):
|
|
"""None while the org has this view's module on, else the answer the
|
|
view gives a record that does not exist. Every handler asks first: the
|
|
middleware gate never sees the portal, which carries no profile."""
|
|
if module_is_available(request.org, self.module):
|
|
return None
|
|
return self._not_found()
|
|
|
|
def _my_cases(self, request):
|
|
"""The only definition of "my cases" in the portal.
|
|
|
|
Contact-scoped, not account-scoped: a colleague at the same company
|
|
cannot read a ticket they were not put on. Widening is a migration,
|
|
narrowing later would be a breach notification.
|
|
"""
|
|
return Case.objects.filter(
|
|
org=request.org, contacts=request.portal_contact, is_active=True
|
|
)
|
|
|
|
def _published_articles(self, request):
|
|
return published_articles(request.org)
|
|
|
|
def _case_or_none(self, request, pk):
|
|
return self._my_cases(request).filter(pk=pk).first()
|
|
|
|
def _not_found(self):
|
|
"""One response for "does not exist" and "not yours".
|
|
|
|
Telling the two apart would confirm that a case id belongs to somebody
|
|
else in this org.
|
|
"""
|
|
return Response({"error": "Case not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
|
|
class PortalCaseListView(PortalBaseView, LimitOffsetPagination):
|
|
module = "tickets"
|
|
|
|
def get(self, request):
|
|
if closed := self.closed(request):
|
|
return closed
|
|
queryset = self._my_cases(request)
|
|
|
|
requested_status = request.query_params.get("status")
|
|
if requested_status is not None:
|
|
if requested_status not in VALID_STATUSES:
|
|
return Response(
|
|
{"error": "Unknown status."}, status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
queryset = queryset.filter(status=requested_status)
|
|
|
|
queryset = queryset.order_by("-created_at")
|
|
page = self.paginate_queryset(queryset, request, view=self)
|
|
return Response(
|
|
{
|
|
"cases": PortalCaseSerializer(page, many=True).data,
|
|
"cases_count": self.count,
|
|
}
|
|
)
|
|
|
|
def post(self, request):
|
|
if closed := self.closed(request):
|
|
return closed
|
|
payload = PortalCaseCreateSerializer(data=request.data)
|
|
if not payload.is_valid():
|
|
return Response(payload.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# org, status and the contact link are all set here rather than read
|
|
# from the body. The serializer does not declare them, so a body that
|
|
# supplies them is ignored rather than honoured. Routing waits for the
|
|
# contact, whose address a rule on the sender's domain reads.
|
|
with transaction.atomic(), route_after_relations():
|
|
case = Case.objects.create(
|
|
org=request.org,
|
|
status="New",
|
|
**payload.validated_data,
|
|
)
|
|
case.contacts.add(request.portal_contact)
|
|
return Response(
|
|
{"case": PortalCaseDetailSerializer(case).data},
|
|
status=status.HTTP_201_CREATED,
|
|
)
|
|
|
|
|
|
class PortalCaseDetailView(PortalBaseView):
|
|
module = "tickets"
|
|
|
|
def get(self, request, pk):
|
|
if closed := self.closed(request):
|
|
return closed
|
|
case = self._case_or_none(request, pk)
|
|
if case is None:
|
|
return self._not_found()
|
|
|
|
# is_internal=False is applied in the query, not in the serializer.
|
|
# An internal note is never loaded, so it cannot be leaked by a later
|
|
# change to how the response is rendered.
|
|
comments = Comment.objects.filter(
|
|
org=request.org,
|
|
content_type=ContentType.objects.get_for_model(Case),
|
|
object_id=case.id,
|
|
is_internal=False,
|
|
).order_by("commented_on")
|
|
|
|
return Response(
|
|
{
|
|
"case": PortalCaseDetailSerializer(case).data,
|
|
"comments": PortalCommentSerializer(
|
|
comments,
|
|
many=True,
|
|
context={"portal_contact": request.portal_contact},
|
|
).data,
|
|
}
|
|
)
|
|
|
|
|
|
class PortalArticleView(PortalBaseView):
|
|
module = "knowledge_base"
|
|
|
|
def _not_found(self):
|
|
# Same response for "no such article" and "not published yet", for
|
|
# the reason `PortalBaseView._not_found` gives about cases: telling
|
|
# them apart confirms that a draft with this id exists.
|
|
return Response(
|
|
{"error": "Article not found"}, status=status.HTTP_404_NOT_FOUND
|
|
)
|
|
|
|
|
|
class PortalArticleListView(PortalArticleView, LimitOffsetPagination):
|
|
def get(self, request):
|
|
if closed := self.closed(request):
|
|
return closed
|
|
queryset = self._published_articles(request)
|
|
|
|
# Narrowed from the visible set, never applied to Solution.objects. A
|
|
# search box that reaches rows the list cannot is the same bug as a
|
|
# missing org filter, just harder to notice.
|
|
search = request.query_params.get("search")
|
|
if search:
|
|
queryset = queryset.filter(
|
|
Q(title__icontains=search) | Q(description__icontains=search)
|
|
)
|
|
|
|
queryset = queryset.order_by("title")
|
|
page = self.paginate_queryset(queryset, request, view=self)
|
|
return Response(
|
|
{
|
|
"articles": PortalSolutionSerializer(page, many=True).data,
|
|
"articles_count": self.count,
|
|
}
|
|
)
|
|
|
|
|
|
class PortalArticleDetailView(PortalArticleView):
|
|
def get(self, request, pk):
|
|
if closed := self.closed(request):
|
|
return closed
|
|
article = self._published_articles(request).filter(pk=pk).first()
|
|
if article is None:
|
|
return self._not_found()
|
|
return Response(
|
|
{
|
|
"article": PortalSolutionDetailSerializer(article).data,
|
|
"related": self._related(request, article),
|
|
}
|
|
)
|
|
|
|
def _related(self, request, article):
|
|
return related_articles(request.org, article)
|
|
|
|
|
|
class PortalArticleSuggestView(PortalArticleView):
|
|
"""Deflection, at the moment the customer is about to file a request.
|
|
|
|
Query-driven rather than case-driven: the agent suggester hangs off an
|
|
existing case, and here there is not one yet. That is the whole reason this
|
|
is a separate endpoint rather than a reuse of `SolutionSuggestionsView`.
|
|
"""
|
|
|
|
def closed(self, request):
|
|
# Off answers as an empty query does: a panel with nothing in it.
|
|
if super().closed(request) is None:
|
|
return None
|
|
return Response({"articles": []})
|
|
|
|
def get(self, request):
|
|
if closed := self.closed(request):
|
|
return closed
|
|
q = (request.query_params.get("q") or "").strip()
|
|
if not q:
|
|
# No seeding from recent articles, unlike the agent side. An agent
|
|
# scanning the newest answers is doing their job; a customer shown
|
|
# three unrelated articles just learns to ignore the panel.
|
|
return Response({"articles": []})
|
|
|
|
results = (
|
|
self._published_articles(request)
|
|
.filter(text_match(q))
|
|
.order_by("-updated_at")[:SUGGEST_LIMIT]
|
|
)
|
|
return Response(
|
|
{
|
|
"articles": [
|
|
{
|
|
"id": str(article.id),
|
|
"title": article.title,
|
|
"snippet": snippet(article.description),
|
|
}
|
|
for article in results
|
|
]
|
|
}
|
|
)
|
|
|
|
|
|
class PortalCaseCommentView(PortalBaseView):
|
|
module = "tickets"
|
|
|
|
def post(self, request, pk):
|
|
if closed := self.closed(request):
|
|
return closed
|
|
case = self._case_or_none(request, pk)
|
|
if case is None:
|
|
return self._not_found()
|
|
|
|
payload = PortalCommentCreateSerializer(data=request.data)
|
|
if not payload.is_valid():
|
|
return Response(payload.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
comment = Comment.objects.create(
|
|
org=request.org,
|
|
content_type=ContentType.objects.get_for_model(Case),
|
|
object_id=case.id,
|
|
comment=payload.validated_data["comment"],
|
|
commented_by_contact=request.portal_contact,
|
|
is_internal=False,
|
|
)
|
|
return Response(
|
|
{
|
|
"comment": PortalCommentSerializer(
|
|
comment, context={"portal_contact": request.portal_contact}
|
|
).data
|
|
},
|
|
status=status.HTTP_201_CREATED,
|
|
)
|