diff --git a/projects/management/commands/update_project_deadlines.py b/projects/management/commands/update_project_deadlines.py index 45b8b34a..76dda4cf 100644 --- a/projects/management/commands/update_project_deadlines.py +++ b/projects/management/commands/update_project_deadlines.py @@ -25,7 +25,6 @@ def handle(self, *args, **options): projects = Project.objects.all() for idx, project in enumerate(projects): - log.info(f'Updating project "{project.name}" deadlines ({idx+1}/{len(projects)})') with transaction.atomic(): project.update_deadlines(initial=True) project.save() \ No newline at end of file diff --git a/projects/models/project.py b/projects/models/project.py index 17ac2ef4..3e80acda 100644 --- a/projects/models/project.py +++ b/projects/models/project.py @@ -267,8 +267,64 @@ def set_attribute_data(self, data): self.attribute_data = {} self.update_attribute_data(data) - def update_attribute_data(self, data, confirmed_fields=None, fake=False): + def _should_skip_attribute_update(self, identifier, confirmed_fields, fake, locked_attributes_data): + """Check if attribute update should be skipped.""" + if identifier in confirmed_fields: + return True + if fake and locked_attributes_data and identifier in locked_attributes_data: + return True + return False + + def _update_geometry_attribute(self, attribute, value): + """Handle geometry type attribute updates.""" + geometry_query_params = {"attribute": attribute, "project": self} + if not value: + ProjectAttributeMultipolygonGeometry.objects.filter( + **geometry_query_params + ).delete() + else: + ProjectAttributeMultipolygonGeometry.objects.update_or_create( + **geometry_query_params, defaults={"geometry": value} + ) + + def _update_file_or_image_attribute(self, identifier, value): + """Handle file or image type attribute updates.""" + if not value: + self.attribute_data.pop(identifier, None) + + def _update_fieldset_attribute(self, identifier, attribute, value): + """Handle fieldset type attribute updates.""" + serialized_value = attribute.serialize_value(value) + if not serialized_value: + self.attribute_data.pop(identifier, None) + else: + self.attribute_data[identifier] = serialized_value + + def _update_standard_attribute(self, identifier, attribute, value): + """Handle standard type attribute updates.""" + serialized_value = attribute.serialize_value(value) + if serialized_value is not None: + self.attribute_data[identifier] = serialized_value + else: + self.attribute_data.pop(identifier, None) + + def _process_single_attribute(self, identifier, value, attribute): + """Process a single attribute update based on its type.""" + self.attribute_data[identifier] = value + + if attribute.value_type == Attribute.TYPE_GEOMETRY: + self._update_geometry_attribute(attribute, value) + elif attribute.value_type in [Attribute.TYPE_IMAGE, Attribute.TYPE_FILE]: + self._update_file_or_image_attribute(identifier, value) + elif attribute.value_type in [Attribute.TYPE_FIELDSET, Attribute.TYPE_INFO_FIELDSET]: + self._update_fieldset_attribute(identifier, attribute, value) + else: + self._update_standard_attribute(identifier, attribute, value) + + def update_attribute_data(self, data, confirmed_fields=None, fake=False, locked_attributes_data=None): + from datetime import datetime confirmed_fields = confirmed_fields or [] + locked_attributes_data = locked_attributes_data or {} if not isinstance(self.attribute_data, dict): self.attribute_data = {} @@ -283,36 +339,10 @@ def update_attribute_data(self, data, confirmed_fields=None, fake=False): log.warning(f"Attribute {identifier} not found") continue - if identifier in confirmed_fields: - continue # Skip silently a value that is in confirmed_fields they should not move because already confirmed - - self.attribute_data[identifier] = value - if attribute.value_type == Attribute.TYPE_GEOMETRY: - geometry_query_params = {"attribute": attribute, "project": self} - if not value: - ProjectAttributeMultipolygonGeometry.objects.filter( - **geometry_query_params - ).delete() - else: - ProjectAttributeMultipolygonGeometry.objects.update_or_create( - **geometry_query_params, defaults={"geometry": value} - ) - elif attribute.value_type in [Attribute.TYPE_IMAGE, Attribute.TYPE_FILE]: - if not value: - self.attribute_data.pop(identifier, None) - elif attribute.value_type in [Attribute.TYPE_FIELDSET, Attribute.TYPE_INFO_FIELDSET]: - serialized_value = attribute.serialize_value(value) - if not serialized_value: - self.attribute_data.pop(identifier, None) - else: - self.attribute_data[identifier] = serialized_value - else: - serialized_value = attribute.serialize_value(value) + if self._should_skip_attribute_update(identifier, confirmed_fields, fake, locked_attributes_data): + continue - if serialized_value is not None: - self.attribute_data[identifier] = serialized_value - else: - self.attribute_data.pop(identifier, None) + self._process_single_attribute(identifier, value, attribute) return True @@ -547,15 +577,21 @@ def update_deadlines(self, user=None, initial=False, preview_attributes={}, conf # Update attribute-based deadlines dls_to_update = [] + for dl in self.deadlines.all().select_related("deadline__attribute"): if not dl.deadline.attribute: continue + # Skip locked/confirmed fields - they should not be updated + if dl.deadline.attribute.identifier in confirmed_fields: + continue + value = self.attribute_data.get(dl.deadline.attribute.identifier) value = value if value != 'null' else None if dl.date != value: dl.date = value dls_to_update.append(dl) + self.deadlines.bulk_update(dls_to_update, ['date']) # Calculate automatic values for newly added deadlines self._set_calculated_deadlines( @@ -703,11 +739,9 @@ def clear_data_by_data_retention_plan(self, data_retention_plan): self.attribute_data[attribute.identifier] = None updated = True if updated: - log.info(f"Clearing data by data_retention_plan '{data_retention_plan}' from project '{self}'") self.save() def clear_audit_log_data(self): - log.info(f"Clearing audit log data from project '{self}'") LogEntry.objects.filter(object_id=str(self.pk)).delete() # Clears django-admin logs from django_admin_log table ActStreamAction.objects.filter(target_object_id=str(self.pk)).delete() # Clear audit logs from actstream_action table diff --git a/projects/serializers/project.py b/projects/serializers/project.py index 715d89d0..7d7a8e27 100644 --- a/projects/serializers/project.py +++ b/projects/serializers/project.py @@ -1784,8 +1784,15 @@ def set_initial_data(self, attribute_data, validated_data): pass def update(self, instance: Project, validated_data: dict) -> Project: + + + attribute_data = validated_data.pop("attribute_data", {}) confirmed_fields = self.context["confirmed_fields"] + locked_fields = self.context.get("locked_fields", []) + # Combine confirmed and locked fields into single protected list + protected_fields = list(set(confirmed_fields + locked_fields)) + subtype = validated_data.get("subtype") subtype_changed = subtype is not None and subtype != instance.subtype phase = validated_data.get("phase") @@ -1826,7 +1833,15 @@ def update(self, instance: Project, validated_data: dict) -> Project: self.update_initial_data(validated_data) if attribute_data: - instance.update_attribute_data(attribute_data, confirmed_fields=confirmed_fields) + # Check if this is a fake/preview request + is_fake = hasattr(self.context.get("request"), "_fake") and self.context["request"]._fake + locked_attrs_data = self.context.get("locked_attributes_data", {}) + instance.update_attribute_data( + attribute_data, + confirmed_fields=protected_fields, + fake=is_fake, + locked_attributes_data=locked_attrs_data + ) project = super(ProjectSerializer, self).update(instance, validated_data) @@ -1843,9 +1858,9 @@ def update(self, instance: Project, validated_data: dict) -> Project: project.update_attribute_data(cleared_attributes) self.log_updates_attribute_data(cleared_attributes) project.deadlines.all().delete() - project.update_deadlines(user=user, preview_attributes=attribute_data, confirmed_fields=confirmed_fields) + project.update_deadlines(user=user, preview_attributes=attribute_data, confirmed_fields=protected_fields) elif should_update_deadlines: - project.update_deadlines(user=user, preview_attributes=attribute_data, confirmed_fields=confirmed_fields) + project.update_deadlines(user=user, preview_attributes=attribute_data, confirmed_fields=protected_fields) project.deadlines.filter(deadline__attribute__identifier__in=attribute_data.keys())\ .update(edited=timezone.now()) diff --git a/projects/views.py b/projects/views.py index 1243aa5d..a482dd2c 100644 --- a/projects/views.py +++ b/projects/views.py @@ -28,6 +28,7 @@ from rest_framework.parsers import MultiPartParser from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.response import Response +from rest_framework.exceptions import ValidationError from rest_framework.views import APIView from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework_extensions.mixins import NestedViewSetMixin @@ -324,6 +325,10 @@ def get_serializer_context(self): context = super().get_serializer_context() context["action"] = self.action context["confirmed_fields"] = self.request.data.get('confirmed_fields', []) + # Extract locked attributes (temporary, not saved to DB) and add field names and VALUES to context + locked_attributes = self.request.data.get('lockedAttributes', {}) + context["locked_fields"] = list(locked_attributes.keys()) if isinstance(locked_attributes, dict) else [] + context["locked_attributes_data"] = locked_attributes if isinstance(locked_attributes, dict) else {} if self.action == "list": context["project_schedule_cache"] = \ @@ -937,12 +942,20 @@ def attribute_data(self, request): def update(self, request, *args, **kwargs): fake = request.query_params.get('fake', False) + # Normalize fake flag to boolean + is_fake = str(fake).lower() in ['1', 'true', 't', 'yes'] # Store the original confirmed_fields before calling update # should prevent confirmed fields from moving when updating or validating confirmed_fields = request.data.get('confirmed_fields', []) - original_attribute_data = request.data.get('attribute_data', {}) + # Capture the persisted snapshot before any mutation + try: + project_instance = self.get_object() + original_attribute_data = dict(project_instance.attribute_data or {}) + except Exception: + original_attribute_data = {} + locked_attributes = request.data.get('lockedAttributes', {}) - if not fake: + if not is_fake: # Actual update logic that saves to db result = super().update(request, *args, **kwargs) @@ -954,17 +967,74 @@ def update(self, request, *args, **kwargs): # Validation logic ?fake # Run update in 'ghost' mode where no changes are applied to database but result is returned with transaction.atomic(): - result = super().update(request, *args, **kwargs) - - # Before returning, check if we need to restore original values for confirmed fields - if hasattr(result, 'data') and confirmed_fields and 'attribute_data' in result.data: - # Restore original values for confirmed fields - for field in confirmed_fields: - if field in original_attribute_data and field in result.data['attribute_data']: - result.data['attribute_data'][field] = original_attribute_data[field] - #Prevents saving anything to database but returns values that have been changed by validation to frontend - transaction.set_rollback(True) - return result + # Only use new locking logic if lockedAttributes is not empty + if locked_attributes and isinstance(locked_attributes, dict) and len(locked_attributes) > 0: + try: + result = super().update(request, *args, **kwargs) + + # Before returning, check if we need to restore original values for confirmed fields + if hasattr(result, 'data') and confirmed_fields and 'attribute_data' in result.data: + # Restore original values for confirmed fields + for field in confirmed_fields: + if field in original_attribute_data and field in result.data['attribute_data']: + result.data['attribute_data'][field] = original_attribute_data[field] + + # If locked fields were attempted to be changed during preview, return structured error and echo original payload + try: + resp_attr = result.data.get('attribute_data', {}) if hasattr(result, 'data') else {} + locked_conflicts = [] + if isinstance(locked_attributes, dict) and isinstance(resp_attr, dict): + for k, v in locked_attributes.items(): + if k in resp_attr and resp_attr.get(k) != v: + locked_conflicts.append(k) + if locked_conflicts: + transaction.set_rollback(True) + return Response({ + 'locked_fields': locked_conflicts, + 'attribute_data': original_attribute_data + }, status=status.HTTP_400_BAD_REQUEST) + except Exception as exc: + log.error(f"[LOCK_DEBUG] Error while evaluating locked field conflicts: {exc}") + + # Prevent saving anything to database but returns values for normal preview success + transaction.set_rollback(True) + return result + + except ValidationError as ve: + # ValidationError during preview - extract affected fields and return locked_fields format + transaction.set_rollback(True) + + # Extract field names from ValidationError detail + affected_fields = [] + if hasattr(ve, 'detail') and isinstance(ve.detail, dict): + # DRF ValidationError with field-level errors + if 'attribute_data' in ve.detail and isinstance(ve.detail['attribute_data'], dict): + affected_fields = list(ve.detail['attribute_data'].keys()) + else: + affected_fields = list(ve.detail.keys()) + + # Return locked_fields response format for frontend + if affected_fields: + return Response({ + 'locked_fields': affected_fields, + 'attribute_data': original_attribute_data + }, status=status.HTTP_400_BAD_REQUEST) + else: + # Re-raise if we can't determine affected fields + raise + else: + # No locked attributes - use original validation logic + result = super().update(request, *args, **kwargs) + + # Before returning, check if we need to restore original values for confirmed fields + if hasattr(result, 'data') and confirmed_fields and 'attribute_data' in result.data: + # Restore original values for confirmed fields + for field in confirmed_fields: + if field in original_attribute_data and field in result.data['attribute_data']: + result.data['attribute_data'][field] = original_attribute_data[field] + # Prevents saving anything to database but returns values that have been changed by validation to frontend + transaction.set_rollback(True) + return result class ProjectPhaseViewSet(viewsets.ReadOnlyModelViewSet):