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
« 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
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}
69class ImportFlowsheetSerializer(serializers.Serializer):
70 data = serializers.JSONField()
73class CardPreviewSimulationObjectSerializer(serializers.Serializer):
74 id = serializers.IntegerField()
75 componentName = serializers.CharField()
76 objectType = serializers.CharField()
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)
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)
103class CardPreviewSerializer(serializers.Serializer):
104 flowsheet = serializers.IntegerField()
105 graphicsObjects = CardPreviewGraphicObjectSerializer(many=True)
106 connections = CardPreviewConnectionSerializer(many=True)
109class CardPreviewsSerializer(serializers.Serializer):
110 previews = CardPreviewSerializer(many=True)
113class DestroyFlowsheetResponseSerializer(serializers.Serializer):
114 active_flowsheet_id = serializers.IntegerField()
117class FlowsheetViewSet(HistoricalRevisionWriteGuardMixin, viewsets.ModelViewSet):
118 """
119 Viewset for the Flowsheet model itself.
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
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 )
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 )
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 )
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.
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()
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()
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 )
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)
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
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)
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)
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": []})
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 = {}
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)
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)
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 )
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 ]
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 }
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 })
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)
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)
389 if has_direct_access:
390 serializer = self.get_serializer(instance)
391 return Response(serializer.data)
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)
399 return Response({"error": "You do not have access to this flowsheet"}, status=status.HTTP_403_FORBIDDEN)
401 def create(self, request, *args, **kwargs):
402 project_id = request.data.get("project")
403 project = None
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 )
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 )
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 )
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)
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)
442 return Response(flowsheet_serializer.data, status=status.HTTP_201_CREATED)
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
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"])
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 )
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 )
478 return super().update(request, *args, **kwargs)
480 def perform_update(self, serializer):
481 """Save flowsheet metadata without treating it as a canvas mutation.
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()
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 )
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 )
502 return super().partial_update(request, *args, **kwargs)
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 )
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 )
527 return Response(
528 {"active_flowsheet_id": replacement.id},
529 status=status.HTTP_200_OK,
530 )
532 @action(detail=True, methods=['post'], url_path="set-active")
533 def set_active(self, request, pk=None):
534 flowsheet = self.get_object()
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 )
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 )
557 return Response(
558 {"message": "Active flowsheet updated"},
559 status=status.HTTP_200_OK,
560 )
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."""
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 )
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 )
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."""
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)
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)
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)
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."""
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 )