Coverage for backend/django/core/auxiliary/viewsets/ProjectFolderViewSet.py: 97%
87 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
1from drf_spectacular.types import OpenApiTypes
2from drf_spectacular.utils import OpenApiParameter, extend_schema
3from django.db.models.functions import Lower
4from rest_framework import mixins, status, viewsets
5from rest_framework.decorators import action
6from rest_framework.exceptions import NotFound
7from rest_framework.response import Response
9from core.auxiliary.models.ProjectFolder import ProjectFolder
10from core.auxiliary.serializers.ProjectFolderSerializer import (
11 ProjectFolderDestinationPageSerializer,
12 ProjectFolderDestinationSerializer,
13 ProjectFolderNavigationSerializer,
14 ProjectFolderSerializer,
15 ProjectFolderWorkspaceSerializer,
16)
17from core.auxiliary.services.project_folders import (
18 BinProjectFolderResult,
19 ProjectFolderNotFoundError,
20 bin_project_folder,
21)
22from core.auxiliary.services.project_folder_queries import (
23 annotate_project_folder_subtree_counts,
24 list_active_project_folder_ancestors,
25)
26from core.pagination import ClampedViewSetPagination
27from core.viewset import HistoricalRevisionWriteGuardMixin
30class ProjectFolderPagination(ClampedViewSetPagination):
31 """Keep folder gallery and destination responses predictably bounded."""
33 page_size = 50
36class ProjectFolderDestinationPagination(ProjectFolderPagination):
37 """Reserve one of the 50 rendered menu rows for the root destination."""
39 page_size = 49
42class ProjectFolderViewSet(
43 HistoricalRevisionWriteGuardMixin,
44 mixins.CreateModelMixin,
45 mixins.UpdateModelMixin,
46 viewsets.GenericViewSet,
47):
48 """Expose only the intentionally designed relational folder operations."""
50 serializer_class = ProjectFolderSerializer
51 lookup_value_regex = r"\d+"
52 pagination_class = ProjectFolderPagination
54 def get_queryset(self):
55 """Scope every resource lookup to active folders owned by the caller."""
57 return ProjectFolder.objects.filter(
58 owner=self.request.user,
59 is_binned=False,
60 )
62 @extend_schema(
63 responses=ProjectFolderWorkspaceSerializer,
64 parameters=[
65 OpenApiParameter(
66 name="parent",
67 required=False,
68 type=OpenApiTypes.STR,
69 description="Current folder ID; omit or use 'root' for My Projects.",
70 ),
71 OpenApiParameter(name="page", required=False, type=OpenApiTypes.INT),
72 ],
73 )
74 @action(
75 detail=False,
76 methods=["get"],
77 url_path="workspace",
78 pagination_class=None,
79 )
80 def workspace(self, request):
81 """Return breadcrumbs and one bounded page of immediate children."""
83 raw_parent_id = request.query_params.get("parent", "root")
84 requested_folder_missing = False
85 if raw_parent_id == "root":
86 current_folder = None
87 current_folder_id = None
88 breadcrumbs = []
89 else:
90 try:
91 current_folder_id = int(raw_parent_id)
92 except ValueError:
93 current_folder_id = None
94 requested_folder_missing = True
95 current_folder = self.get_queryset().filter(pk=current_folder_id).first()
96 if current_folder is None:
97 current_folder_id = None
98 requested_folder_missing = True
99 breadcrumbs = []
100 else:
101 breadcrumbs = list_active_project_folder_ancestors(
102 folder=current_folder,
103 user=request.user,
104 )
106 paginator = ProjectFolderPagination()
107 page = paginator.paginate_queryset(
108 self.get_queryset()
109 .filter(parent_id=current_folder_id)
110 .order_by(Lower("name"), "id"),
111 request,
112 view=self,
113 )
114 annotate_project_folder_subtree_counts(folders=page, user=request.user)
115 folder_page = paginator.get_paginated_response(
116 self.get_serializer(page, many=True).data
117 ).data
118 return Response(
119 {
120 "current_folder": (
121 ProjectFolderNavigationSerializer(current_folder).data
122 if current_folder is not None
123 else None
124 ),
125 "breadcrumbs": ProjectFolderNavigationSerializer(
126 breadcrumbs,
127 many=True,
128 ).data,
129 "folders": folder_page,
130 "requested_folder_missing": requested_folder_missing,
131 }
132 )
134 @extend_schema(
135 responses=ProjectFolderDestinationPageSerializer,
136 parameters=[
137 OpenApiParameter(name="q", required=False, type=OpenApiTypes.STR),
138 OpenApiParameter(name="page", required=False, type=OpenApiTypes.INT),
139 ],
140 )
141 @action(
142 detail=False,
143 methods=["get"],
144 url_path="destinations",
145 pagination_class=None,
146 )
147 def destinations(self, request):
148 """Search move destinations and resolve only a bounded ancestry tail."""
150 query = request.query_params.get("q", "").strip()
151 queryset = self.get_queryset().order_by(Lower("name"), "id")
152 if query: 152 ↛ 154line 152 didn't jump to line 154 because the condition on line 152 was always true
153 queryset = queryset.filter(name__icontains=query)
154 paginator = ProjectFolderDestinationPagination()
155 page = paginator.paginate_queryset(queryset, request, view=self)
156 destination_data = self._serialize_destination_page(
157 page,
158 first_position=paginator.page.start_index(),
159 )
160 paginated_data = paginator.get_paginated_response(destination_data).data
161 paginated_data["root_matches"] = not query or query.casefold() in "my projects"
162 return Response(
163 ProjectFolderDestinationPageSerializer(paginated_data).data
164 )
166 def _serialize_destination_page(self, folders, *, first_position: int):
167 """Resolve at most two ancestors for each destination in three queries."""
169 folders_by_id = {folder.pk: folder for folder in folders}
170 frontier = {folder.parent_id for folder in folders if folder.parent_id}
171 for _ in range(2):
172 if not frontier: 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true
173 break
174 parents = list(
175 self.get_queryset().filter(pk__in=frontier).only("id", "name", "parent_id")
176 )
177 folders_by_id.update({folder.pk: folder for folder in parents})
178 frontier = {
179 folder.parent_id for folder in parents if folder.parent_id is not None
180 }
182 destinations = []
183 for offset, folder in enumerate(folders):
184 path_folders = []
185 cursor = folder
186 while cursor is not None and len(path_folders) < 3:
187 path_folders.append(cursor)
188 cursor = folders_by_id.get(cursor.parent_id)
189 destinations.append(
190 {
191 "id": folder.pk,
192 "path": [item.name for item in reversed(path_folders)],
193 "has_omitted_ancestors": path_folders[-1].parent_id is not None,
194 "result_position": first_position + offset,
195 }
196 )
197 return ProjectFolderDestinationSerializer(destinations, many=True).data
199 @extend_schema(
200 request=None,
201 responses=BinProjectFolderResult,
202 parameters=[
203 OpenApiParameter(
204 name="id",
205 type=OpenApiTypes.INT,
206 location=OpenApiParameter.PATH,
207 )
208 ],
209 )
210 @action(detail=True, methods=["post"], url_path="bin")
211 def bin(self, request, pk=None):
212 """Soft-bin one owned folder subtree by stable folder ID."""
214 try:
215 result = bin_project_folder(user=request.user, folder_id=int(pk))
216 except ProjectFolderNotFoundError as exc:
217 raise NotFound(str(exc)) from exc
218 return Response(result.model_dump(mode="json"), status=status.HTTP_200_OK)