Coverage for backend/django/core/auxiliary/viewsets/ProjectViewSet.py: 93%

245 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-07-22 05:22 +0000

1from rest_framework import serializers, status, viewsets 

2from rest_framework.exceptions import ValidationError 

3from rest_framework.response import Response 

4from rest_framework.decorators import action 

5 

6from django.db.models import F, QuerySet, Prefetch 

7from django.db.models.functions import Lower 

8 

9from CoreRoot import settings 

10from authentication.user.AccessTable import AccessTable 

11from authentication.user.models import User 

12from core.auxiliary.enums.FlowsheetTemplateType import FlowsheetTemplateType 

13from core.auxiliary.models.Project import Project 

14from core.auxiliary.models.ProjectFolder import ProjectFolder 

15from core.auxiliary.models.Flowsheet import Flowsheet 

16from core.auxiliary.serializers.ProjectSerializer import ProjectDetailSerializer, ProjectSerializer 

17from core.auxiliary.serializers.FlowsheetCloneValidationSerializer import ( 

18 CloneRequestSerializer, 

19 FlowsheetCloneValidationErrorSerializer, 

20 ScenarioResultCopySummarySerializer, 

21) 

22from core.auxiliary.services.flowsheet_states import ( 

23 FlowsheetStateCloneError, 

24 clone_validation_error_data, 

25 copy_project_data, 

26 summarize_scenario_results, 

27) 

28from core.managers import has_flowsheet_write_access, project_access_filter 

29import core.auxiliary.enums.ViewType as ViewType 

30from core.pagination import ClampedViewSetPagination 

31from core.viewset import HistoricalRevisionWriteGuardMixin 

32 

33from drf_spectacular.utils import extend_schema, OpenApiParameter 

34from drf_spectacular.types import OpenApiTypes 

35 

36 

37class ProjectListPagination(ClampedViewSetPagination): 

38 """Name the project-list pagination policy for generated API schemas.""" 

39 

40 

41class ProjectSharingSerializer(serializers.Serializer): 

42 user_email = serializers.EmailField() 

43 read_only = serializers.BooleanField(default=False) 

44 

45 

46class RemoveSharedProjectUserSerializer(serializers.Serializer): 

47 user_email = serializers.EmailField() 

48 

49 

50class UpdateSharedProjectUserAccessSerializer(serializers.Serializer): 

51 user_email = serializers.EmailField() 

52 read_only = serializers.BooleanField() 

53 

54 

55class SharedProjectUserSerializer(serializers.Serializer): 

56 email = serializers.EmailField() 

57 read_only = serializers.BooleanField() 

58 

59 

60class ListSharedProjectUsersSerializer(serializers.Serializer): 

61 users = SharedProjectUserSerializer(many=True) 

62 

63 

64class ProjectViewSet(HistoricalRevisionWriteGuardMixin, viewsets.ModelViewSet): 

65 serializer_class = ProjectSerializer 

66 lookup_value_regex = r"\d+" 

67 pagination_class = ProjectListPagination 

68 

69 PROJECT_LIST_ORDERING = { 

70 "recently_added": (F("created_at").desc(nulls_last=True), "-id"), 

71 "recently_binned": (F("binned_at").desc(nulls_last=True), "-id"), 

72 "recently_edited": ( 

73 F("active_flowsheet__savedDate").desc(nulls_last=True), 

74 "-id", 

75 ), 

76 "name": (Lower("name").asc(), "id"), 

77 } 

78 

79 def get_serializer_class(self): 

80 """ 

81 Use lightweight serializer for listing projects. 

82 Detailed serializer for retreiving a single project so it inlcudes all flowsheet configurations (to display in the frontend sidebar). 

83 """ 

84 if self.action == "retrieve": 

85 return ProjectDetailSerializer 

86 return ProjectSerializer 

87 

88 def _base_queryset(self) -> QuerySet[Project]: 

89 return Project.objects.select_related("owner","active_flowsheet") 

90 

91 def _detail_queryset(self, user) -> QuerySet[Project]: 

92 return ( 

93 self._all_visible_queryset(user).prefetch_related( 

94 Prefetch( 

95 "flowsheets", 

96 queryset=( 

97 Flowsheet.objects 

98 .select_related("owner") 

99 .order_by("created_at", "id") 

100 ), 

101 to_attr="prefetched_configurations" 

102 ) 

103 ) 

104 ) 

105 

106 def _owned_queryset(self, user) -> QuerySet[Project]: 

107 """Projects owned by the user""" 

108 return self._base_queryset().filter(owner=user) 

109 

110 def _shared_queryset(self, user) -> QuerySet[Project]: 

111 """Projects shared with the user""" 

112 return ( 

113 self._base_queryset() 

114 .filter(access_list__user=user,) 

115 .exclude(owner=user) 

116 .distinct() 

117 ) 

118 

119 def _all_visible_queryset(self, user) -> QuerySet[Project]: 

120 """All projects visible to the user, including owned and shared""" 

121 return ( 

122 self._base_queryset() 

123 .filter(project_access_filter(user, project_path=None)) 

124 .distinct() 

125 ) 

126 

127 def _project_list_queryset(self, queryset: QuerySet[Project]) -> QuerySet[Project]: 

128 """Return only projects whose active flowsheet is a regular editable flowsheet.""" 

129 

130 return queryset.filter( 

131 active_flowsheet__flowsheet_template_type=FlowsheetTemplateType.NotTemplate, 

132 ) 

133 

134 def get_queryset(self) -> QuerySet[Project]: 

135 return self._owned_queryset(self.request.user) 

136 

137 def _get_owned_project(self, user, pk): 

138 return self._owned_queryset(user).get(pk=pk) 

139 

140 def _validate_project_share(self, owner, project, sharee): 

141 if sharee.email == owner.email: 

142 raise ValueError("You cannot share a project with yourself.") 

143 

144 if sharee.email == settings.PLATFORM_TEST_EMAIL and not owner.is_tester: 

145 raise PermissionError("Only testers can share with the platform test account.") 

146 

147 if AccessTable.objects.filter(user=sharee, project=project).exists(): 

148 raise ValueError("This user already has access to the project.") 

149 

150 def _shared_project_users(self, project): 

151 return ( 

152 AccessTable.objects 

153 .filter(project=project) 

154 .exclude(user=project.owner) 

155 .values("user__email", "read_only") 

156 .order_by("user__email") 

157 ) 

158 

159 @extend_schema( 

160 parameters=[ 

161 OpenApiParameter(name="type", required=True, type=OpenApiTypes.STR), 

162 OpenApiParameter( 

163 name="folder", 

164 required=False, 

165 type=OpenApiTypes.STR, 

166 description="Owned-project folder ID, or 'root' for unfiled projects.", 

167 ), 

168 OpenApiParameter(name="is_starred", required=False, type=OpenApiTypes.BOOL), 

169 OpenApiParameter(name="is_binned", required=False, type=OpenApiTypes.BOOL), 

170 OpenApiParameter( 

171 name="ordering", 

172 required=False, 

173 type=OpenApiTypes.STR, 

174 enum=list(PROJECT_LIST_ORDERING), 

175 ), 

176 OpenApiParameter(name="page", required=False, type=OpenApiTypes.INT), 

177 ] 

178 ) 

179 def list(self, request, *args, **kwargs): 

180 """Return one filtered and deterministically ordered project page.""" 

181 

182 view_type = request.query_params.get("type", ViewType.OWNED) 

183 

184 if view_type == ViewType.SHARED: 

185 queryset = self._shared_queryset(request.user) 

186 elif view_type == ViewType.ALL: 

187 queryset = self._all_visible_queryset(request.user) 

188 else: 

189 queryset = self._owned_queryset(request.user) 

190 

191 queryset = self._project_list_queryset(queryset) 

192 queryset = self._filter_project_list(queryset, view_type=view_type) 

193 ordering = request.query_params.get("ordering", "recently_edited") 

194 try: 

195 ordering_fields = self.PROJECT_LIST_ORDERING[ordering] 

196 except KeyError as exc: 

197 raise ValidationError({"ordering": "Unknown project ordering."}) from exc 

198 queryset = queryset.order_by(*ordering_fields) 

199 

200 page = self.paginate_queryset(queryset) 

201 serializer = self.get_serializer(page, many=True) 

202 return self.get_paginated_response(serializer.data) 

203 

204 def _filter_project_list( 

205 self, 

206 queryset: QuerySet[Project], 

207 *, 

208 view_type: str, 

209 ) -> QuerySet[Project]: 

210 """Apply explicit list filters before pagination.""" 

211 

212 folder = self.request.query_params.get("folder") 

213 if folder is not None: 

214 if view_type != ViewType.OWNED: 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true

215 raise ValidationError( 

216 {"folder": "Folder filtering is only available for owned projects."} 

217 ) 

218 if folder == "root": 

219 queryset = queryset.filter(folder__isnull=True) 

220 else: 

221 try: 

222 folder_id = int(folder) 

223 except ValueError as exc: 

224 raise ValidationError({"folder": "Use a folder ID or 'root'."}) from exc 

225 if not ProjectFolder.objects.filter( 

226 pk=folder_id, 

227 owner=self.request.user, 

228 is_binned=False, 

229 ).exists(): 

230 raise ValidationError({"folder": "Project folder not found."}) 

231 queryset = queryset.filter(folder_id=folder_id) 

232 

233 for parameter in ("is_starred", "is_binned"): 

234 raw_value = self.request.query_params.get(parameter) 

235 if raw_value is None: 

236 continue 

237 normalized_value = raw_value.casefold() 

238 if normalized_value not in {"true", "false"}: 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true

239 raise ValidationError({parameter: "Use true or false."}) 

240 queryset = queryset.filter(**{parameter: normalized_value == "true"}) 

241 return queryset 

242 

243 def retrieve(self, request, *args, **kwargs): 

244 try: 

245 instance = self._detail_queryset(request.user).get(pk=kwargs["pk"]) 

246 except Project.DoesNotExist: 

247 return Response({"error": "Project not found."}, status=status.HTTP_404_NOT_FOUND) 

248 

249 serializer = self.get_serializer(instance) 

250 return Response(serializer.data) 

251 

252 def create(self, request, *args, **kwargs): 

253 serializer = self.get_serializer(data=request.data, context={"request": request}) 

254 serializer.is_valid(raise_exception=True) 

255 serializer.save() 

256 

257 return Response(serializer.data, status=status.HTTP_201_CREATED) 

258 

259 def partial_update(self, request, *args, **kwargs): 

260 """ 

261 Owners use the standard serializer update path.  

262 Non-owners are routed to a restricted shared-project path so menu-bar rename can work without changing owner-only main-page project actions. 

263 """ 

264 pk = kwargs["pk"] 

265 

266 try: 

267 project = self._owned_queryset(request.user).get(pk=pk) 

268 except Project.DoesNotExist: 

269 return self._partial_update_shared_project(request, pk) 

270 

271 serializer = self.get_serializer(project, data=request.data, partial=True) 

272 serializer.is_valid(raise_exception=True) 

273 serializer.save() 

274 return Response(serializer.data) 

275 

276 def _partial_update_shared_project(self, request, pk): 

277 """ 

278 Shared users with write access may only patch the project name. This supports menu-bar renaming while keeping fields like starred/binned owner-controlled. 

279 """ 

280 try: 

281 project = self._all_visible_queryset(request.user).get(pk=pk) 

282 except Project.DoesNotExist: 

283 return Response({"error": "Project not found."}, status=status.HTTP_404_NOT_FOUND) 

284 

285 access = AccessTable.objects.filter(user=request.user, project=project).first() 

286 if access is None or access.read_only: 

287 return Response( 

288 {"error": "You do not have permission to update this project."}, 

289 status=status.HTTP_403_FORBIDDEN, 

290 ) 

291 

292 if set(request.data.keys()) != {"name"}: 

293 return Response( 

294 {"error": "Shared users with write access can only update the project name."}, 

295 status=status.HTTP_403_FORBIDDEN, 

296 ) 

297 

298 serializer = self.get_serializer(project, data={"name": request.data["name"]}, partial=True) 

299 serializer.is_valid(raise_exception=True) 

300 serializer.save() 

301 return Response(serializer.data) 

302 

303 @extend_schema(request=ProjectSharingSerializer, responses=None) 

304 @action(detail=True, methods=["post"], url_path="share") 

305 def share_project(self, request, pk=None): 

306 serializer = ProjectSharingSerializer(data=request.data) 

307 serializer.is_valid(raise_exception=True) 

308 user_email = serializer.validated_data["user_email"] 

309 read_only = serializer.validated_data["read_only"] 

310 

311 try: 

312 project = self._get_owned_project(request.user, pk) 

313 sharee = User.objects.get(email=user_email) 

314 

315 self._validate_project_share(request.user, project, sharee) 

316 AccessTable.objects.create(user=sharee, project=project, read_only=read_only) 

317 return Response({"message": "Project shared successfully"}, status=status.HTTP_200_OK) 

318 except ValueError as exc: 

319 return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST) 

320 except PermissionError as exc: 

321 return Response({"error": str(exc)}, status=status.HTTP_403_FORBIDDEN) 

322 except (Project.DoesNotExist, User.DoesNotExist): 

323 return Response({"error": "Failed to share project"}, status=status.HTTP_404_NOT_FOUND) 

324 

325 @extend_schema(responses=ListSharedProjectUsersSerializer) 

326 @action(detail=True, methods=["get"], url_path="shared-users") 

327 def list_shared_users(self, request, pk=None): 

328 try: 

329 project = self._get_owned_project(request.user, pk) 

330 except Project.DoesNotExist: 

331 return Response({"error": "Failed to retrieve list of user"}, status=status.HTTP_404_NOT_FOUND) 

332 

333 serializer = ListSharedProjectUsersSerializer({ 

334 "users": [ 

335 {"email": row["user__email"], "read_only": row["read_only"]} 

336 for row in self._shared_project_users(project) 

337 ] 

338 }) 

339 return Response(serializer.data, status=status.HTTP_200_OK) 

340 

341 @extend_schema(request=RemoveSharedProjectUserSerializer, responses=None) 

342 @action(detail=True, methods=["post"], url_path="remove-user") 

343 def remove_user(self, request, pk=None): 

344 serializer = RemoveSharedProjectUserSerializer(data=request.data) 

345 serializer.is_valid(raise_exception=True) 

346 user_email = serializer.validated_data["user_email"] 

347 

348 try: 

349 project = self._get_owned_project(request.user, pk) 

350 user = User.objects.get(email=user_email) 

351 

352 if user.id == project.owner_id: 352 ↛ 353line 352 didn't jump to line 353 because the condition on line 352 was never true

353 raise ValueError("Cannot remove the owner of the project.") 

354 

355 AccessTable.objects.get(user=user, project=project).delete() 

356 return Response({"message": "User removed successfully"}, status=status.HTTP_200_OK) 

357 except ValueError as exc: 

358 return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST) 

359 except (Project.DoesNotExist, User.DoesNotExist, AccessTable.DoesNotExist): 

360 return Response({"error": "Failed to remove user"}, status=status.HTTP_404_NOT_FOUND) 

361 

362 @extend_schema(request=UpdateSharedProjectUserAccessSerializer, responses=None) 

363 @action(detail=True, methods=["post"], url_path="update-shared-user-access") 

364 def update_shared_user_access(self, request, pk=None): 

365 serializer = UpdateSharedProjectUserAccessSerializer(data=request.data) 

366 serializer.is_valid(raise_exception=True) 

367 user_email = serializer.validated_data["user_email"] 

368 read_only = serializer.validated_data["read_only"] 

369 

370 try: 

371 project = self._get_owned_project(request.user, pk) 

372 user = User.objects.get(email=user_email) 

373 

374 if user.id == project.owner_id: 

375 raise ValueError("Cannot modify the owner's access.") 

376 

377 access_row = AccessTable.objects.get(user=user, project=project) 

378 access_row.read_only = read_only 

379 access_row.save(update_fields=["read_only"]) 

380 

381 return Response({"message": "Shared user access updated successfully"}, status=status.HTTP_200_OK) 

382 except ValueError as exc: 

383 return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST) 

384 except (Project.DoesNotExist, User.DoesNotExist, AccessTable.DoesNotExist): 

385 return Response({"error": "Failed to update shared user access"}, status=status.HTTP_404_NOT_FOUND) 

386 

387 @extend_schema( 

388 responses={200: ScenarioResultCopySummarySerializer}, 

389 description="Count scenario results available for a full project duplicate.", 

390 ) 

391 @action(detail=True, methods=["get"], url_path="copy-preview") 

392 def copy_preview(self, request, pk=None): 

393 """Return counts across the non-template flowsheets copied with a project.""" 

394 

395 try: 

396 source_project = self._all_visible_queryset(request.user).get(pk=pk) 

397 except Project.DoesNotExist: 

398 return Response( 

399 {"error": "Project not found."}, 

400 status=status.HTTP_404_NOT_FOUND, 

401 ) 

402 state_ids = source_project.flowsheets.filter( 

403 flowsheet_template_type=FlowsheetTemplateType.NotTemplate, 

404 ).values_list("current_state_id", flat=True) 

405 summary = summarize_scenario_results(flowsheet_state_ids=state_ids) 

406 return Response(ScenarioResultCopySummarySerializer(summary).data) 

407 

408 @extend_schema( 

409 request=CloneRequestSerializer, 

410 responses={ 

411 201: ProjectSerializer, 

412 422: FlowsheetCloneValidationErrorSerializer, 

413 }, 

414 ) 

415 @action(detail=True, methods=["post"], url_path="copy") 

416 def copy(self, request, pk=None): 

417 input_serializer = CloneRequestSerializer(data=request.data) 

418 input_serializer.is_valid(raise_exception=True) 

419 repair_legacy_formula_units = input_serializer.validated_data[ 

420 "repair_legacy_formula_units" 

421 ] 

422 include_scenario_results = input_serializer.validated_data[ 

423 "include_scenario_results" 

424 ] 

425 try: 

426 source_project = self._all_visible_queryset(request.user).get(pk=pk) 

427 except Project.DoesNotExist: 

428 return Response( 

429 {"error: Project not found."}, 

430 status=status.HTTP_404_NOT_FOUND, 

431 ) 

432 

433 if repair_legacy_formula_units: 

434 repairable_flowsheet_ids = source_project.flowsheets.filter( 

435 flowsheet_template_type=FlowsheetTemplateType.NotTemplate, 

436 ).values_list("pk", flat=True) 

437 if any( 437 ↛ 441line 437 didn't jump to line 441 because the condition on line 437 was never true

438 not has_flowsheet_write_access(request.user, flowsheet_id) 

439 for flowsheet_id in repairable_flowsheet_ids 

440 ): 

441 return Response( 

442 {"error": "You do not have write access to repair this project"}, 

443 status=status.HTTP_403_FORBIDDEN, 

444 ) 

445 

446 try: 

447 copied_project = copy_project_data( 

448 source_project=source_project, 

449 user=request.user, 

450 repair_legacy_formula_units=repair_legacy_formula_units, 

451 include_scenario_results=include_scenario_results, 

452 ) 

453 except FlowsheetStateCloneError as exc: 

454 if not exc.is_user_repairable: 454 ↛ 455line 454 didn't jump to line 455 because the condition on line 454 was never true

455 raise 

456 return Response( 

457 clone_validation_error_data(exc), 

458 status=status.HTTP_422_UNPROCESSABLE_ENTITY, 

459 ) 

460 

461 return Response( 

462 self.get_serializer(copied_project).data, 

463 status=status.HTTP_201_CREATED, 

464 )