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

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 

8 

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 

28 

29 

30class ProjectFolderPagination(ClampedViewSetPagination): 

31 """Keep folder gallery and destination responses predictably bounded.""" 

32 

33 page_size = 50 

34 

35 

36class ProjectFolderDestinationPagination(ProjectFolderPagination): 

37 """Reserve one of the 50 rendered menu rows for the root destination.""" 

38 

39 page_size = 49 

40 

41 

42class ProjectFolderViewSet( 

43 HistoricalRevisionWriteGuardMixin, 

44 mixins.CreateModelMixin, 

45 mixins.UpdateModelMixin, 

46 viewsets.GenericViewSet, 

47): 

48 """Expose only the intentionally designed relational folder operations.""" 

49 

50 serializer_class = ProjectFolderSerializer 

51 lookup_value_regex = r"\d+" 

52 pagination_class = ProjectFolderPagination 

53 

54 def get_queryset(self): 

55 """Scope every resource lookup to active folders owned by the caller.""" 

56 

57 return ProjectFolder.objects.filter( 

58 owner=self.request.user, 

59 is_binned=False, 

60 ) 

61 

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.""" 

82 

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 ) 

105 

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 ) 

133 

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.""" 

149 

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 ) 

165 

166 def _serialize_destination_page(self, folders, *, first_position: int): 

167 """Resolve at most two ancestors for each destination in three queries.""" 

168 

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 } 

181 

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 

198 

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.""" 

213 

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)