Coverage for backend/django/core/auxiliary/viewsets/FlowsheetViewSet.py: 88%

275 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.response import Response 

3from django.core.paginator import Paginator 

4from django.db.models import Count, F, Prefetch, Q, QuerySet, Window 

5from django.db.models.functions import RowNumber 

6from core.auxiliary.models.Flowsheet import Flowsheet 

7from core.auxiliary.models.Project import Project 

8from core.auxiliary.enums.FlowsheetTemplateType import FlowsheetTemplateType 

9from core.auxiliary.serializers.FlowsheetSerializer import FlowsheetSerializer 

10from core.auxiliary.serializers.FlowsheetRevisionSerializer import ( 

11 CreateFlowsheetRevisionSerializer, 

12 CurrentFlowsheetStateSerializer, 

13 FlowsheetRevisionListSerializer, 

14 FlowsheetRevisionSerializer, 

15 ListFlowsheetRevisionsQuerySerializer, 

16 RestoreFlowsheetRevisionResponseSerializer, 

17 UpdateFlowsheetRevisionSerializer, 

18) 

19from core.auxiliary.serializers.FlowsheetCloneValidationSerializer import ( 

20 FlowsheetCloneValidationErrorSerializer, 

21) 

22from drf_spectacular.utils import extend_schema 

23from rest_framework.decorators import action 

24from drf_spectacular.types import OpenApiTypes 

25from drf_spectacular.utils import OpenApiParameter 

26from authentication.user.AccessTable import AccessTable 

27from flowsheetInternals.graphicData.models.graphicObjectModel import GraphicObject 

28from flowsheetInternals.graphicData.models.groupingModel import Grouping 

29from flowsheetInternals.unitops.models.Port import Port 

30import core.auxiliary.enums.ViewType as ViewType 

31from django.db import transaction 

32from core.managers import has_flowsheet_write_access, project_access_filter 

33from core.auxiliary.models.FlowsheetState import ( 

34 FlowsheetRevisionKind, 

35 FlowsheetState, 

36 FlowsheetStateRole, 

37) 

38from core.auxiliary.services.flowsheet_states import ( 

39 ACTIVE_TASKS_ERROR_CODE, 

40 ActiveTasksError, 

41 FlowsheetStateCloneError, 

42 RevisionOperationError, 

43 clone_validation_error_data, 

44 create_revision, 

45 delete_revision as delete_flowsheet_revision, 

46 has_active_tasks, 

47 list_revisions, 

48 rename_revision, 

49 restore_revision as restore_flowsheet_revision, 

50) 

51from core.auxiliary.services.flowsheet_template_transitions import ( 

52 FlowsheetNotRegularError, 

53 delete_regular_flowsheet, 

54) 

55from core.viewset import HistoricalRevisionWriteGuardMixin 

56 

57MAX_CARD_PREVIEW_OBJECTS = 80 

58REVISION_PAGE_SIZE = 10 

59CARD_PREVIEW_CONNECTION_FIELDS = { 

60 "unitOp", 

61 "stream", 

62 "port", 

63 "direction", 

64 "anchor_side", 

65 "anchor_index", 

66} 

67 

68 

69class ImportFlowsheetSerializer(serializers.Serializer): 

70 data = serializers.JSONField() 

71 

72 

73class CardPreviewSimulationObjectSerializer(serializers.Serializer): 

74 id = serializers.IntegerField() 

75 componentName = serializers.CharField() 

76 objectType = serializers.CharField() 

77 

78 

79class CardPreviewGraphicObjectSerializer(serializers.Serializer): 

80 id = serializers.IntegerField() 

81 simulationObject = CardPreviewSimulationObjectSerializer() 

82 is_flipped = serializers.BooleanField() 

83 visible = serializers.BooleanField() 

84 x = serializers.FloatField() 

85 y = serializers.FloatField() 

86 height = serializers.FloatField() 

87 width = serializers.FloatField() 

88 rotation = serializers.IntegerField() 

89 flipped = serializers.BooleanField() 

90 flowsheet = serializers.IntegerField() 

91 group = serializers.IntegerField(allow_null=True) 

92 

93 

94class CardPreviewConnectionSerializer(serializers.Serializer): 

95 unitOp = serializers.IntegerField() 

96 stream = serializers.IntegerField() 

97 port = serializers.IntegerField() 

98 direction = serializers.CharField() 

99 anchor_side = serializers.CharField(allow_null=True, required=False) 

100 anchor_index = serializers.IntegerField(allow_null=True, required=False) 

101 

102 

103class CardPreviewSerializer(serializers.Serializer): 

104 flowsheet = serializers.IntegerField() 

105 graphicsObjects = CardPreviewGraphicObjectSerializer(many=True) 

106 connections = CardPreviewConnectionSerializer(many=True) 

107 

108 

109class CardPreviewsSerializer(serializers.Serializer): 

110 previews = CardPreviewSerializer(many=True) 

111 

112 

113class DestroyFlowsheetResponseSerializer(serializers.Serializer): 

114 active_flowsheet_id = serializers.IntegerField() 

115 

116 

117class FlowsheetViewSet(HistoricalRevisionWriteGuardMixin, viewsets.ModelViewSet): 

118 """ 

119 Viewset for the Flowsheet model itself. 

120 

121 Flowsheet does not use AccessControlManager, so its access rules stay here 

122 and in the serializer rather than in the generic flowsheet-scoped manager. 

123 """ 

124 serializer_class = FlowsheetSerializer 

125 

126 @staticmethod 

127 def _with_current_user_access(queryset: QuerySet[Flowsheet], user) -> QuerySet[Flowsheet]: 

128 return queryset.prefetch_related( 

129 Prefetch( 

130 "project__access_list", 

131 queryset=AccessTable.objects.filter(user=user), 

132 to_attr="current_user_access_entries" 

133 ) 

134 ) 

135 

136 def _accessible_queryset(self, user) -> QuerySet[Flowsheet]: 

137 """Return accessible regular flowsheets in stable creation order.""" 

138 return ( 

139 Flowsheet.objects 

140 .select_related("owner", "project", "current_state") 

141 .filter(flowsheet_template_type=FlowsheetTemplateType.NotTemplate) 

142 .filter(project_access_filter(user)) 

143 .distinct() 

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

145 ) 

146 

147 @staticmethod 

148 def _with_economics_study_count(queryset: QuerySet[Flowsheet]) -> QuerySet[Flowsheet]: 

149 return queryset.annotate( 

150 economics_study_count=Count("current_state__economics_studies", distinct=True) 

151 ) 

152 

153 @staticmethod 

154 def _filter_to_current_user_preview_access(queryset: QuerySet[Flowsheet], user) -> QuerySet[Flowsheet]: 

155 """ 

156 Restrict preview payloads to flowsheets the user can inspect. 

157 

158 ``with_user_access`` only prefetches the current user's AccessTable rows 

159 for serializers; it intentionally does not filter the queryset. 

160 """ 

161 if user is None or not user.is_authenticated: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true

162 return queryset.none() 

163 

164 return queryset.filter( 

165 project_access_filter(user) 

166 | Q(project__isnull=True, owner=user) 

167 | Q(flowsheet_template_type=FlowsheetTemplateType.PublicTemplate) 

168 ).distinct() 

169 

170 def get_queryset(self) -> QuerySet[Flowsheet]: 

171 current_user = self.request.user 

172 queryset = self._with_economics_study_count( 

173 self._accessible_queryset(current_user) 

174 ) 

175 return self._with_current_user_access( 

176 queryset, 

177 current_user, 

178 ) 

179 

180 @extend_schema( 

181 parameters=[ 

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

183 ] 

184 ) 

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

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

187 queryset = self._accessible_queryset(request.user) 

188 

189 if type == ViewType.OWNED: 

190 queryset = queryset.filter(project__owner=request.user) 

191 elif type == ViewType.SHARED: 

192 queryset = queryset.filter(project__access_list__user=request.user).exclude(project__owner=request.user) 

193 elif type == ViewType.ALL: 

194 pass 

195 

196 queryset = self._with_current_user_access( 

197 self._with_economics_study_count(queryset), 

198 request.user, 

199 ) 

200 serializer = self.get_serializer(queryset, many=True) 

201 return Response(serializer.data) 

202 

203 @extend_schema( 

204 parameters=[ 

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

206 ], 

207 responses=CardPreviewsSerializer, 

208 ) 

209 @action(detail=False, methods=["get"], url_path="card-previews") 

210 def card_previews(self, request): 

211 raw_flowsheet_ids = request.query_params.get("flowsheets", "") 

212 try: 

213 requested_ids = [ 

214 int(value) 

215 for value in raw_flowsheet_ids.split(",") 

216 if value.strip() 

217 ] 

218 except ValueError: 

219 return Response({"error": "Invalid flowsheet id"}, status=status.HTTP_400_BAD_REQUEST) 

220 

221 if not requested_ids: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true

222 return Response({"previews": []}) 

223 

224 flowsheets = list( 

225 self._with_current_user_access( 

226 self._filter_to_current_user_preview_access( 

227 Flowsheet.objects.filter(id__in=requested_ids).select_related( 

228 "current_state__root_grouping" 

229 ), 

230 request.user, 

231 ), 

232 request.user, 

233 ) 

234 ) 

235 root_group_ids = [ 

236 flowsheet.current_state.root_grouping_id 

237 for flowsheet in flowsheets 

238 if flowsheet.current_state_id is not None 

239 and flowsheet.current_state.root_grouping_id is not None 

240 ] 

241 graphics_by_group = {group_id: [] for group_id in root_group_ids} 

242 fallback_graphics_by_flowsheet = {} 

243 

244 if root_group_ids: 

245 graphics = ( 

246 GraphicObject.objects.filter( 

247 group_id__in=root_group_ids, 

248 visible=True, 

249 simulationObject__is_deleted=False, 

250 ) 

251 .select_related("simulationObject", "simulationObject__grouping") 

252 .prefetch_related( 

253 Prefetch( 

254 "simulationObject__connectedPorts", 

255 queryset=Port.objects.select_related("unitOp"), 

256 ) 

257 ) 

258 .annotate( 

259 preview_row=Window( 

260 expression=RowNumber(), 

261 partition_by=[F("group_id")], 

262 order_by=[F("y").asc(), F("x").asc(), F("id").asc()], 

263 ) 

264 ) 

265 .filter(preview_row__lte=MAX_CARD_PREVIEW_OBJECTS) 

266 .order_by("group_id", "y", "x", "id") 

267 ) 

268 for graphic in graphics: 

269 graphics_by_group.setdefault(graphic.group_id, []).append(graphic) 

270 

271 fallback_state_ids = [ 

272 flowsheet.current_state_id 

273 for flowsheet in flowsheets 

274 if not graphics_by_group.get(flowsheet.current_state.root_grouping_id) 

275 ] 

276 if fallback_state_ids: 

277 fallback_graphics = ( 

278 GraphicObject.objects.filter( 

279 flowsheet_state_id__in=fallback_state_ids, 

280 visible=True, 

281 simulationObject__is_deleted=False, 

282 ) 

283 .select_related( 

284 "flowsheet_state", 

285 "simulationObject", 

286 "simulationObject__grouping", 

287 ) 

288 .prefetch_related( 

289 Prefetch( 

290 "simulationObject__connectedPorts", 

291 queryset=Port.objects.select_related("unitOp"), 

292 ) 

293 ) 

294 .annotate( 

295 preview_row=Window( 

296 expression=RowNumber(), 

297 partition_by=[F("flowsheet_state_id")], 

298 order_by=[F("y").asc(), F("x").asc(), F("id").asc()], 

299 ) 

300 ) 

301 .filter(preview_row__lte=MAX_CARD_PREVIEW_OBJECTS) 

302 .order_by("flowsheet_state_id", "y", "x", "id") 

303 ) 

304 for graphic in fallback_graphics: 

305 fallback_graphics_by_flowsheet.setdefault( 

306 graphic.flowsheet_state.flowsheet_id, 

307 [], 

308 ).append(graphic) 

309 

310 all_preview_graphics = [ 

311 graphic 

312 for grouped_graphics in ( 

313 list(graphics_by_group.values()) 

314 + list(fallback_graphics_by_flowsheet.values()) 

315 ) 

316 for graphic in grouped_graphics 

317 ] 

318 placements_by_endpoint = Grouping.get_anchor_placements_for_graphics( 

319 all_preview_graphics, 

320 ) 

321 

322 previews_by_id = {} 

323 for flowsheet in flowsheets: 

324 graphics = graphics_by_group.get( 

325 flowsheet.current_state.root_grouping_id, 

326 [], 

327 ) or fallback_graphics_by_flowsheet.get(flowsheet.id, []) 

328 connections = [ 

329 connection.model_dump(include=CARD_PREVIEW_CONNECTION_FIELDS) 

330 for connection in flowsheet.current_state.root_grouping.get_connections_for_graphics( 

331 graphics, 

332 placements_by_endpoint, 

333 ) 

334 ] 

335 

336 previews_by_id[flowsheet.id] = { 

337 "flowsheet": flowsheet.id, 

338 "graphicsObjects": [ 

339 { 

340 "id": graphic.id, 

341 "simulationObject": { 

342 "id": graphic.simulationObject_id, 

343 "componentName": graphic.simulationObject.componentName, 

344 "objectType": graphic.simulationObject.objectType, 

345 }, 

346 "is_flipped": graphic.flipped, 

347 "visible": graphic.visible, 

348 "x": graphic.x, 

349 "y": graphic.y, 

350 "height": graphic.height, 

351 "width": graphic.width, 

352 "rotation": graphic.rotation, 

353 "flipped": graphic.flipped, 

354 "flowsheet": graphic.flowsheet_state.flowsheet_id, 

355 "group": graphic.group_id, 

356 } 

357 for graphic in graphics 

358 ], 

359 "connections": connections, 

360 } 

361 

362 return Response({ 

363 "previews": [ 

364 previews_by_id[flowsheet_id] 

365 for flowsheet_id in requested_ids 

366 if flowsheet_id in previews_by_id 

367 ] 

368 }) 

369 

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

371 try: 

372 instance = ( 

373 self._with_current_user_access( 

374 self._with_economics_study_count( 

375 Flowsheet.objects.select_related("owner", "project") 

376 ), 

377 request.user, 

378 ) 

379 .get(id=kwargs["pk"]) 

380 ) 

381 except Flowsheet.DoesNotExist: 

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

383 

384 # For project-backed flowsheets, check for project access. 

385 if instance.project_id is not None: 

386 user_access_entries = getattr(instance.project, "current_user_access_entries", []) 

387 has_direct_access = request.user.id == instance.project.owner_id or bool(user_access_entries) 

388 

389 if has_direct_access: 

390 serializer = self.get_serializer(instance) 

391 return Response(serializer.data) 

392 

393 # For template flowsheets, check for template access. 

394 if instance.flowsheet_template_type == FlowsheetTemplateType.PublicTemplate and request.user.is_staff: 

395 # Allow admins to view contents of public templates 

396 serializer = self.get_serializer(instance) 

397 return Response(serializer.data) 

398 

399 return Response({"error": "You do not have access to this flowsheet"}, status=status.HTTP_403_FORBIDDEN) 

400 

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

402 project_id = request.data.get("project") 

403 project = None 

404 

405 # Prevent creating a flowsheet without a project 

406 if project_id is None: 

407 return Response( 

408 {"error": "Project is required when creating a flowsheet."}, 

409 status=status.HTTP_400_BAD_REQUEST, 

410 ) 

411 

412 project = Project.objects.filter(pk=project_id).first() 

413 if project is None: 

414 return Response( 

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

416 status=status.HTTP_404_NOT_FOUND, 

417 ) 

418 

419 # Validate project write access (owner or sharee) 

420 has_project_write_access = ( 

421 project.owner_id == request.user.id 

422 or AccessTable.objects.filter( 

423 user=request.user, 

424 project=project, 

425 read_only=False, 

426 ).exists() 

427 ) 

428 if not has_project_write_access: 

429 return Response( 

430 {"error": "You can only create flowsheets in projects you can edit."}, 

431 status=status.HTTP_403_FORBIDDEN, 

432 ) 

433 

434 flowsheet_serializer = FlowsheetSerializer( 

435 data=request.data, context={"request": request}) 

436 flowsheet_serializer.is_valid(raise_exception=True) 

437 flowsheet = flowsheet_serializer.save(project=project) 

438 

439 if flowsheet.project_id: 439 ↛ 442line 439 didn't jump to line 442 because the condition on line 439 was always true

440 flowsheet.project.set_active_flowsheet(flowsheet) 

441 

442 return Response(flowsheet_serializer.data, status=status.HTTP_201_CREATED) 

443 

444 def _is_read_only_sharee(self, user, flowsheet_id: int) -> bool: 

445 """ 

446 Lightweight helper for the flowsheet record itself, which is not filtered 

447 through AccessControlManager like flowsheet-owned child models are. 

448 """ 

449 flowsheet = ( 

450 Flowsheet.objects 

451 .select_related("project") 

452 .only("project_id") 

453 .filter(id=flowsheet_id) 

454 .first() 

455 ) 

456 if flowsheet is None or flowsheet.project_id is None: 456 ↛ 457line 456 didn't jump to line 457 because the condition on line 456 was never true

457 return False 

458 

459 access = AccessTable.objects.filter( 

460 user=user, 

461 project_id=flowsheet.project_id, 

462 ).values("read_only").first() 

463 return bool(access and access["read_only"]) 

464 

465 def update(self, request, *args, **kwargs): 

466 if "project" in request.data: 

467 return Response( 

468 {"error": "Flowsheets cannot be moved between projects."}, 

469 status=status.HTTP_400_BAD_REQUEST, 

470 ) 

471 

472 if self._is_read_only_sharee(request.user, kwargs["pk"]): 472 ↛ 473line 472 didn't jump to line 473 because the condition on line 472 was never true

473 return Response( 

474 {"error": "This flowsheet is shared with read-only access."}, 

475 status=status.HTTP_403_FORBIDDEN, 

476 ) 

477 

478 return super().update(request, *args, **kwargs) 

479 

480 def perform_update(self, serializer): 

481 """Save flowsheet metadata without treating it as a canvas mutation. 

482 

483 Renaming, starring, or binning a flowsheet is outside the canvas edit 

484 stack. These independent metadata changes must not invalidate earlier 

485 reversible object/property operations. 

486 """ 

487 serializer.save() 

488 

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

490 if "project" in request.data: 

491 return Response( 

492 {"error": "Flowsheets cannot be moved between projects."}, 

493 status=status.HTTP_400_BAD_REQUEST, 

494 ) 

495 

496 if self._is_read_only_sharee(request.user, kwargs["pk"]): 

497 return Response( 

498 {"error": "This flowsheet is shared with read-only access."}, 

499 status=status.HTTP_403_FORBIDDEN, 

500 ) 

501 

502 return super().partial_update(request, *args, **kwargs) 

503 

504 @extend_schema(responses={200: DestroyFlowsheetResponseSerializer}) 

505 @transaction.atomic 

506 def destroy(self, request, *args, **kwargs): 

507 if self._is_read_only_sharee(request.user, kwargs["pk"]): 

508 return Response( 

509 {"error": "This flowsheet is shared with read-only access."}, 

510 status=status.HTTP_403_FORBIDDEN, 

511 ) 

512 

513 instance = self.get_object() 

514 try: 

515 replacement = delete_regular_flowsheet(candidate=instance) 

516 except Flowsheet.DoesNotExist: 

517 return Response( 

518 {"error": "Flowsheet not found."}, 

519 status=status.HTTP_404_NOT_FOUND, 

520 ) 

521 except FlowsheetNotRegularError as exc: 

522 return Response( 

523 {"error": str(exc)}, 

524 status=status.HTTP_400_BAD_REQUEST, 

525 ) 

526 

527 return Response( 

528 {"active_flowsheet_id": replacement.id}, 

529 status=status.HTTP_200_OK, 

530 ) 

531 

532 @action(detail=True, methods=['post'], url_path="set-active") 

533 def set_active(self, request, pk=None): 

534 flowsheet = self.get_object() 

535 

536 # Check for write access 

537 if not has_flowsheet_write_access(request.user, flowsheet.id): 537 ↛ 543line 537 didn't jump to line 543 because the condition on line 537 was always true

538 return Response( 

539 {"error": "You do not have write access to this flowsheet."}, 

540 status=status.HTTP_403_FORBIDDEN, 

541 ) 

542 

543 if flowsheet.project_id: 

544 try: 

545 flowsheet.project.set_active_flowsheet(flowsheet) 

546 except Flowsheet.DoesNotExist: 

547 return Response( 

548 {"error": "Flowsheet not found."}, 

549 status=status.HTTP_404_NOT_FOUND, 

550 ) 

551 except FlowsheetNotRegularError as exc: 

552 return Response( 

553 {"error": str(exc)}, 

554 status=status.HTTP_400_BAD_REQUEST, 

555 ) 

556 

557 return Response( 

558 {"message": "Active flowsheet updated"}, 

559 status=status.HTTP_200_OK, 

560 ) 

561 

562 @extend_schema( 

563 methods=["GET"], 

564 parameters=[ 

565 OpenApiParameter( 

566 name="page", 

567 required=False, 

568 type=OpenApiTypes.INT, 

569 description="Revision page number. Each page contains 10 revisions.", 

570 ), 

571 OpenApiParameter( 

572 name="category", 

573 required=False, 

574 type=OpenApiTypes.STR, 

575 enum=["all", "manual", "solve"], 

576 description="Filter saved versions by how they were created.", 

577 ), 

578 ], 

579 responses={200: FlowsheetRevisionListSerializer}, 

580 description="List the current state and retained revisions newest first.", 

581 ) 

582 @extend_schema( 

583 methods=["POST"], 

584 request=CreateFlowsheetRevisionSerializer, 

585 responses={ 

586 201: FlowsheetRevisionSerializer, 

587 422: FlowsheetCloneValidationErrorSerializer, 

588 }, 

589 description="Save the current durable flowsheet configuration as a revision.", 

590 ) 

591 @action(detail=True, methods=["get", "post"], url_path="revisions") 

592 def revisions(self, request, pk=None): 

593 """List or create immutable revisions under a stable flowsheet URL.""" 

594 

595 flowsheet = self.get_object() 

596 if request.method == "GET": 

597 query_serializer = ListFlowsheetRevisionsQuerySerializer( 

598 data=request.query_params 

599 ) 

600 query_serializer.is_valid(raise_exception=True) 

601 try: 

602 revisions = list_revisions( 

603 flowsheet=flowsheet, 

604 category=query_serializer.validated_data["category"], 

605 ) 

606 except RevisionOperationError as exc: 

607 return Response( 

608 {"error": str(exc)}, 

609 status=status.HTTP_400_BAD_REQUEST, 

610 ) 

611 paginator = Paginator(revisions, REVISION_PAGE_SIZE) 

612 revision_page = paginator.get_page(request.query_params.get("page", 1)) 

613 return Response( 

614 { 

615 "current": CurrentFlowsheetStateSerializer( 

616 flowsheet.current_state 

617 ).data, 

618 "has_active_tasks": has_active_tasks( 

619 flowsheet_id=flowsheet.pk 

620 ), 

621 "count": paginator.count, 

622 "page": revision_page.number, 

623 "page_size": REVISION_PAGE_SIZE, 

624 "pages": paginator.num_pages, 

625 "revisions": FlowsheetRevisionSerializer( 

626 revision_page.object_list, 

627 many=True, 

628 ).data, 

629 } 

630 ) 

631 

632 if not has_flowsheet_write_access(request.user, flowsheet.pk): 

633 return Response( 

634 {"error": "You do not have write access to this flowsheet."}, 

635 status=status.HTTP_403_FORBIDDEN, 

636 ) 

637 input_serializer = CreateFlowsheetRevisionSerializer(data=request.data) 

638 input_serializer.is_valid(raise_exception=True) 

639 try: 

640 revision = create_revision( 

641 flowsheet_id=flowsheet.pk, 

642 kind=FlowsheetRevisionKind.MANUAL, 

643 created_by=request.user, 

644 label=input_serializer.validated_data.get("label"), 

645 repair_legacy_formula_units=input_serializer.validated_data[ 

646 "repair_legacy_formula_units" 

647 ], 

648 ) 

649 except ActiveTasksError as exc: 

650 return Response( 

651 { 

652 "code": ACTIVE_TASKS_ERROR_CODE, 

653 "error": str(exc), 

654 }, 

655 status=status.HTTP_409_CONFLICT, 

656 ) 

657 except FlowsheetStateCloneError as exc: 

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

659 raise 

660 return Response( 

661 clone_validation_error_data(exc, flowsheet=flowsheet), 

662 status=status.HTTP_422_UNPROCESSABLE_ENTITY, 

663 ) 

664 except RevisionOperationError as exc: 

665 return Response( 

666 {"error": str(exc)}, 

667 status=status.HTTP_400_BAD_REQUEST, 

668 ) 

669 return Response( 

670 FlowsheetRevisionSerializer(revision).data, 

671 status=status.HTTP_201_CREATED, 

672 ) 

673 

674 @extend_schema( 

675 methods=["GET"], 

676 operation_id="core_flowsheets_revision_detail_retrieve", 

677 request=None, 

678 responses={200: FlowsheetRevisionSerializer}, 

679 description="Retrieve one retained flowsheet revision.", 

680 ) 

681 @extend_schema( 

682 methods=["PATCH"], 

683 request=UpdateFlowsheetRevisionSerializer, 

684 responses={200: FlowsheetRevisionSerializer}, 

685 description="Rename one retained flowsheet revision.", 

686 ) 

687 @extend_schema( 

688 methods=["DELETE"], 

689 request=None, 

690 responses={204: None}, 

691 description="Permanently delete one retained flowsheet revision.", 

692 ) 

693 @action( 

694 detail=True, 

695 methods=["delete", "get", "patch"], 

696 url_path=r"revisions/(?P<state_id>[^/.]+)", 

697 ) 

698 def delete_revision(self, request, pk=None, state_id=None): 

699 """Retrieve, rename, or delete a retained revision by stable state URL.""" 

700 

701 flowsheet = self.get_object() 

702 if request.method == "GET": 

703 try: 

704 revision = FlowsheetState.objects.get( 

705 pk=int(state_id), 

706 flowsheet=flowsheet, 

707 role=FlowsheetStateRole.REVISION, 

708 ) 

709 except (FlowsheetState.DoesNotExist, TypeError, ValueError): 

710 return Response( 

711 {"error": "Revision not found."}, 

712 status=status.HTTP_404_NOT_FOUND, 

713 ) 

714 return Response(FlowsheetRevisionSerializer(revision).data) 

715 

716 if not has_flowsheet_write_access(request.user, flowsheet.pk): 

717 return Response( 

718 {"error": "You do not have write access to this flowsheet."}, 

719 status=status.HTTP_403_FORBIDDEN, 

720 ) 

721 if request.method == "PATCH": 

722 input_serializer = UpdateFlowsheetRevisionSerializer(data=request.data) 

723 input_serializer.is_valid(raise_exception=True) 

724 try: 

725 revision = rename_revision( 

726 flowsheet_id=flowsheet.pk, 

727 revision_state_id=int(state_id), 

728 label=input_serializer.validated_data["label"], 

729 ) 

730 except (RevisionOperationError, ValueError) as exc: 

731 return Response( 

732 {"error": str(exc)}, 

733 status=status.HTTP_400_BAD_REQUEST, 

734 ) 

735 return Response(FlowsheetRevisionSerializer(revision).data) 

736 

737 try: 

738 delete_flowsheet_revision( 

739 flowsheet_id=flowsheet.pk, 

740 revision_state_id=int(state_id), 

741 ) 

742 except (RevisionOperationError, ValueError) as exc: 

743 return Response( 

744 {"error": str(exc)}, 

745 status=status.HTTP_400_BAD_REQUEST, 

746 ) 

747 return Response(status=status.HTTP_204_NO_CONTENT) 

748 

749 @extend_schema( 

750 request=None, 

751 responses={200: RestoreFlowsheetRevisionResponseSerializer}, 

752 description="Replace Current with an immutable revision.", 

753 ) 

754 @action( 

755 detail=True, 

756 methods=["post"], 

757 url_path=r"revisions/(?P<state_id>[^/.]+)/restore", 

758 ) 

759 def restore_revision(self, request, pk=None, state_id=None): 

760 """Replace current content while preserving stable flowsheet identity.""" 

761 

762 flowsheet = self.get_object() 

763 if not has_flowsheet_write_access(request.user, flowsheet.pk): 763 ↛ 764line 763 didn't jump to line 764 because the condition on line 763 was never true

764 return Response( 

765 {"error": "You do not have write access to this flowsheet."}, 

766 status=status.HTTP_403_FORBIDDEN, 

767 ) 

768 try: 

769 result = restore_flowsheet_revision( 

770 flowsheet_id=flowsheet.pk, 

771 revision_state_id=int(state_id), 

772 restored_by=request.user, 

773 ) 

774 except ActiveTasksError as exc: 

775 return Response( 

776 { 

777 "code": ACTIVE_TASKS_ERROR_CODE, 

778 "error": str(exc), 

779 }, 

780 status=status.HTTP_409_CONFLICT, 

781 ) 

782 except (RevisionOperationError, ValueError) as exc: 

783 return Response( 

784 {"error": str(exc)}, 

785 status=status.HTTP_400_BAD_REQUEST, 

786 ) 

787 result.flowsheet.refresh_from_db() 

788 return Response( 

789 { 

790 "flowsheet": FlowsheetSerializer( 

791 result.flowsheet, 

792 context=self.get_serializer_context(), 

793 ).data, 

794 "current": CurrentFlowsheetStateSerializer( 

795 result.current_state 

796 ).data, 

797 "cleared_calculated_data_categories": list( 

798 result.cleared_calculated_data_categories 

799 ), 

800 "restored_from_revision_number": ( 

801 result.current_state.restored_from_revision_number 

802 ), 

803 "warnings": result.warnings, 

804 } 

805 )