bottleneckcrm/backend/common/views/document_views.py
Ashwin 66421bbad0 Add ticket approval panel tests and enhance ticket board functionality
- Implement tests for ticket approval panel to verify approval request logic and visibility based on user roles.
- Add functionality to handle ticket movement based on server response for both tickets and leads.
- Introduce new tests for duplicate checking API to ensure correct handling of duplicates and merging logic.
- Remove outdated permissions tests that are no longer relevant.
- Update ticket board and lead board providers to include 'can_move' field for better control over ticket actions.
- Refactor existing tests to improve coverage and ensure accurate representation of API responses.
2026-09-26 23:33:20 +05:30

499 lines
20 KiB
Python

from django.db.models import Q
from django.http import FileResponse, Http404
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import 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 common import swagger_params
from common.models import Document, Profile, Teams
from common.permissions import HasOrgContext, is_org_admin
from common.serializer import (
DocumentCreateSerializer,
DocumentCreateSwaggerSerializer,
DocumentEditSwaggerSerializer,
DocumentSerializer,
ProfileSerializer,
)
from common.validators import payload_id_list
def _visible_to(profile):
"""Q() describing which documents a non-admin profile may see.
One definition, used by the list query and by every object-level check
below, because they disagreed before: the list matched on `shared_to`
while the detail view also tried to match the creator, so which documents
you could open did not line up with which ones you could find.
Three ways in, and each was broken in its own way:
* **Creator.** `Document.created_by` is a **User** FK. The create path
sets `created_by=request.profile.user`. The old checks compared it to
`request.profile`, a different model, so the expression was always
False and the uploader of a document could not open or delete it.
Compare against `profile.user`.
* **Shared with you.** Worked, and is unchanged.
* **Shared with a team you are in.** `Document.teams` was written by POST
and PUT and read by no query at all, so picking a team granted nobody
anything while the interface presented it as a share. Honouring it is
what makes the stored value mean what it says.
Scope: this only ever narrows within one org. The caller has already
filtered on `org=request.profile.org`, and RLS is underneath that. It is
not a substitute for either.
"""
return (
Q(created_by=profile.user)
| Q(shared_to=profile)
| Q(teams__in=profile.user_teams.all())
)
def may_read_document(profile, document):
"""Org admins (`is_org_admin`, which admits a superuser's profile), plus
anyone `_visible_to` covers: creator, share, team.
Evaluated as a queryset filter rather than in Python so that it is
literally the same predicate the list uses. Doing it by hand was how the
two drifted apart in the first place.
Module level because the download view needs it too, and a file is only
ever as private as the narrowest thing that can hand it over.
"""
if is_org_admin(profile):
return True
return Document.objects.filter(pk=document.pk).filter(_visible_to(profile)).exists()
class DocumentListView(APIView, LimitOffsetPagination):
permission_classes = (IsAuthenticated, HasOrgContext)
model = Document
def get_context_data(self, **kwargs):
params = self.request.query_params
queryset = self.model.objects.filter(org=self.request.profile.org).order_by(
"-id"
)
if not (is_org_admin(self.request.profile)):
queryset = queryset.filter(_visible_to(self.request.profile)).distinct()
request_post = params
if request_post:
if request_post.get("title"):
queryset = queryset.filter(title__icontains=request_post.get("title"))
if request_post.get("status"):
queryset = queryset.filter(status=request_post.get("status"))
# `json.loads` on the raw value meant `?shared_to=<id>`, the
# obvious spelling, raised JSONDecodeError and answered 500. Only a
# JSON array ever worked, and a malformed id inside one answered
# 500 as well.
shared_to = payload_id_list(request_post.get("shared_to"), "shared_to")
if shared_to:
queryset = queryset.filter(shared_to__id__in=shared_to)
context = {}
profile_list = Profile.objects.filter(
is_active=True, org=self.request.profile.org
)
if is_org_admin(self.request.profile):
profiles = profile_list.order_by("user__email")
else:
profiles = profile_list.filter(role="ADMIN").order_by("user__email")
search = False
if (
params.get("document_file")
or params.get("status")
or params.get("shared_to")
):
search = True
context["search"] = search
queryset_documents_active = queryset.filter(status="active")
results_documents_active = self.paginate_queryset(
queryset_documents_active.distinct(), self.request, view=self
)
documents_active = DocumentSerializer(results_documents_active, many=True).data
if results_documents_active:
offset = queryset_documents_active.filter(
id__gte=results_documents_active[-1].id
).count()
if offset == queryset_documents_active.count():
offset = None
else:
offset = 0
context["documents_active"] = {
"documents_active_count": self.count,
"documents_active": documents_active,
"offset": offset,
}
queryset_documents_inactive = queryset.filter(status="inactive")
results_documents_inactive = self.paginate_queryset(
queryset_documents_inactive.distinct(), self.request, view=self
)
documents_inactive = DocumentSerializer(
results_documents_inactive, many=True
).data
if results_documents_inactive:
# This block computes the *inactive* envelope's offset, so it must
# key off the last inactive row, not `results_documents_active[-1]`,
# a copy-paste from the active block above. When the org has archived
# documents but no active ones (e.g. the only document was just
# archived), `results_documents_active` is empty and `[-1]` raised
# IndexError -> 500 on the whole list. No client rendered this
# endpoint before, so the crash sat unhit.
offset = queryset_documents_inactive.filter(
id__gte=results_documents_inactive[-1].id
).count()
if offset == queryset_documents_inactive.count():
offset = None
else:
offset = 0
context["documents_inactive"] = {
"documents_inactive_count": self.count,
"documents_inactive": documents_inactive,
"offset": offset,
}
context["users"] = ProfileSerializer(profiles, many=True).data
context["status_choices"] = Document.DOCUMENT_STATUS_CHOICE
return context
@extend_schema(
tags=["documents"],
operation_id="documents_list",
parameters=swagger_params.document_get_params,
responses={
200: inline_serializer(
name="DocumentListResponse",
fields={
"search": serializers.BooleanField(),
"documents_active": serializers.DictField(),
"documents_inactive": serializers.DictField(),
"users": ProfileSerializer(many=True),
"status_choices": serializers.ListField(),
},
)
},
)
def get(self, request, *args, **kwargs):
context = self.get_context_data(**kwargs)
return Response(context)
@extend_schema(
tags=["documents"],
operation_id="documents_create",
parameters=swagger_params.organization_params,
request=DocumentCreateSwaggerSerializer,
responses={
201: inline_serializer(
name="DocumentCreateResponse",
fields={
"error": serializers.BooleanField(),
"message": serializers.CharField(),
},
)
},
)
def post(self, request, *args, **kwargs):
params = request.data
serializer = DocumentCreateSerializer(data=params, request_obj=request)
if serializer.is_valid():
doc = serializer.save(
created_by=request.profile.user,
org=request.profile.org,
document_file=request.FILES.get("document_file"),
)
if params.get("shared_to"):
assinged_to_list = params.get("shared_to")
assigned_ids = payload_id_list(assinged_to_list, "shared_to")
profiles = Profile.objects.filter(
id__in=assigned_ids, org=request.profile.org, is_active=True
)
if profiles:
doc.shared_to.add(*profiles)
if params.get("teams"):
teams_list = params.get("teams")
team_ids = payload_id_list(teams_list, "teams")
teams = Teams.objects.filter(id__in=team_ids, org=request.profile.org)
if teams:
doc.teams.add(*teams)
return Response(
{"error": False, "message": "Document Created Successfully"},
status=status.HTTP_201_CREATED,
)
return Response(
{"error": True, "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
class DocumentDetailView(APIView):
permission_classes = (IsAuthenticated, HasOrgContext)
def get_object(self, pk):
# Security fix: Filter by org to prevent cross-org enumeration
return get_object_or_404(Document, id=pk, org=self.request.profile.org)
def _may_read(self, document):
"""See `may_read_document`, which the download view shares."""
return may_read_document(self.request.profile, document)
def _may_delete(self, document):
"""Deleting is destructive and not covered by a share.
Being able to read a document does not imply being able to destroy it
for everyone else, so this stays narrower than `_may_read`: the person
who uploaded it, or an admin. `created_by` is a User FK. Comparing it
to a Profile is the bug that made this branch unreachable.
"""
if is_org_admin(self.request.profile):
return True
return document.created_by_id == self.request.profile.user_id
def _may_write(self, document):
"""Editing is as consequential as deleting, so it is gated the same way.
PUT and PATCH used to authorise on `_may_read`, which meant anyone a
document was merely *shared with*, or a member of a team it was shared
with, could overwrite its file, rename it, flip its status to
`inactive` (hiding it from the org), and, on PUT, wipe its entire
`shared_to`/`teams` list and lock everyone else out. A share hands
someone a copy to work with; it does not hand them the original to
rewrite. Replacing the bytes behind a title is at least as destructive
as deleting the row, so mutation stays exactly as narrow as
`_may_delete`: the uploader or an admin. Reading (`_may_read`) remains
broad on purpose (creator, share, team, admin), because seeing a
document and changing it for everyone are different privileges.
"""
return self._may_delete(document)
@staticmethod
def _forbidden():
return Response(
{
"error": True,
"errors": "You do not have Permission to perform this action",
},
status=status.HTTP_403_FORBIDDEN,
)
@extend_schema(
tags=["documents"],
operation_id="documents_retrieve",
parameters=swagger_params.organization_params,
responses={
200: inline_serializer(
name="DocumentDetailResponse",
fields={
"doc_obj": DocumentSerializer(),
"file_type_code": serializers.CharField(),
"users": ProfileSerializer(many=True),
},
)
},
)
def get(self, request, pk, format=None):
# get_object_or_404 now handles org filtering - returns 404 if not found/wrong org
self.object = self.get_object(pk)
if not self._may_read(self.object):
return self._forbidden()
profile_list = Profile.objects.filter(org=self.request.profile.org)
if is_org_admin(request.profile):
profiles = profile_list.order_by("user__email")
else:
profiles = profile_list.filter(role="ADMIN").order_by("user__email")
context = {}
context.update(
{
"doc_obj": DocumentSerializer(self.object).data,
"file_type_code": self.object.file_type()[1],
"users": ProfileSerializer(profiles, many=True).data,
}
)
return Response(context, status=status.HTTP_200_OK)
@extend_schema(
tags=["documents"],
operation_id="documents_destroy",
parameters=swagger_params.organization_params,
responses={
200: inline_serializer(
name="DocumentDeleteResponse",
fields={
"error": serializers.BooleanField(),
"message": serializers.CharField(),
},
)
},
)
def delete(self, request, pk, format=None):
# `get_object` already filters on org and raises 404 otherwise, so the
# falsy-document and org-mismatch branches that used to sit here were
# both unreachable.
document = self.get_object(pk)
if not self._may_delete(document):
return self._forbidden()
document.delete()
return Response(
{"error": False, "message": "Document deleted Successfully"},
status=status.HTTP_200_OK,
)
@extend_schema(
tags=["documents"],
operation_id="documents_update",
parameters=swagger_params.organization_params,
request=DocumentEditSwaggerSerializer,
responses={
200: inline_serializer(
name="DocumentUpdateResponse",
fields={
"error": serializers.BooleanField(),
"message": serializers.CharField(),
},
)
},
)
def put(self, request, pk, format=None):
self.object = self.get_object(pk)
params = request.data
if not self._may_write(self.object):
return self._forbidden()
serializer = DocumentCreateSerializer(
data=params, instance=self.object, request_obj=request, partial=True
)
if serializer.is_valid():
# Parsed before anything is written: these raise a 400 on a
# malformed list, which used to arrive after the rename was saved
# and the shares cleared, leaving half a write behind.
assigned_ids = payload_id_list(params.get("shared_to"), "shared_to")
team_ids = payload_id_list(params.get("teams"), "teams")
# The body is the full share list. A person is kept if they are
# active in this org, or if this document is ALREADY shared with
# them: sharing is picked from active people only, so without the
# second arm every change of sharing dropped each share to
# somebody deactivated since, resubmitted or not. That arm only
# matches ids the document already holds, so it cannot add a new
# inactive share, and both arms stay inside the caller's org.
profiles = Profile.objects.filter(
Q(is_active=True) | Q(id__in=self.object.shared_to.all()),
id__in=assigned_ids,
org=request.profile.org,
)
teams = Teams.objects.filter(id__in=team_ids, org=request.profile.org)
save_kwargs = {
"org": request.profile.org,
}
if request.FILES.get("document_file"):
save_kwargs["document_file"] = request.FILES.get("document_file")
if params.get("status"):
save_kwargs["status"] = params.get("status")
doc = serializer.save(**save_kwargs)
# `list()` runs the query before `set` removes anything, so the
# "already shared" arm above still sees the old shares.
doc.shared_to.set(list(profiles))
doc.teams.set(list(teams))
return Response(
{"error": False, "message": "Document Updated Successfully"},
status=status.HTTP_200_OK,
)
return Response(
{"error": True, "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
@extend_schema(
tags=["documents"],
parameters=swagger_params.organization_params,
request=DocumentEditSwaggerSerializer,
description="Partial Document Update",
responses={
200: inline_serializer(
name="DocumentPatchResponse",
fields={
"error": serializers.BooleanField(),
"message": serializers.CharField(),
},
)
},
)
def patch(self, request, pk, format=None):
"""Handle partial updates to a document."""
self.object = self.get_object(pk)
params = request.data
if not self._may_write(self.object):
return self._forbidden()
serializer = DocumentCreateSerializer(
data=params, instance=self.object, request_obj=request, partial=True
)
if serializer.is_valid():
serializer.save(
org=request.profile.org,
)
return Response(
{"error": False, "message": "Document Updated Successfully"},
status=status.HTTP_200_OK,
)
return Response(
{"error": True, "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
class DocumentDownloadView(APIView):
"""Stream a document's file to somebody allowed to read the record.
Until this existed there was no authenticated way to reach the bytes at
all, so both clients listed a filename, a size and nothing to click. The
only other route to the file is its `/media/` path, and that path is
guarded by `RLSContextMiddleware` alone, which asks for *an* org context
rather than *the* org: it turns any signed-in user of any tenant into a
reader of every uploaded file in the system. Handing a client a media URL
is therefore not a smaller version of this view, it is the hole.
The gate is `may_read_document`, the same predicate the detail view and
the list use, so a document you cannot open is a document you cannot
download. Deliberately *not* `_may_write`: reading is broad here on
purpose (creator, share, team, admin) and a share exists to be read.
"""
permission_classes = (IsAuthenticated, HasOrgContext)
@extend_schema(
tags=["documents"],
operation_id="documents_download",
parameters=swagger_params.organization_params,
responses={(200, "application/octet-stream"): OpenApiTypes.BINARY},
)
def get(self, request, pk, format=None):
document = get_object_or_404(Document, id=pk, org=request.profile.org)
if not may_read_document(request.profile, document):
return Response(
{
"error": True,
"errors": "You do not have Permission to perform this action",
},
status=status.HTTP_403_FORBIDDEN,
)
if not document.document_file:
raise Http404("That document has no file.")
document.document_file.open("rb")
return FileResponse(
document.document_file,
as_attachment=True,
# `title` is the human name and the only one either client shows.
# The stored name is a timestamped upload path nobody chose.
filename=document.title or "document",
)